Skip to content

feat(program): each expression node states the dimensions it is read over - #899

Merged
FBumann merged 19 commits into
mainfrom
feat/node-frames
Oct 20, 2026
Merged

FBumann merged 19 commits into
mainfrom
feat/node-frames

Conversation

@FBumann

@FBumann FBumann commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: Now do #897

Prompt: Could we remove dims of then instead?

Note

The following content was generated by AI.

Every expression node in the program has dims, sorted by name. Each node is checked against its operator's rule when it is built, and dims_of is gone. A load lists every dimension fault it finds. Closes #897.

What changes
  • The field. All fifteen expression node classes in program.py take dims as a required keyword field. It is part of equality, so two trees are equal only with the same dims on every node.
  • The dims rule lives in construction. The resolver builds each node with its dims from its children's dims. sorted_dims in _dimensions.py sorts them by name, which is the rule fix(language): two files that declare their dimensions in different orders load to the same program #911 set for the dims the loader derives.
  • The node rules also live in construction. check_node holds a Sum, a Join, a Translate or a WindowSum to its operator's rule on the dims its operand states. The resolver calls it on each such node it builds, and appends a refusal to its errors, as the condition resolver already does for conditions. A tree is checked once, bottom up. Before, a subtree was checked again under each at(...) and bare sum(...), and once more at the end.
  • dims_of is removed. check_spec keeps the entry rules and reads .dims: a named expression's dims:, rule 5, the objective, bounds, where:. The condition resolver, _piecewise and the resolver's at and bare sum read .dims too.
  • The class of the error says what is wrong. The Namespace records each refusal a dim rule writes. Namespace.error raises one DimensionError that lists the refusals when every fault is a dim rule's, and one SchemaError when any other fault is among them. A refusal that only points at an entry's own ("named expression 'x' does not load") leaves the class to that entry's fault.
  • Consumers in mathspec. _lowering._frame_of reads the body's dims. The typesetter rebuilds an Add or a Multiply for printing with the node's own dims.
  • Docs. The reading guide says over which dimensions each node is indexed, and that they are sorted by name whatever order dimensions: or an entry's dims: writes. The error reference says which class a load with several faults raises. The Expression, Join and _dimensions docstrings state the rest.
What a file author sees
The load finds Error
only faults of an operator's dim rule, such as sum(x, over=d) where x is not indexed over d, a join or at on a column the operand lacks, or a shift along a dim the operand lacks one DimensionError that lists every one
such faults and any other fault, such as a name nothing declares one SchemaError that lists every one
no fault while it reads the expressions, then an entry that breaks a rule on a whole entry, such as rule 5 a DimensionError that names the first such entry, as before

Before, the first operator fault stopped the load as a DimensionError. Three cases were SchemaError instead: a fault under a bare sum(...), a fault under an at(...), and a join fault in a where: condition. sum(sum(p, over=bus)) was a SchemaError, and sum(sum(p, over=bus), over=generator) a DimensionError. Now both are a DimensionError. The message text is the same.

Verification

Gates on 677fcba, which merges main with #911:

  • pixi run lint passes.
  • pixi run test passes: 3055 passed, 1 skipped.
  • pixi run docs-build passes.
  • compile-tex was not run, because the container cannot download the TeX bundle. CI runs it.
  • The golden typesetter output and every generated page are unchanged by this PR. Every generator was re-run, and none gave a diff.

The check (#897). test_every_expression_node_states_its_dims_sorted_by_name loads 76 specs: every file in examples/, both sides of each tests/expand/ pair, and the golden model. It checks each spec as loaded and after spec.expand(). It walks every expression node the program holds, and compares dims with the dims that the test derives from the spec by the language's rules, without reading dims. #911's test_the_order_of_dimensions_reaches_no_value_of_the_program now covers the nodes as well: on the merge alone, before node dims were sorted, it failed for 45 specs.

The class. test_every_fault_resolution_meets_is_listed_under_the_class_of_what_is_wrong loads a file with two operator faults (a DimensionError that lists both), the same file with a name nothing declares (a SchemaError that lists all three), and a file with a condition at(...) through a dim it lacks (a DimensionError).

Mutation table. Each change was made and the full suite was run. The file was restored with git checkout --, __pycache__ was dropped before and after each run, and the tree came back clean. Row 1 is on 677fcba. Rows 2 to 7 are on 5372843, rows 8 to 11 on cc86700, and rows 12 to 15 on 152a73a. Some tests had other names then. Later commits renamed them and changed only the words.

Change Caught by
a node's dims in the order they are met, not sorted 63 failures: 45 cases of the dims test, 10 generated pages, the golden output, test_the_composed_sum_prints_its_terms_by_name, test_every_python_step_prints_what_the_page_shows
a sum also removes the dims of a join's axes 8 failures, among them test_a_join_opens_an_axis_for_the_column_it_drops_and_the_sum_over_it_closes_it and test_dim_inference
arithmetic has the dims of its left operand only examples/pypsa.yaml no longer loads: 3 modules stop at collection
a join does not add the dims it groups by 5 modules stop at collection, among them test_terms.py and test_library_example.py
a case leaves out the dims of its when: 5 cases of the dims test
a parameter leaf has no dims examples/pypsa.yaml no longer loads: 3 modules stop at collection
a named body without dims: is not checked as its entry (since removed: the body is checked as it is built) test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_name
the resolver does not call check_node 17 failures: test_an_ill_dimensioned_expression_is_rejected, test_a_rule_decided_without_data, test_two_joined_columns_do_not_share_a_dimension, test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_name
a sum does not check its operand 6 failures in the same tests
a join does not check its operand 6 failures, among them test_two_joined_columns_do_not_share_a_dimension
a translation does not check its operand 5 cases of test_an_ill_dimensioned_expression_is_rejected
a load of only dim faults is a SchemaError 16 failures, among them the class test and test_an_ill_dimensioned_expression_is_rejected
a follow-on refusal counts as a fault of another kind test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_name[an-entry-a-constraint-reads]
a condition's join refusal is not recorded as a dim rule's test_every_fault_resolution_meets_is_listed_under_the_class_of_what_is_wrong[a-condition-read-through-a-dim-it-lacks]
an operator's refusal is not recorded as a dim rule's 15 failures, among them the class test and test_two_joined_columns_do_not_share_a_dimension

Coverage moved.

  • Every hand-built expected tree in test_lowering.py, test_expansion.py and test_piecewise.py now states its dims, so these tests also assert the dims.
  • In test_an_ill_dimensioned_expression_is_rejected, the fault under a bare sum() expects a DimensionError, as the same fault under a sum over a named dim does.
  • SHAPES_MODEL in test_lowering.py declares lead over no dimension. The test resolved shift(p, along=g, offset=lead) with lead over g, which the dim rules refuse. It passed only because it never ran them.
  • The fixtures expression_of, comparison_of and where_of raise the error Namespace.error picks, as a load does.
Not done

🤖 Generated with Claude Code

https://claude.ai/code/session_01D2S9sN1jLGGMCCwqwVyGD6

…body does not carry is refused at load

Closes #896.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
…terms do not carry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
…over

Closes #897.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
@read-the-docs-community

read-the-docs-community Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

… it is built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
…in, listed with every other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
…t breaks it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
…er, rather than its frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
…xed over, rather than its frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
@FBumann
FBumann added this pull request to stack #903 October 20, 2026 09:00
@FBumann FBumann added the v0.4.0 label Oct 20, 2026

@brynpickering brynpickering left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncontroversial IMO. No objections, just one point of clarification that I couldn't quite understand from the docs entry / source

Comment thread docs/reference/reading.md Outdated
Comment on lines +155 to +157
`ExpressionReference` has the dimensions of its body. The order of an entry's
own `dims` can be different: a named expression keeps the order its `dims:`
declares.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, so the node will always have dims ordered as they are defined in dimensions even if that differs from the order of dimensions defined in a named expression's dims?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This leads to deterministic dimension ordering. And a canonical yaml should replicate it.
Might be worth a follow up issue to verify this is properly done already

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this can't really work predictably with composition. I revert my option: We should simply sort by name, like all other entries too.
See other PR.

…es, whatever order its entry declares

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
Base automatically changed from fix/named-expression-frame to main October 20, 2026 14:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf
# Conflicts:
#	CHANGELOG.md
#	src/mathspec/_lowering.py
#	tests/test_dimensions.py
The order of dimensions: no longer reaches a node, which is the rule
#911 set for the dims the loader derives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2S9sN1jLGGMCCwqwVyGD6
@FBumann
FBumann merged commit 0f892c0 into main Oct 20, 2026
9 checks passed
FBumann pushed a commit that referenced this pull request Oct 20, 2026
`_frame_of` keeps #899's body dims and sorts a declared `dims:` with
#899's `sorted_dims`. Every program dims this branch sorts goes through
`sorted_dims` too, so the rule has one home. A relation's key dims keep
`tuple(sorted(...))`, because a key may name one dimension twice and the
refusal of a bare relation reads the repeat. The reading guide says an
entry's dims are sorted, as a node's are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Each expression node of the program states the dimensions it is read over

3 participants