Skip to content

feat(language): a where may compare arithmetic over parameters on both lanes - #1697

Closed
FBumann wants to merge 3 commits into
claude/happy-maxwell-9ldgqa-directionfrom
claude/happy-maxwell-9ldgqa-where-expressions
Closed

FBumann wants to merge 3 commits into
claude/happy-maxwell-9ldgqa-directionfrom
claude/happy-maxwell-9ldgqa-where-expressions

Conversation

@FBumann

@FBumann FBumann commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Prompt: Continue bumping to the head of #566

Note

The following content was generated by AI.

math-spec#566 lets either side of a where comparison be arithmetic over parameters — p_max > cost, 0.5 * p_max > 0, sum(p_max, over=generator) > 0. Both lanes build it now, so what the language accepts is what this package builds.

A side is compiled the way a constant side of a constraint is: the relational lane folds its constant fragments over the comparison's dims and joins the value column onto the carrier, the linopy lane evaluates it to a DataArray. A coordinate no fragment reaches stays null and the comparison reads false, which is what every other atom over a missing value does.

Two decisions to review rather than take on trust

Hard rule 0 moves. A mask now reads an expression, and a cases expression already read a mask, so the recursion is the language's own grammar and no ordering of a lane's two modules removes it. DELIBERATE_LAZY_IMPORTS was empty and that emptiness was the claim; it now holds two entries, one per lane, and docs/about/architecture.md says so. The alternative was threading an evaluator callback through masked() at seven call sites in two modules, which buys a parameter and a detour for every future reader.

NEVER_LOWERED excuses ArithmeticComparisonNode from both dispatch guards. It is in program.WhereNode, but lower_program rewrites every one into an ExpressionComparisonNode, so no lowered mask holds one and neither lane can dispatch on it. The exclusion is a claim about upstream, so test_no_never_lowered_node_survives_lowering checks it rather than trusting it, and the two assert_never pragmas name the same reason. Whether the union should carry a node lowering always replaces is a question for math-spec#566, and I have not raised it there.

Mutation table

tools.mutate, one guard, run before and after the probe that reaches it.

mutation before the probe after
the constants-only guard on a where side (predicates.py:223-225) 4023 passed — survived caught

The guard survived because lower_program refuses a variable and a dual() on a where side before the engine is reached, so no model can carry one there. test_a_where_side_holding_a_variable_is_caught_rather_than_compiled_as_a_constant hands compile_predicate the node directly, which is the one caller that can reach it — without it the side would fold a variable's coefficient into a value column and mask on it.

Coverage moved

'p_max > cost' leaves the refusal sweep, where it asserted "compares two parameters", and joins ACCEPTED with '0.5 * p_max > 0' and 'sum(p_max, over=generator) > 0'. All three are swept by test_both_lanes_build_the_same_model for lane agreement on variable rows and termination status.

Gates

Run through uv rather than pixi, which is not installed in this environment — a departure from the pixi run check default. Re-run after the rebase.

gate result
pytest -q -n auto 4024 passed, 251 skipped, 1 xfailed, 70.41s
ruff check . All checks passed
ruff format --check . 326 files already formatted
pyrefly check 0 errors, 6 suppressed — was 4

Not run: test-bench, the sweep, and the docs build. The typst gallery cases skip for want of the binary, as they do in CI.

Deliberately not done: nothing was measured, so no number is claimed — a where side now compiles an expression where it used to read a column, and whether that costs anything on a real model is unmeasured. The stack is the base branch: this sits on #1696, which carries the pin.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NHXujoUG77G2SoDbhau5mS


Generated by Claude Code

…h lanes

math-spec#566 lets either side of a where comparison be arithmetic over
parameters — `p_max > cost`, `0.5 * p_max > 0`, `sum(p_max, over=generator)
> 0`. Both lanes now build it, so what the language accepts is what this
package builds.

A side is compiled the way a constant side of a constraint is: the
relational lane folds its constant fragments over the comparison's dims and
joins the value column onto the carrier, the linopy lane evaluates it to a
DataArray. A coordinate no fragment reaches stays null and the comparison
reads false, which is what every other atom over a missing value does.

Two architectural decisions to review rather than take on trust:

Hard rule 0 moves. A mask now reads an expression, and a cases expression
already read a mask, so the recursion is the language's own grammar and no
ordering of a lane's two modules removes it. DELIBERATE_LAZY_IMPORTS was
empty and that emptiness was the claim; it now holds two entries, one per
lane, and architecture.md says so. The alternative was threading an
evaluator callback through masked() at seven call sites in two modules,
which buys a parameter and a detour for every future reader.

NEVER_LOWERED excuses ArithmeticComparisonNode from both dispatch guards.
It is in program.WhereNode but lower_program rewrites every one into an
ExpressionComparisonNode, so no lowered mask holds one and neither lane can
dispatch on it. The exclusion is a claim about upstream, so
test_no_never_lowered_node_survives_lowering checks it rather than trusting
it, and the two assert_never pragmas name the same reason. Whether the
union should carry a node lowering always replaces is a question for #566.

Coverage moved: 'p_max > cost' leaves the refusal sweep, where it asserted
"compares two parameters", and joins ACCEPTED with the two arithmetic
cases, so all three are swept for lane agreement on rows and status.

pytest -q -n auto: 4023 passed, 251 skipped, 1 xfailed. ruff check, ruff
format and pyrefly clean, the last with 6 suppressed rather than 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHXujoUG77G2SoDbhau5mS
…ompiled as a constant

The constants-only guard in _side survived deletion with the whole suite
green, because lower_program refuses a variable on a where side before the
engine is reached and no model can carry one there. The probe hands
compile_predicate the node directly, so the guard is exercised by the one
caller that can reach it.

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

codspeed Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks
⏩ 58 skipped benchmarks1


Comparing claude/happy-maxwell-9ldgqa-where-expressions (f23d814) with claude/happy-maxwell-9ldgqa-direction (4fc4b9e)

Open in CodSpeed

Footnotes

  1. 58 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@FBumann
FBumann added this pull request to stack #1698 September 19, 2026 19:05
@FBumann
FBumann marked this pull request as draft September 19, 2026 19:06
…ather than crashing the eager one

`where: "1 > 0"` reached the eager lane as a Python bool, because both sides
evaluate to scalars and the comparison of two scalars carries no dimension.
The handler called `.fillna` on it and raised AttributeError, while the
relational lane built the model. One language, two answers.

The comparison is wrapped as a DataArray before the null fill, which is the
0-dimensional mask `evaluate_where` already returns for the no-mask case, so
callers keep combining with `&` and `|` without case analysis.

Found by sweeping the expression language across a where side rather than
by a report. '1 > 0' joins ACCEPTED, so the lanes are held to agreeing on it
the way they are on every other predicate; it fails there without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHXujoUG77G2SoDbhau5mS
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