Skip to content

feat(language): a where may compare arithmetic over parameters - #566

Merged
FBumann merged 14 commits into
mainfrom
claude/expression-parser-language-split-kdqegz-arithmetic-where
Sep 22, 2026
Merged

FBumann merged 14 commits into
mainfrom
claude/expression-parser-language-split-kdqegz-arithmetic-where

Conversation

@FBumann

@FBumann FBumann commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Rebuild it as two PRs on #562. First a chore(language) that lands the one atom and the dispatch with no behaviour change […]. Then #469's semantics become one new branch in that dispatch plus its lowering, docs and tests." — "I'm wondering however if there is potential for more reuse and simplification" — "is the .of(schema) even sth desirable? Why not extract all the information at creation of the Namespace?"

Prompt: "WOuld it be more usefull together with #566?" — "all 3"

Prompt: "Review the stack of 602 and below" — "Do 1-3"

Note

The following content was generated by AI.

On main, now that #565 and #585 have landed. Either side of a where comparison may be arithmetic over parameters: p_min <= 0.5 * p_max, sum(p_max, over=generator) >= peak, a pullback, a translation with its edge, a macro, a named expression. This is #469's semantics on the one-atom grammar, and it replaces #469.

What this changes

  • One new branch in the dispatch. _comparison sends a comparison to _expression_comparison where a side is not a plain leaf, or where a plain-looking side names a parameter or an expressions: entry against a value. There each side is expanded, typed, degree-checked and dim-checked as an expression is, and refused where it names a variable or a dual(). The transient "arithmetic on a side" refusal chore(language): a where comparison is one grammar rule over the expression grammar's arithmetic #565 introduced is gone, and so is the grammar-side arbitration feat(language): a where may compare arithmetic over parameters #469 needed (_reads_arithmetic as a parse condition, _is_plain, _nested over two vocabularies, the re-route inside _comparison).
  • Two parameters compare as arithmetic. feat(language): a where may compare arithmetic over parameters #469 admitted c <= 0.5 * k and kept refusing c <= k with "compares two parameters, which is not in the language". Here c > k loads as a comparison of expressions, and that branch of _declared_rhs_error is deleted. A str or bool parameter on a side is refused by the message arithmetic already has for it.
  • What is decided before data is refused at load. A comparison with a number on each side, 2 < 1, is refused and the message names the rewrite; it used to reach the program as an ExpressionComparison over two constants. A cases: block of one case whose when: compares expressions is refused as a pair is: the rule is on the case, since its otherwise is the negation and only the data says where that falls. The pairwise check reported it only from two cases up.
  • Two vocabularies, as feat(language): a where may compare arithmetic over parameters #469 had them. The resolved tree holds ArithmeticComparison over the core syntax tree, which the typesetter, the dim rules and the exclusivity check read. lower_program rebuilds every mask, on variables, constraints and case regions, with ExpressionComparison over program expressions. names_read on a program mask includes the relation a grouping or a pullback reads through, the one a translation or window is partitioned by, the parameter a named offset or width is read from, and the masks that decide which region of a cased entry applies. The resolved form does not answer names_read: nothing asks a resolved mask that question, so its arm is the assertion the typesetter already carries for the lowered node rather than a second walk.
  • A namespace is built from its schema and carries it. The where path needs expand, dims_of and the expressions: names, none of which the flat listing holds. feat(language): a where may compare arithmetic over parameters #469 hung the schema on Namespace behind an eight-argument constructor and a classmethod that was its only caller. Here Namespace(schema) reads every declaration at construction, and expression_of, _check_expression and _named, which took the schema beside a namespace built from that same schema, take the namespace alone.
  • A relation column and a quoted label stay out of arithmetic. lk.h > 2 * k and 2 * k == 'x' are refused with the rewrite. The relation refusal no longer claims every other comparison tests a name against a literal.
  • The Predicate union says which member a program never carries. It holds ArithmeticComparison, which lowering rewrites away, so a consumer walking a program meets every other member and never that one. The union's comment and the reading page say so. These eleven lines were written on chore(program): nothing left — this PR's work is in #585, #566 and #569 #580; they moved here because they are only true where these two nodes exist.
  • The plain form and the arithmetic form overlap, and the reading page says so. p_max > 5 lowers to a ParameterComparison and 1 * p_max > 5 to an ExpressionComparison, though both mask the same coordinates. Routing prefers the plain path, so no file produces both for one text, but a consumer that handles only the new node misses the common case. Verified by lowering both against the dispatch fixture.
  • Rendering. One branch in the walk. The golden model gains four constraints and one data-only named expression, and the notation page follows.
  • Docs. The where BNF, the table, and a section on arithmetic in a comparison on expressions.md; three paragraphs on reading.md. The sentence "comparing two parameters is not in the language" is replaced.

The break

Predicate and TypedPredicate gain ExpressionComparison and ArithmeticComparison, and a consumer's mask walk meets the first. Namespace(schema) replaces Namespace.of(schema), and expression_of(text, ns, context) no longer takes the schema and has moved to tests/fixtures.py, its only caller. lpspec's mask evaluation gains an expression evaluator in its own PR, as #469 said. The node renames this branch carries are #585's, already on main, and lpspec owes them once rather than twice.

Measured against fluxopt/lpspec at fc91b4a, which pins v0.0.0-alpha.106: both of its mask walkers, linopy/where.py and the polars predicates.py, end in assert_never and have zero branches for either new node. assert_never is a static check, so each walker needs an ExpressionComparison branch or it stops type-checking — two branches, not one evaluator's worth of work each, but they are not optional.

Merged onto main at e6ae307, and what that took

Merged, not rebased: the hard rule here is never to force-push.

#585 renamed the program's where vocabulary and expression nodes on main while this branch was building, and this branch's own two comparison nodes carried the Node suffix the rule reserves for the core AST. The rename was applied here first, as one commit, so that the base merge agreed line for line instead of conflicting on every one of them: merged straight it conflicted in 40 places.

What the two sides genuinely disagreed about, resolved by hand:

  • _check_where_dims keeps this branch's leaf form. main's noun form cannot name a comparison of expressions, which carries no single name.
  • _declared_rhs_error keeps its three kinds. A parameter on the right-hand side is arithmetic here, not a refusal, so main's parameter branch has nothing left to fire on.
  • Namespace keeps its schema-carrying form and builds a RelationDeclaration without the name main dropped from it.
  • expression_of and where_of stay in tests/fixtures.py; main's copies in resolution.py go with the merge.
  • Translate.along, WindowSum.along, Program.roots, Footprint.kinds and the relation records are main's.
Gates

pixi.sh is refused by this environment's egress proxy with a 403, so the gates ran from a uv environment on Python 3.12 with the pinned ruff==0.16.1 and pyrefly==1.2.0. That is a departure from the "every command runs under pixi run" default.

gate result
pytest -q -n 4 1398 passed, 1 skipped (main: 1357 passed)
ruff check, ruff format --check clean
pyrefly check 0 errors, 9 suppressed, as on the base
mkdocs build --strict clean, with the docs.python.org inventory dropped for the run, which the proxy refuses with a 403
prettier --check on every Markdown clean before the review commit; not run for it
typos, reuse lint clean before the review commit; not run for it
the schema, the golden output and the four page generators no diff
taplo, zizmor, compile-tex not run, for want of the tools

The golden .out files and the notation page were regenerated and read; the four new constraints print as #469's did. The schema did not move. The YAML example on expressions.md was written to a file and loads with python -m math_spec check.

The reading-page paragraph was added in a later commit and gated on its own: pytest tests/test_docs.py tests/test_reading_page.py (34 passed), the full suite (1383 passed, 1 skipped), mkdocs build --strict and prettier --check clean. typos, reuse lint, taplo, zizmor and compile-tex were not run for it, for want of the tools. The page, by the measurement in the docs-writing skill: n 55, avg 14.6, median 13, over25 7 — every long sentence predates it, and the three added are 10, 20 and 9 words.

The review commit (84e3ec2) was written as four failing tests first: two-numbers and arithmetic-over-numbers-alone in test_a_bad_comparison_of_expressions_is_refused_at_load, test_a_lone_case_comparing_expressions_is_refused_too, and test_a_cased_side_reads_the_data_its_regions_are_decided_by. Two test_exclusivity cases now see one refusal per case rather than one per pair.

Coverage moved
  • test_a_bound_or_where_cannot_name_an_expression[where] asserted that a where naming an expressions: entry fails to resolve; the entry is now read as arithmetic, and the case asserts the variable refusal that body earns instead.
  • TestAWhereSideIsReadInResolution holds every case on what a where side may be: the position() shapes and the literal forms from chore(language): a where comparison is one grammar rule over the expression grammar's arithmetic #565, and the arithmetic cases, with two-parameters and a-literal-on-the-left as loading cases.
  • where-against-a-parameter in TestRulesDecidedWithoutData asserted "compares two parameters"; it is a-flag-against-a-parameter, asserting the bool refusal.
  • New: the two lowering tests, the typesetter test in every format, the undecidable-case test, and the refusals for a relation column, a quoted label and position() inside arithmetic.
Left alone
  • 2 < c loads, where feat(language): a where may compare arithmetic over parameters #469 refused it at parse. Either side may be an expression, and a literal is one.
  • c > c loads. It is unsatisfiable on finite data, but detecting it is tautology detection, which the fold does not attempt.
  • position(g) + 1 == 0 is refused as an unknown operator, since position is a where atom and not an expression operator.
  • position() stays out of the operator table: a row there also puts it in BUILTIN_NAMES, which the expression resolver, the unknown-operator message and the golden operator census read.
  • Namespace keeps its flat fields. They are read in 24 places in the resolver, and reading them off the schema at each site would trade one dict lookup for a walk of a section.
  • The overlap above is documented, not removed. Folding ParameterComparison into the arithmetic form would lose the literal's type check against the declared dtype, has no representation for the str case at all, and would cost lpspec the _certain_names optimisation, which reads the node's name field to choose an inner join over a left join.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH

https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP

https://claude.ai/code/session_01JJfEUsCDtwXuXV8CANCHuR

https://claude.ai/code/session_019tCWoetBmjF1LpbTbjQY29

@read-the-docs-community

read-the-docs-community Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

@FBumann
FBumann added this pull request to stack #560 September 19, 2026 17:35
@FBumann
FBumann removed this pull request from stack #560 September 19, 2026 18:10
@FBumann
FBumann added this pull request to stack #574 September 19, 2026 18:10
@FBumann
FBumann removed this pull request from stack #574 September 19, 2026 18:12
@FBumann
FBumann added this pull request to stack #575 September 19, 2026 18:12
FBumann pushed a commit that referenced this pull request Sep 19, 2026
#567 is closed and this branch retargets onto #566. The typing work it
carried in its history is on main as #572, so model.py takes main's
narrowing: `ValidationInfo[object]` and `dict[str, object]` rather than
this branch's unparameterised `ValidationInfo` and pydantic's
`JsonSchemaValue`.

The two piecewise conflicts are this branch's own feature against the
code it renamed: the breakpoint dim is `along`, and a weight's where is
the block's where and its mask together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2SFmoxEF3SnaPKp7HbZTk
FBumann pushed a commit to fluxopt/specsolve that referenced this pull request Sep 19, 2026
…fused, as a sum already was

math-spec moves from 0.0.0-alpha.105 to c25984e, the head of
energy-models/mathspec#566. The pin is a commit rather than a tag because
that PR is open and stacked on two more; it is not a pin to merge on.

The refusal is the outcome a model author sees. at()'s joined-column check
asked whether a joined dimension was produced, which the landing check
already refuses, rather than whether it was consumed, so
`at(load, by=diag, over=rep, into=generator)` loaded and landed with the
joined dimension gone. sum already refused the same shape.

Carrying it is a port of the relation node API, which no consumer can tell
apart. Walk is now Direction, with Partition split off as its own class
rather than a direction with nothing consumed or produced, and the
accessors that inverted between the two operators are gone: a node's ends
are read off its direction, and an At reads the direction the other way
round at the four sites that care. GroupSum.walk and At.walk are
.direction; .over, .into, .joined and .relation are
.direction.consumed_dims, .produced_dims, .joined_dims and .name, each
read in the operator's own terms.

Coverage moved: test_by_takes_a_relation asserts position(by=)'s new
refusals — a parameter and an undeclared name now take the message every
operator's by= gives, and a dimension is told by= takes the map out of it —
and test_a_call_walks_one_table_and_says_so follows "walks one table" to
"reads one table". The thirteen generated pages are tools.gallery_math and
tools.ladder re-run; the diff is one legend line per page, "the order shift
walks" becoming "the order shift steps along".

Not done here: math-spec#566 also adds ExpressionComparisonNode and
ArithmeticComparisonNode, which neither lane evaluates. Four tests are red
on that and land with the evaluator in the PR stacked on this one, as
#566's own body says they should.

pytest -q -n auto: 4016 passed, 251 skipped, 1 xfailed, 4 failed — the four
above. ruff check and ruff format clean. pyrefly reports 2 errors, both
assert_never on those same two nodes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHXujoUG77G2SoDbhau5mS
@FBumann
FBumann removed this pull request from stack #575 September 20, 2026 20:34
@FBumann
FBumann added this pull request to stack #583 September 20, 2026 20:35
…ession 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
…als it points at

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…ather 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
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
… 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
…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
…e 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
@FBumann
FBumann force-pushed the claude/expression-parser-language-split-kdqegz-arithmetic-where branch from 948a2f9 to 7984556 Compare September 20, 2026 20:51
@FBumann
FBumann removed this pull request from stack #583 September 20, 2026 20:53
@FBumann
FBumann changed the base branch from claude/expression-parser-language-split-kdqegz to main September 20, 2026 21:15
@FBumann
FBumann added this pull request to stack #587 September 20, 2026 21:15
… 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
#585 landed the program naming rule on main, and #584 cut alpha.107. The
branch's own two comparison nodes arrive under that rule as
`ExpressionComparison` and `ArithmeticComparison`.

Resolved by hand where the two sides changed the same code:

- `_check_where_dims` keeps this branch's `leaf` form. main's `noun` form
  cannot name a comparison of expressions, which carries no single name.
- `_declared_rhs_error` keeps this branch's three kinds. A parameter on the
  right-hand side is arithmetic here, not a refusal, so main's `parameter`
  branch has nothing left to fire on.
- `Namespace` keeps its schema-carrying form and builds a
  `RelationDeclaration` without the name main dropped from it.
- `expression_of` and `where_of` stay in `tests/fixtures.py`, where this
  branch moved them; main's copies in `resolution.py` go with the merge.
- `Translate.along` and `WindowSum.along`, `Program.roots`, `Footprint.kinds`
  and the relation records are main's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP
@FBumann FBumann added this to the Richer where: clauses milestone Sep 20, 2026
FBumann added a commit that referenced this pull request Sep 20, 2026
#585 landed this branch's renames on main, and the two branches below carried
them up. What is left here is the paragraph saying lowering rewrites every
`ArithmeticComparison` into an `ExpressionComparison`, which needs #566's two
nodes to be true, and two docstrings.

Both conflicts were wording the base branch already carries: the row-building
trees are `roots`, and the golden test's carrier note names a direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP
FBumann and others added 2 commits September 20, 2026 21:30
…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
…mparison

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
@FBumann FBumann added area: where What a where predicate may say area: formulations Blocks expanding to declarations: piecewise, indicator, McCormick and removed area: formulations Blocks expanding to declarations: piecewise, indicator, McCormick labels Sep 21, 2026
@FBumann
FBumann removed this pull request from stack #588 September 21, 2026 16:15
@FBumann
FBumann added this pull request to stack #601 September 21, 2026 16:27
…gz-arithmetic-where

Brings the stack up to date with main at 0.0.0-alpha.110. The five
commits are three release chores, the relations page, and the fix for a
read that lands outside the key.

Merged clean. `resolution.py` is the one file both sides changed, and
the suite is green on the result: 1390 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
FBumann pushed a commit that referenced this pull request Sep 21, 2026
…ic-where' into claude/adoring-brahmagupta-ovwe3v

Carries main at 0.0.0-alpha.110 up the stack from #566. Merged clean,
and the suite is green: 1414 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
…ng 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: where What a where predicate may say

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants