Skip to content

fix(language): a comparison with the literal first is refused as the order it is - #594

Merged
FBumann merged 2 commits into
claude/expression-parser-language-split-kdqegz-arithmetic-wherefrom
claude/adoring-brahmagupta-ovwe3v-undecidable-message
Sep 21, 2026
Merged

FBumann merged 2 commits into
claude/expression-parser-language-split-kdqegz-arithmetic-wherefrom
claude/adoring-brahmagupta-ovwe3v-undecidable-message

Conversation

@FBumann

@FBumann FBumann commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Prompt: "Can we just fix the error message on main with a small pr?" — "Do the pr onto 566"

Note

The following content was generated by AI.

The refusal for two cases that compare a parameter against a literal told the author to compare one parameter against a literal. It now names the order they wrote it in and quotes the same test the other way round. Decidability is unchanged.

What this changes

capacity > 2 resolves to a ParameterComparison, which the exclusivity check reasons about: two cases over it prove apart and the model loads. The same test with the sides swapped resolves to an ArithmeticComparison, which is refused — with advice the author had already taken:

cases 'big' and 'small' cannot be told apart before the data arrives: it compares expressions, whose values only the data decides — compare one parameter against a literal, or precompute the test as a boolean parameter and test that.

Now:

cases 'big' and 'small' cannot be told apart before the data arrives: the literal is on the left, and a comparison is read as arithmetic there — write it as the same test the other way round, capacity > 2.

The number is quoted as the file wrote it — 2, not 2.0, and -2 keeps its sign — and the comparator is mirrored. Only that one shape is named: capacity > soc_initial keeps the general refusal, because two parameters genuinely carry no order before the data arrives.

Why this is on #566 and not on main

It cannot go on main: the message does not exist there. Arithmetic in a where arrives with #566 — origin/main has no ArithmeticComparison or ExpressionComparison in program.py and no such refusal in exclusivity.py. On main, 2 < capacity never reaches the exclusivity check at all; resolution refuses it earlier, accurately for that tree:

a where-comparison tests one name, relation column or position() against a literal or a second column, and a side here is arithmetic…

So #566 is what creates both the capability and the misleading message, and this sits on top of it.

What this does not do

It does not make the comparison decidable. 2 < capacity is still refused; it is only refused in words the author can act on. Teaching the check to read the plain shape out of an expression comparison is #593, which also has to land before ParameterComparison can be folded away — without it that fold would silently stop models loading.

Gates

pixi is refused by this environment's egress proxy, so the gates ran from a uv environment on Python 3.12. That is a departure from the "everything runs in a pixi environment" default.

gate result
pytest -n auto 1384 passed, 5 skipped (base: 1379 passed)
ruff check, ruff format clean
pyrefly check 0 errors
typos, reuse lint clean
compile-tex not run, for want of a TeX distribution

No generated file moves: the change is one refusal's wording, and no page or golden quotes it.

Mutation table
Guard Caught by
the plain-shape branch in the refusal test_a_literal_written_first_is_named_as_the_order_it_is and all three rows of test_the_rewrite_is_the_same_test_with_the_sides_swapped

Both tests were written against the old message first and watched to fail.

Coverage
  • test_a_literal_written_first_is_named_as_the_order_it_is — the message names the order, quotes the rewrite, and does not print 2.0 for a 2 the file wrote.
  • test_the_rewrite_is_the_same_test_with_the_sides_swapped — a signed literal, a fraction with a mirrored comparator, and !=, which is its own mirror.
  • test_a_comparison_of_real_expressions_keeps_the_general_refusal — the branch is narrow: two parameters still get the old sentence.
  • A quoted label is not handled, because it cannot reach here: the grammar takes a quoted value only on a comparison's right.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DBU3ocfqkHe99mWmxijw64


Generated by Claude Code

…order it is

A case `when:` comparing a parameter against a literal is decided, and two
cases over it prove apart. Written with the literal first it resolves to an
`ArithmeticComparison` instead, which the exclusivity check refuses as a
comparison of expressions — advising the author to "compare one parameter
against a literal", which is what they had already done.

The refusal now names the order and quotes the same test the other way round:
`2 < capacity` says to write `capacity > 2`. The number is quoted as the file
wrote it, sign included, and a comparator is mirrored. Only that shape is
named; a comparison of real expressions keeps the general refusal, and
`capacity > soc_initial` is still the data's to decide.

The decidability itself is not changed — #593 holds that, and it is the
prerequisite for collapsing the comparison leaves rather than work this PR
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBU3ocfqkHe99mWmxijw64
…than three

The rewrite shipped as `_expression_rewrite` over `_plain_pair` over
`_is_number`, plus a comparator table spread over eight lines — three names
and a layer for one sentence. It reads as one function: the shape test is a
boolean, and the table is a line.

Source drops from 52 added lines to 21. No behaviour changes; the tests are
untouched and still pass, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBU3ocfqkHe99mWmxijw64
@FBumann
FBumann merged commit 3cfc931 into claude/expression-parser-language-split-kdqegz-arithmetic-where Sep 21, 2026
5 checks passed
FBumann added a commit that referenced this pull request Sep 22, 2026
* chore(language): a where comparison is one grammar rule over the expression grammar's arithmetic

The where grammar's three comparison rules — a name against a literal,
two relation columns, and position() against an integer — are one rule,
side <op> side, where a side is the expression grammar's ARITHMETIC. What
a side is, resolution decides with the schema in hand, and it hands back
the same typed nodes with the same messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* test: the parser test names the validation class that holds the refusals it points at

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* chore(parser): the where depth measurement names the nodes it walks rather than Any

`explicit-any` is an error on `src/math_spec` since #572, and that PR took
only the fold's return type from #567, leaving `_nested` on this branch
with a signature whose `Any` no longer even imports. `_ParsedWhere` is the
union the measurement walks, which is #567's own answer for this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk

* feat(language): a where may compare arithmetic over parameters

Either side of a where comparison may be an expression over
parameters: arithmetic, a reduction, a pullback, a translation with
its edge, a macro, a named expression. A side is expanded, typed,
degree-checked and dim-checked as an expression is, and refused where
it names a variable or a dual. Two parameters compare the same way.

The resolved tree holds ArithmeticComparisonNode over the core syntax
tree, and lowering rebuilds every mask with ExpressionComparisonNode
over program expressions. A case when: comparing expressions is
refused as undecidable before the data arrives.

expressions.md sentence lengths: n 68, avg 14.0, median 13, over 25
words 3 (base: n 56, avg 13.0, median 13, over 25 words 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(language): a comparison of expressions answers what it reads once, on the program's form

The resolved form of the comparison had a second walk collecting the
parameters and relations its sides read, and nothing asks a resolved
mask that question: lowering rebuilds every mask before one reaches a
consumer. The arm is the assertion the typesetter already carries for
the lowered node. The one-line mask wrapper in lowering is inlined, and
the two validation classes for what a where side may be are one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(language): a namespace is built from its schema and carries it, so nothing passes both

Namespace(schema) reads every declaration at construction; the eight
argument constructor and the classmethod that was its only caller are
gone. expression_of, _check_expression and _named took the schema and a
namespace built from that same schema; they take the namespace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(language): the single-text doors live with the tests that are their only callers

expression_of and where_of had no caller in the package, and the two
assertions that named expression_of as the door now name
resolve_expression, which is the one the package walks through. The
plain form of a where comparison is read by one method: a name that is
a value against another is arithmetic, which used to be a second
question asked after the first. The "prefix a context once" rule has
one home in errors.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

* refactor(program): the branch's nodes follow the naming rule main now states

#585 renamed the program's where vocabulary and expression nodes on main. This
branch was written against the old names, and its own two comparison nodes
arrived with the suffix the rule reserves for the core AST. Applying the same
rename here first is what lets the base merge that follows agree line for line
rather than conflict on every one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP

* docs(reading): the predicate union says which member a program never carries

The `Predicate` union said it was what a lowered mask's `root` is built of.
That is false here: it also holds `ArithmeticComparison`, which lowering
rewrites into an `ExpressionComparison`, so a consumer walking a program meets
every other member and never that one.

These lines were #580's, which is where they were written. They are only true
where the two nodes exist, and this is the branch that adds them.

docs/reference/reading.md: the two sentences added are 11 and 9 words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP

* docs(reading): a comparison against a literal is not an expression comparison

A consumer walking a lowered mask gets ParameterComparison for `p_max > 5` and
ExpressionComparison for `1 * p_max > 5`, though both mask the same
coordinates. The page named only the second form.

Gates: pytest tests/test_docs.py tests/test_reading_page.py (34 passed), the
full suite (1383 passed, 1 skipped), mkdocs build --strict (clean, with the
docs.python.org inventory dropped for the run, which this environment's proxy
refuses with a 403), prettier --check (clean). Not run: typos, reuse lint,
taplo, zizmor, compile-tex, for want of the tools.

docs/reference/reading.md, by the measurement in the docs-writing skill:
n 55, avg 14.6, median 13, over25 7. Every long sentence predates this change;
the three added are 10, 20 and 9 words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJfEUsCDtwXuXV8CANCHuR

* fix(language): a comparison with the literal first is refused as the order it is (#594)

* fix(language): a where comparing two numbers, and a lone case comparing expressions, are refused at load

A comparison with a number on each side is decided before any data arrives,
and reached the program as an ExpressionComparison over two constants. A
cases: block of one case escaped the undecidable refusal, which only looked
at pairs. names_read on a mask over a cased entry dropped the data its
regions are decided by. The relation refusal still said every other
comparison tests a name against a literal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tCWoetBmjF1LpbTbjQY29

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants