Skip to content

refactor(language): an expression resolves straight into the program's own nodes - #638

Merged
FBumann merged 6 commits into
claude/blissful-heisenberg-z5rpwo-namedfrom
claude/mathspec-packages-review-urd4ya
Sep 23, 2026
Merged

FBumann merged 6 commits into
claude/blissful-heisenberg-z5rpwo-namedfrom
claude/mathspec-packages-review-urd4ya

Conversation

@FBumann

@FBumann FBumann commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: Review the packages code as a whole. Do you see room for simplififcation? Or reuction of concepts? … Let's apply your proposed changes … Now let's work on the big thing … Let's do this … Rebase the full stack

Note

The following content was generated by AI.

Resolution builds program.Expression nodes, so every pass after it reads one vocabulary and lowering only packages declarations. Before that, a piecewise block is checked once at load, one function types an expression string, and four duplicate shapes are gone. Stacked on #632; the whole stack is rebased onto main at #640.

Warning

Found in review, fixed at #643: a dim fault under a bare sum() is a SchemaError where the dim rules raise DimensionError, and the tests were loosened rather than pinned; sum(sum(p, into=g)) added a false "already a scalar"; the exclusivity rewrite rounded a literal to six digits; an undeclared piecewise over: added per-link lines; a str or bool breakpoint parameter and an lp x-link with no variable were refused only under an emitted row; offset=-tag lost its dtype hint; fan_in crashed on a Named; _operands repeated children; the otherwise test in the walk was always true; the UNREACHABLE entry if isinstance(node, Cases): names a line that runs. Unclaimed here and pinned there: an unused template or entry is now refused for the form of an amount, an edge or a bare sum.

Concepts

  • One expression vocabulary. The syntax tree the grammar builds is read by expansion and resolution only. Everything after — the dim rules, the degree rules, the exclusivity check, the typesetter, lowering — reads program.Expression. The predicate side already worked this way; the arithmetic side now does too.
  • Resolution builds, lowering packages. A Sum, GroupSum, Pullback, Translate or WindowSum is built at the call, with the Direction or Partition resolution already computed. Resolved holds ConstraintDeclarations and an ObjectiveDeclaration. Lowering rebuilds each declaration with every Named use inlined and does nothing else to a tree.
  • A formal builds nothing and refuses nothing. _Resolver.arith returns None with an error for a refusal and None with no error for a node a macro formal stands under. A template is checked by resolving it and keeping the errors; the call site that binds the formals is where the node is built. No Formal leaf exists. This is also what fixes fix(language): a macro template nothing calls is held to every rule a call site is #628's refusal of a formal along= beside by=; fix(language): a long chain of named expressions loads, a typo beside a formal is refused, and a fault in an entry hides no other #643 pins it.
  • The otherwise region is built by resolution. Its mask is the remainder of the other regions (resolution.remainder), so a consumer adds regions. The typesetter prints "otherwise" for the last region.
  • The form of an amount and an edge is decided where it is read. A literal offset= or window= is whole and in range, a named one is a parameter, and a shift without an edge= says what it means, all in resolution, so Translate.offset and WindowSum.width are the int | str they say. The dim rules keep only what needs the operand's dims.
  • A piecewise block is checked once, at load, with every other declaration. The names it references are Spec reference rules, the names it emits are collision rules the sos check shares, and its frame is curve_frame, which the expansion and the typesetter both read. The expansion cannot fail for what load admits; two rules load did not decide here are decided at fix(language): a long chain of named expressions loads, a typo beside a formal is refused, and a fault in an entry hides no other #643.
  • A named expression is resolution's. Expansion substitutes macros only. A use of an expressions: entry resolves to the one Named node built for the entry, and the memo, the cycle check and the "does not load" refusal stay where refactor(language): each named expression is resolved once, and every use reads that node #632 put them.
  • An assumption is the class named Assumption. The file's section is assumptions: and every other declaration class is a noun; the Holds class and its Assumption alias, which stood in for kinds that never came, are one class.

Renames

Was Is
validation._check_expression and resolution._value resolution.resolve_expression_text, and resolve_constraint_text for a constraint
resolution.ResolvedConstraint program.ConstraintDeclaration
resolution.ResolvedAssumption program.Assumption
piecewise.Assumed model.AssumptionBlock
program.Holds, and the program.Assumption alias of it program.Assumption, one class
model.LinkSign _expression_parser.ComparisonOperator
program.ExpressionComparison[Side] program.ExpressionComparison, both sides Expression
_expression_parser.case_context errors.case_context
dimensions._AMOUNTS and _Amount operators.AMOUNTS and Amount
lowering._none_of resolution.remainder
lowering._Lowering.expr and .mask lowering.inline and inline_mask
piecewise._nominated PiecewiseBlock.nominated
typesetting.walk.Walk._curve_frame piecewise.curve_frame

degree.carries_variable is removed as a duplicate of program.carries_variable, which was already there.

Removed

  • Parser nodes: VariableNode, ParameterNode, DualNode, DimensionNode, DirectionNode, PartitionNode, EdgeNode, CaseArm, CasesNode, DefinitionNode, and the groups KwargNode, UnresolvedNode, LeafNode. The parser keeps NumberNode, NameNode, NameListNode, KeywordNode, UnaryOperatorNode, BinaryOperatorNode, FunctionCallNode and ComparisonNode.
  • Public: math_spec.PiecewiseExpansionError, math_spec.program.Holds.
  • Lowering: _Lowering, _CALLS, _amount, _partition_of.
  • Dim rules: _CALL_RULES, _dims_call, _cases_dims, _check_amount_form, _check_edge, _edge_fill, _vacates, _whole, _amount_of (the form checks moved to resolution; the rest is one dims_of over program nodes).
  • Degree: _REDUCTIONS, the reductions being the node kinds now.
  • Typesetter: walk._amount, walk._step, Walk._curve_frame.
  • Guards: the six "reached X unresolved" assertion arms in degree, dimensions, lowering and the walk.

Added

  • program.Named(name, body): the one node a Spec.resolved tree holds that no Program does. Exported, since program.__all__ is checked against what the module defines; absent from Expression, so a consumer's exhaustive match is unchanged. program.children steps through it.
  • piecewise.Emitted and piecewise.curve_frame, operators.Amount, lowering.inline, resolution.remainder, resolution.resolve_constraint_text.
Method, gate output, alternatives

The commits

Six on one branch, because the session was held to one. That departs from one issue, one PR; split at the commits if wanted.

  1. chore(resolution): one door for an expression string. resolve_expression_text in resolution.py replaces validation.py's _check_expression and resolution's _value, which were the same five steps down to the message text.
  2. refactor(language): a piecewise block is checked once at load. Spec._validate_expressions computes resolved before it expands, so the expansion reads each link's typed tree rather than parsing the text again. PiecewiseExpansionError is gone: a block is refused as a SchemaError or a DimensionError like any other declaration.
  3. chore: a shape declared once. assumptions_of returns AssumptionBlocks, Resolved.assumptions holds the program's assumption class, and the LinkSign alias is gone.
  4. refactor(language): an expression resolves straight into the program's own nodes. The ledger above. feat(language): a where may read a predicate through a relation with at() #634's at() over a predicate is carried in this commit: PulledBackPredicate is built from the Direction the relation read returns, pulled_back_dims serves the expression and the predicate alike, and the walk's _pullback reads through the helper feat(language): a where may read a predicate through a relation with at() #634 added.
  5. chore(schema): the published schema regenerated for a docstring the fourth commit changed.
  6. refactor(program): an assumption is the class named Assumption. The rename of Holds, in source, tests and reading.md.

The rebase

The seven branches of the stack, #626 through #632 and this one, are rebased onto main at #640, one commit each, in that order. Two of the seven conflicted with #634: #626 on the shape of the Predicate union, #632 on an import line, both resolved in the commit that meets them. This branch's fourth commit conflicted in the four modules #634 touched, and is resolved with the files of the merge commit that was pushed and validated here before (540d412); the rebased head's tree equals that merge's tree, release files aside, so the earlier gate output stands. Each branch was pushed with a lease against the head it was rebased from.

Breaking for consumers

  • math_spec.PiecewiseExpansionError no longer exists. fluxopt/lpspec re-exports it in errors.py and __init__.py, lists it in tests/test_architecture.py and tests/test_api.py, and matches it in tests/test_piecewise.py:293,298; each becomes SchemaError or DimensionError. No alias, per the alpha-stream rule.
  • math_spec.program.Holds no longer exists; the class is Assumption, which reading.md documents. lpspec names neither.
  • math_spec.program.Named is new in __all__ and absent from Expression. lpspec's test_docs_site.py:291 pins get_args(program.Expression), which is unchanged.
  • program.ConstraintSense stays: lpspec reads it in three places, and the Literal cannot move into program.py without an import cycle through model.py.

Behaviour a user sees

Tests

  • 1565 passed on the rebased head, and each rebased branch below it passes its own suite (1570 to 1578). refactor(language): each named expression is resolved once, and every use reads that node #632's head had 1562 before the rebase: the parser test for nine resolved nodes' __str__, the unresolved-name test in test_degree, and two rows of the operator-table test went with the classes they tested.
  • Coverage that moved: tests/test_lowering.py's "lowers to its node" cases assert the node resolution builds; tests/test_expansion.py reads through Named with lowering.inline; the golden census counts Expression members plus Named and the operators by node type; tests/test_piecewise.py asserts SchemaError where it asserted PiecewiseExpansionError.
  • The line census of the walk (test_the_golden_model_reaches_every_line_of_the_walk, needs coverage) passes; the one arm it cannot reach, a Cases outside a Named, is in UNREACHABLE with its reason.
  • Not done: a mutation sweep at fix(language): a long chain of named expressions loads, a typo beside a formal is refused, and a fault in an entry hides no other #643 found nine guards of the moved and added rules with no test, not three: the three frame refusals curve_frame carries, three reference rules of a block, three kwargs naming nothing. Three of the nine were redundant with the check beside them. All are pinned or gone there.

Numbers

Against #632's head, src/ is 16 files, 1281 added, 1745 removed. Parser node classes 18 to 8; program node classes 15 to 16, the one added being Named.

Gates

  • ruff check and ruff format --check: green. pyrefly check --python-interpreter-path <venv>: 0 errors.
  • pytest -q -n auto: 1565 passed. python -m tools.schema and the golden generator re-run, no diff.
  • prettier on the changed pages: applied.
  • docs-build and compile-tex did not run: the session proxy refuses docs.python.org and pixi.sh, and typos, reuse and zizmor are not installed here.

What was left out

Folding the dim rules into the resolver, so that each node is built knowing its dims, is the next cut the design invites and a separate one. Namespace still mirrors Spec in five fields.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn

@read-the-docs-community

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

Copy link
Copy Markdown

@FBumann FBumann changed the title refactor(language): a piecewise block is refused at load the way every other declaration is refactor(language): an expression resolves straight into the program's own nodes Sep 23, 2026
@FBumann
FBumann added this pull request to stack #637 September 23, 2026 06:52
@FBumann
FBumann force-pushed the claude/mathspec-packages-review-urd4ya branch from 540d412 to 3270e48 Compare September 23, 2026 08:16
FBumann pushed a commit that referenced this pull request Sep 23, 2026
…e test that fails without it

A mutation sweep over the guards #638 moved and added left nine green.
Two were redundant with the check beside them and are gone: a formal
amount is caught under its sign, and a role that named no column is
caught where the roles are counted. The other seven are pinned.

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

`resolve_expression_text` in resolution.py is what validation.py's
`_check_expression` was, and resolution's `_value` was the same function
with `comparison=False, ceiling=None`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn
…e expansion only writes rows

`Spec` computes `resolved` before it expands, so the expansion reads each
link's typed tree instead of parsing the text again. The names a block
references and the names it emits are checked with the other `Spec`
reference rules, and its frame in `curve_frame`, which the typesetter reads
too. `PiecewiseExpansionError` is gone: a block is refused as a
`SchemaError` or a `DimensionError` like every other declaration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn
`piecewise.assumptions_of` returns `AssumptionBlock`s, which is what it
made from its `Assumed` tuples; `Resolved.assumptions` holds `program.Holds`,
which `ResolvedAssumption` had the fields of; the `Assumption` alias of
`Holds` and the `LinkSign` alias of `ComparisonOperator` are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn
…s own nodes

Resolution builds `program.Expression` nodes from the syntax tree, so the
dim rules, the degree rules, the exclusivity check, the typesetter and
lowering read one vocabulary. The parser keeps its eight syntax nodes;
the ten typed ones, the node groups and the assertion arms that policed
them are gone, and lowering packages declarations with each `Named` use
of an `expressions:` entry inlined. The form of an `offset=`, `window=`
and `edge=` is decided where it is read, in resolution.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn
`program.Holds` is renamed `Assumption`: the file's section is
`assumptions:`, every other declaration class is a noun, and the alias
that carried the noun stood in for kinds that never came.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn
@FBumann
FBumann force-pushed the claude/mathspec-packages-review-urd4ya branch from 3270e48 to f2675a2 Compare September 23, 2026 11:26
FBumann pushed a commit that referenced this pull request Sep 23, 2026
… a formal is refused, and a fault in an entry hides no other

The review of #626 through #638, addressed on top of #638.

- A template's by= is checked before its formal columns send the call back,
  so sum(x, by=nope, over=a, into=b) is refused again; the over= and by=
  refusals say "or a formal of this macro" again; a formal along= beside a
  by= is pinned as building nothing.
- Validation no longer raises after the macros and the expressions: entries,
  so a fault there hides no constraint's fault. A use of a refused entry says
  it does not load, and the refusal is listed with it.
- A cycle closed through a macro names the macro in its chain, and one closed
  through a case's when is pinned.
- Named expressions are resolved from a worklist in dependency order, so a
  chain of any length costs no stack, and the resolved tree is held to
  MAX_RESOLVED_DEPTH, three times what one text may nest, with a refusal that
  names the depth rather than the parser's message about a tree that was not
  deep.
- A refused call builds nothing for the call around it, so sum(sum(p, into=g))
  reports one fault.
- A named offset or window is held to dtype: int where it is read, so
  offset=-tag says the dtype before the sign.
- A piecewise block is refused on the link the file wrote for a str or bool
  breakpoint parameter and for an lp x-link with no variable; an undeclared
  over: is one line.
- The exclusivity rewrite quotes a literal as the file wrote it.
- fan_in reads through a Named; dimensions uses program.children; the walk
  prints "otherwise" for the last region without testing it; the census names
  no line that runs.
- Tests pin the error class per rule, the unused template and entry refusals,
  +p as a named amount, and a link through a refused entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015h57WkBDnpxrknuJ5zZy9F
FBumann pushed a commit that referenced this pull request Sep 23, 2026
…e test that fails without it

A mutation sweep over the guards #638 moved and added left nine green.
Two were redundant with the check beside them and are gone: a formal
amount is caught under its sign, and a role that named no column is
caught where the roles are counted. The other seven are pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015h57WkBDnpxrknuJ5zZy9F
@FBumann
FBumann removed this pull request from stack #637 September 23, 2026 11:30
@FBumann
FBumann added this pull request to stack #644 September 23, 2026 11:32
@FBumann
FBumann merged commit b3cee88 into main Sep 23, 2026
5 checks passed
FBumann added a commit that referenced this pull request Sep 23, 2026
… a formal is refused, and a fault in an entry hides no other (#643)

* fix(language): a long chain of named expressions loads, a typo beside a formal is refused, and a fault in an entry hides no other

The review of #626 through #638, addressed on top of #638.

- A template's by= is checked before its formal columns send the call back,
  so sum(x, by=nope, over=a, into=b) is refused again; the over= and by=
  refusals say "or a formal of this macro" again; a formal along= beside a
  by= is pinned as building nothing.
- Validation no longer raises after the macros and the expressions: entries,
  so a fault there hides no constraint's fault. A use of a refused entry says
  it does not load, and the refusal is listed with it.
- A cycle closed through a macro names the macro in its chain, and one closed
  through a case's when is pinned.
- Named expressions are resolved from a worklist in dependency order, so a
  chain of any length costs no stack, and the resolved tree is held to
  MAX_RESOLVED_DEPTH, three times what one text may nest, with a refusal that
  names the depth rather than the parser's message about a tree that was not
  deep.
- A refused call builds nothing for the call around it, so sum(sum(p, into=g))
  reports one fault.
- A named offset or window is held to dtype: int where it is read, so
  offset=-tag says the dtype before the sign.
- A piecewise block is refused on the link the file wrote for a str or bool
  breakpoint parameter and for an lp x-link with no variable; an undeclared
  over: is one line.
- The exclusivity rewrite quotes a literal as the file wrote it.
- fan_in reads through a Named; dimensions uses program.children; the walk
  prints "otherwise" for the last region without testing it; the census names
  no line that runs.
- Tests pin the error class per rule, the unused template and entry refusals,
  +p as a named amount, and a link through a refused entry.

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

* chore(resolution): the resolved depth carries the number of the PR that measured it

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

* test(piecewise): every guard of a block's frame and references has the test that fails without it

A mutation sweep over the guards #638 moved and added left nine green.
Two were redundant with the check beside them and are gone: a formal
amount is caught under its sign, and a role that named no column is
caught where the roles are counted. The other seven are pinned.

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

* chore(resolution): a formal amount is read once, where a bare name is

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Sep 23, 2026
Merged against the tree this branch was written on, the head of #638 before
its rebase, so the conflicts were the four places #643 and the join touch the
same lines: fan_in reads through a Named and then asks a Sum, the relation
read builds a join, and the dim tests carry the error class and the join's
wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015h57WkBDnpxrknuJ5zZy9F
FBumann added a commit to fluxopt/specsolve that referenced this pull request Sep 23, 2026
…at() (#1718)

> **Prompt:** Update lpspec to the latest mathspec release (119)

> [!NOTE]
> The following content was generated by AI.

The pin moves from math-spec alpha.116 to alpha.119. Both lanes now
build the new `at(<predicate>, by=, over=, into=)` in a `where:`
(energy-models/mathspec#634), and they agree on the result. A
coordinate with no relation row reads false. 70 insertions, 34
deletions.

<details><summary>What moved</summary>

* **`PulledBackPredicate`**: the relational lane walks the coordinates
the operand admits through `walk_join`, as an expression's `at` does.
The linopy lane reads the evaluated mask through `operator_at`. Before
this change, both lanes hit `assert_never`.
* **`PiecewiseExpansionError` is gone upstream**
(energy-models/mathspec#638). A piecewise block now raises
`DimensionError`, so `lps.PiecewiseExpansionError` goes too, with no
alias. `test_api`, `test_architecture` and `test_piecewise` follow, and
so does `docs/reference/api.md`.
* **`ArithmeticComparison` is gone upstream**
(energy-models/mathspec#631). The two lanes' branches for it were never
reached, so they go. `NEVER_LOWERED` in `test_resolution_parity` goes
with them.
* **Tests**: `test_a_where_reads_a_relation` gains 2 cases and
`test_a_relation_where_agrees_with_the_oracle` gains 3: a total
relation, a partial one, a negation and a conjunction.
`COVERED_ELSEWHERE` names the oracle test for `PulledBackPredicate`.
Before the implementation, the coverage guard failed on it.
* **Upstream now refuses `sum()` over a scalar**, so the
carried-parameter probe in `test_strategy` reads `soc_initial` bare. It
still asserts the refusal names "carried".
* `uv.lock` is relocked. Only the math-spec entry changed.

</details>

<details><summary>Mutation table</summary>

Run by hand on the committed tree. Each file was restored through `git
checkout --` and `__pycache__` was dropped. The runs cover
`test_label_coords.py` and `test_resolution_parity.py`.

| Mutation | Result |
| --- | --- |
| linopy: a missing relation row reads true (`fillna(True)`) | caught, 1
failed |
| polars: a missing row reads true (`fill_null(True)`) | caught, 5
failed |
| polars: the operand's mask is ignored (`masked(..., None)`) | caught,
5 failed |

</details>

<details><summary>Gate</summary>

```
ruff check .            clean
ruff format --check .   327 files already formatted
pyrefly check           0 errors (20 suppressed)
pytest -q -n auto       3796 passed, 403 skipped, 1 xfailed, 11 failed
```

The run used `uv` with the `[linopy]` extra, not pixi. The xfail is the
known `osemosys_utopia` / #894 case. Of the 11 failures, one was the
`test_strategy` probe, which is fixed in the second commit. The other 10
are the gurobi and xpress parametrizations of `test_diagnostics` and
`TestThePositionalHandoff`. They raise `ModuleNotFoundError` because
neither package is installed here, and they fail the same way on
`origin/main`.

**Not run**: `docs-build`, `docs-test`, `test-floors`, `test-bench`, the
gurobi and xpress sinks. I did not regenerate the gallery pages. The
typesetter changed upstream (energy-models/mathspec#629), but the doc
tests passed.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LzN7uYEvHW9dDGCmtAzbkD

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LzN7uYEvHW9dDGCmtAzbkD)_

---------

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