Skip to content

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

Closed
FBumann wants to merge 3 commits into
mainfrom
claude/zealous-archimedes-k13bq7
Closed

FBumann wants to merge 3 commits into
mainfrom
claude/zealous-archimedes-k13bq7

Conversation

@FBumann

@FBumann FBumann commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Prompt: Lets implement the new mathspec features in lpspec. Do two stacked PRs in lpspec pinning the head of the mathspec feature branches. Pull the latest lpspec first!

Note

The following content was generated by AI.

Either side of a where comparison may be an expression on both lanes — p_min <= 0.5 * p_max, sum(p_max, over=generator) >= peak, load - shift(load, over=snapshot, offset=1, edge=0) > ramp — read by the absence rules the language states: a side with no value compares false, and under a +, a sum or a window an absent term is one fewer.

Follows math-spec to 5e29391, the head of energy-models/math-spec#469. The second PR of the stack pins the assumptions branch on top of this one.

How each lane reads a side
  • Relational: each side is compiled as a constant position is (PolarsCompiler.expression), its pieces added per coordinate with added(..., fill=False) — null where no piece has a value — and the column joined onto the mask's carrier, one column per side. A null compares as null, and the consumer's falsy_if_null reads it as false, as for every other comparison.
  • linopy: each side is evaluate_expression's value, absent parameter rows filled with zero (right for every piece that adds), masked by where the side has a value, decided by structure off the plan (_has_value): a sum where any term has one, a product or a quotient where both factors do, an operator wherever it gathers a present slot — the indicator is put through the same operator, a shift's named edge counting as a value and a window's unreachable lag not.

linopy/where.py is now linopy/evaluation.py, holding both walks. A cased expression holds a mask per region and a mask may now compare expressions, so the expression walk and the where walk are mutually recursive; builder.py keeps the four declarations. test_lazy_intra_package_imports_are_all_declared is why: a deferred import between the two would have been the first exception to hard rule 0. ARCHITECTURE and the linopy page follow.

ArithmeticComparisonNode, the spec-side node math-spec's WhereNode union also carries, is asserted away in both walks: lowering rebuilds every mask with an ExpressionComparisonNode in its place, so no lane reads one. The two censuses in test_architecture.py see it named; the parity census excludes it.

Mutation table

Taken by hand — each mutation is a changed expression tools/mutate.py cannot express — with the tree restored through git checkout -- and __pycache__ dropped on both sides; the tree came back clean each time. Suite: tests/test_where_arithmetic.py tests/test_resolution_parity.py, base 70f0e6de for the first pass and a6facab3 for the re-run.

guard mutation first pass with the two probe cases
linopy: the comparison masked by where each side has a value drop & left_defined & right_defined caught
linopy: a sum has a value where any term does | → & in _has_value's Add still green caught (p_max + extra > 50)
linopy: a product has a value where both factors do & → | in Multiply caught
linopy: a shift's named edge is a value indicator shifted with fill=None still green caught (a bare shift(...) < 85)
polars: a side with no piece is null added(..., fill=True) caught
polars: the sides in order left and right swapped caught

The two survivors were survivors because the first table's cases let another term lend the side a value; the second commit adds the two probes.

Verified

Ran in a uv venv (pixi is not installable here) with the linopy extra and math-spec editable at 5e29391:

  • ruff check ., ruff format --check .: clean.
  • pyrefly check --python-interpreter-path <venv>: 0 errors (the bare pyrefly check reports 142 import errors on main too — it cannot see the venv).
  • pytest -n 8: 3751 passed, 329 skipped, 1 xfailed; 12 failed, all [gurobi]/[xpress] cases refusing because neither extra is installed here (the same 10 fail on main; the remaining two were the census tests this PR makes pass, and the cased-expression test fixed before the second commit).

Not run: pixi run check as such, the docs build, test-bench.

Not done, and departures
  • No row in docs/reference/data.md: nothing new is refused at the door. The linopy page's where table gains one row.
  • The != reading of a NaN in the existing ParameterComparisonNode branch of the linopy lane (p != 3 where p has no row reads as true, where the relational lane reads null as false) is adjacent and untouched; the new branch masks by presence explicitly, so it does not inherit it.
  • A division by an absent divisor inside a side reaches numpy as a division by the filled zero; the row is masked out by _has_value, and the parity case load / extra > 50 passes under filterwarnings = error.

🤖 Generated with Claude Code

https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1


Generated by Claude Code

…h lanes

Follows math-spec to 5e29391, the head of energy-models/mathspec#469,
where either side of a where comparison may be an expression:
`p_min <= 0.5 * p_max`, `sum(p_max, over=generator) >= peak`,
`load - shift(load, over=snapshot, offset=1, edge=0) > ramp`.

The relational lane compiles each side as a constant position and adds
its pieces per coordinate, null where no piece has a value, then joins
the column onto the mask's carrier. The linopy lane evaluates each side
as a build does, absent rows filled with zero, and masks the comparison
by where the side has a value, decided by structure off the plan.

The two linopy-lane walks now live in one module, `linopy/evaluation.py`:
a cased expression holds a mask per region and a mask may compare
expressions, so the expression walk and the where walk are mutually
recursive. `builder.py` keeps the declarations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1
…are probed on their own

The two guards a green suite could not reach: the sum rule is probed by a
comparison the present term alone satisfies, and the edge rule by a bare
shift compared, with no other term to lend the side a value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1
@read-the-docs-community

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

Copy link
Copy Markdown

@codspeed

codspeed Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks
⏩ 58 skipped benchmarks1


Comparing claude/zealous-archimedes-k13bq7 (fc49e24) with main (1fd8926)2

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. ↩

  2. No successful run was found on main (28cceaf) during the generation of this report, so 1fd8926 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

The pin follows math-spec's own branch onto the relations plan, so the
evaluation module reads `Relation*` predicate nodes, a partition's `Walk`
and `maps_out_of`, and the tests that spell a `shift` or a `sum_back` say
`along=` and `window=`. `where.py` stays deleted: what main changed in it
lands in `evaluation.py`, which holds both walks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pdMgEVkGBfh1f2AyiCSKA
@FBumann FBumann closed this Sep 22, 2026
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