Skip to content

feat(language)!: every entry is indexed by its dimensions sorted by name, whatever order the file writes them in - #914

Closed
FBumann wants to merge 10 commits into
mainfrom
feat/dims-sorted-by-name
Closed

FBumann wants to merge 10 commits into
mainfrom
feat/dims-sorted-by-name

Conversation

@FBumann

@FBumann FBumann commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: It seems like the reasonable thing to do for now. Have a clear, universal rule. And only adding specail cases later.

Prompt: Make sure 914 is updated and handles the code added or changed by 899 properly

Prompt: Could we simplify some things or methods? Or refacto? As everything is now sorted the same way?

Prompt: Is there a reason to not make it a tuple?
For consistency?

Prompt: Why not also sort those?

Prompt: THe columns should be key first always i think

Note

The following content was generated by AI.

What this changes

Every dims and frame in the program is sorted by name, including an entry's declared dims:. A relation's columns are its key's first, each side sorted by name. Indices and tables print in that order, and the canonical form sorts every dims: list and each side of a relation. #911 did this for derived dims. This PR makes the rule universal.

The branch merges main with #899, so the expression nodes' dims follow the same rule. All sorting of program dims goes through #899's sorted_dims. Mask.dims and Relation.dims are sorted tuples too, so every dims in the program has one type. The code that sorting made redundant is gone.

What moves, and what a reader sees
Where Before Now
a parameter, variable, constraint or given entry the order its dims: writes sorted by name
a named expression that declares dims: the order its dims: writes sorted by name
a condition on a parameter, a variable or a given condition (ParameterDefined, VariableDefined, ParameterComparison) the order the entry it reads writes sorted by name
a condition on a relation (RelationDefined, RelationComparison, RelationPairComparison) the order the relation lists its key sorted by name
Relation.columns, Relation.key the order the file writes key: and values: the key's columns first, each side sorted by role
JoinColumns.joined, .grouped the columns the call names, then the unnamed key columns the order of Relation.columns
Relation.dims the dimension of each column, in column order, with repeats every dimension once, sorted by name
Mask.dims a frozenset a tuple sorted by name
the typesetter: a sum's indices, a dual's indices, an assumption's frame, a relation in the legend the order of dimensions:, or of the file sorted by name, a relation's key first
the canonical form a dims: list and a relation's sides as written sorted
merge, where no file defines a sum readers that list its dims in two orders are refused accepted, and the sum's dims are sorted

What a reader sees:

  • Every printed index list is in name order, for example p_{g,t} instead of p_{t,g}.
  • An expansion's weights print as \lambda_{b,g,t}, with the breakpoint index first.
  • The mixed lines that fix(language): two files that declare their dimensions in different orders load to the same program #911 left are gone. injection_(b,t) = generator_injection_(b,t) + …
  • A bare relation keyed [generator, bus] prints as connection ⊆ B × G. A relation whose columns share a dimension prints in role order, so Generator_maintenance_cover, keyed {scenario, generator, start, covered}, prints as T × G × Ξ × T.
  • specsolve gets every variable and constraint in name order, so its result layouts change. Its archive stores relation.columns, which are now in the new order.
  • The language reference states the rule for dims once, on the dimensions page, and the rule for a relation's columns on the relations page. The pages that said dims: sets the order of the indices are corrected.
  • The golden output, the generated pages and the pypsa fragments are regenerated with their tools. docs/first-spec.md, docs/reference/typeset.md, the named.md example and docs/several-files.md show printed output and are edited by hand.
The merge with #899, and what it made redundant
  • _frame_of in _lowering.py is removed. A named expression's dims are its body's dims. Two rules make the sets equal: dims: gives only dims the body is indexed over (fix(language)!: a named expression whose dims: gives a dimension its expression is not indexed over is refused at load #898), and the body is indexed only over dims that dims: gives. Both are sorted, so the tuples are equal.
  • One home for the rule. The 14 tuple(sorted(...)) this branch added, and 5 that main had, now call sorted_dims, apart from 3 that read a relation's key. Those keep tuple(sorted(...)), because a key may name one dimension twice, and the refusal of an ambiguous bare relation reads the repeat. test_a_rule_decided_without_data[where-bare-name-of-a-relation-with-two-columns-over-one-dim] fails when sorted_dims takes those too.
  • No second sort. Namespace.leaf_dims is sorted when it is built, so a leaf reads it as it is (_expression_resolver.py, _bound). The typesetter's dual reads the constraint's dims without sorted().
  • Mask.dims is a tuple sorted by name, not a frozenset. A named condition's frame, a TranslatedCondition's dims, two refusal messages and the typesetter's assumption frame read it as it is. The three places that took a union or a difference build a set first. program.py sorts inline, because it cannot import _dimensions.py, which imports it.
  • Docs. feat(program): each expression node states the dimensions it is read over #899's paragraph in docs/reference/reading.md showed an entry's dims as written. It now says that an entry's dims are sorted, as a node's are, and that a mask's .dims are sorted.
  • Conflicts were in CHANGELOG.md (both lines kept), _lowering.py (_frame_of, since removed) and the imports of tests/test_dimensions.py (both sets kept).

#899's test_every_expression_node_states_its_dims_sorted_by_name and this PR's _dims_tuples walker both check the node dims from #899, because the walker reads every dataclass field named dims. The walker also asks each Mask and each Relation for its dims, which are properties.

Relations
  • Relation.columns holds the key's columns first, then the values', each side sorted by role. The key stays in front because a relation reads as a map from its key to its values. Relation.key is sorted by role.
  • JoinColumns.joined and .grouped follow the same order, through Relation.in_order. The typesetter prints a grouped sum's indices in it.
  • Relation.dims is every dimension a column is over, once, sorted by name. Its two callers took a set of it already.
  • The canonical form sorts the key: and values: of each relation, a list or a mapping.
  • Tests. dims_reversed reverses each relation's key and values as well, so the tests that a file's order reaches neither the program, the printed document nor the canonical text cover relations. Expected JoinColumns in test_a_relation_lowers_with_the_join_each_call_names are in the new order.

The order is by role, not by dimension, because two columns may share a dimension and only the role tells them apart.

Why

A file's order now reaches nothing a consumer reads, so one rule holds everywhere. Two files that differ only in a dims: list or a relation's column order state the same spec and write the same canonical text. A merge has nothing to refuse. If a custom index order is ever needed, it can be added later as one declared order for the whole spec.

Verification

Tests.

  • test_the_order_a_file_writes_dimensions_in_reaches_no_value_of_the_program reverses dimensions:, every dims: list and each relation's columns in every example, and asserts an equal program, before and after expand().
  • test_every_dims_of_the_program_is_sorted_by_name walks every program and its expansion, and checks each dims and frame, a mask's and a relation's included.
  • test_the_order_a_dims_list_is_written_in_prints_nowhere asserts that every example prints the same in all three formats with every dims: list and each relation's columns reversed.
  • test_a_condition_on_a_relation_states_its_key_dims_sorted_by_name uses a relation keyed [snapshot, generator] and reads it three ways.
  • test_a_file_written_backwards_writes_the_same_text now also reverses every dims: list and each relation's columns.

Gates on cc88eb2:

  • pixi run lint passes.
  • pixi run test passes: 3269 passed, 1 skipped.
  • pixi run docs-build passes.
  • Every generator re-run gives no diff.
  • compile-tex was not run, because the container cannot download the TeX bundle. CI runs it.

53b42fb alone fails 3 cases of test_a_condition_on_a_relation_states_its_key_dims_sorted_by_name, because the walker then also yields the mask around the leaf. f328aae starts that test at the leaf.

Mutation table. Each change was made by hand 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 each time. Rows 1 to 13 were run before the merge with #899; rows 1 to 9 on the commit before the relation test was added, rows 10 to 13 with it. Row 14 was run on 53b42fb, and rows 15 to 20 on cc88eb2.

Change Caught by
a variable's dims as written 250 failures
a constraint's dims as written 233 failures
a parameter's dims as written 185 failures
a declared named expression's dims as written 133 failures
a leaf condition's dims as written 143 failures
a sum's indices in the order of dimensions: 20 failures: golden output, generated pages, two test_line cases
the canonical form keeps a dims: list 47 failures, among them test_a_file_written_backwards_writes_the_same_text and test_the_fragments_merge_to_the_one_file
a composed sum takes its first reader's order test_the_composed_sum_is_read_over_the_readers_frame
a relation comparison's dims in key order, without the relation test no failure, so the relation test was added
a relation comparison's dims in key order [a-column-against-a-label]
a bare relation's dims in key order [the-bare-relation]
a relation pair's dims in key order [a-column-against-another-relation]
a given expression's dims as written 70 failures
Mask.dims sorted in reverse 56 failures, among them 25 cases of test_every_dims_of_the_program_is_sorted_by_name
a relation's columns as the file writes them 33 failures, among them test_the_order_a_file_writes_dimensions_in_reaches_no_value_of_the_program[pypsa.yaml]
a relation's columns all sorted together, the key not first test_a_relation_is_declared_as_the_file_declares_it
a join's columns as the call names them test_a_relation_lowers_with_the_join_each_call_names
a join's columns all sorted together, the key not first test_a_value_column_the_call_consumes_is_a_condition_like_a_produced_one
Relation.dims one per column, in column order 27 failures, among them test_every_dims_of_the_program_is_sorted_by_name[operators/at.yaml]
the canonical form keeps a relation's sides as written 7 failures of test_a_file_written_backwards_writes_the_same_text

Coverage moved.

  • test_readers_that_order_the_frame_apart_are_refused is now test_readers_that_list_the_frame_in_two_orders_compose_one_sum. It checks that both orders give one canonical text.
  • The the-dims-of-an-entry case of test_an_order_the_form_keeps_is_a_difference_in_the_text is removed, because the form now sorts that order.
  • test_the_order_of_dimensions_reaches_no_value_of_the_program from fix(language): two files that declare their dimensions in different orders load to the same program #911 is widened to reverse every dims: list and each relation's columns as well.
Not done
  • The spec keeps each dims: list and each relation's columns as the file writes them, and to_yaml() writes them back that way, because a Spec is the author's file. Only the program, the typesetter and the canonical form sort.
  • JoinColumns.joined_dims and the other per-column *_dims stay one dimension per column, paired with the roles by position. They are not sets of dims.
  • specsolve follows after a release, as a separate PR there. It can then drop its own scope.in_declaration_order, and read region.when.dims as it is.

Generated by Claude Code

…orted by name, whatever order a file writes

A declared dims: now reaches the program sorted, as a derived frame
already did: parameters, variables, constraints, given entries, a named
expression's declared dims, and the conditions that read them. The
typesetter orders a sum's dummies, a dual's indices and an assumption's
frame by name instead of by the dimensions: section. The canonical form
sorts every dims: list, and merge no longer refuses readers that list a
sum's dims in different orders, since the order no longer reaches
anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2S9sN1jLGGMCCwqwVyGD6
…tates its dims sorted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2S9sN1jLGGMCCwqwVyGD6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2S9sN1jLGGMCCwqwVyGD6
@FBumann FBumann added the v0.4.0 label Oct 20, 2026
@read-the-docs-community

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

Copy link
Copy Markdown

`_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
A named expression's dims are its body's dims: dims: gives only dims the
body is indexed over (#898), the body is indexed only over dims dims:
gives, and both are sorted by name. So `_frame_of` goes. The leaf dims
the namespace holds and a constraint's dims are already sorted, so
reading them sorts nothing again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
… other dims is

`Mask.dims` was the one `dims` in the program that was a frozenset. It is
now sorted by name, so a named condition's frame, a translated
condition's dims and the typesetter's assumption frame read it as it is.
The three places that took a union or a difference build a set first.

The test that every program dims is sorted asks each mask for its dims,
since `Mask.dims` is a property rather than a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
…ition, not the mask around it

The walk now also yields `Mask.dims`, so the test starts at the leaf it
asserts on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
…orted by name

The order a file writes a relation's `key:` and `values:` in now reaches
nothing, as #914 does for `dims:`. `Relation.columns` holds the key's
columns first, each side sorted by role, and a join's columns follow that
order (`Relation.in_order`). The legend prints the table in it, and the
canonical form sorts each side.

`Relation.dims` was the dimension of each column, in column order and
with a repeat where two columns share one. It is now every dimension a
column is over, once, sorted by name, as every other `dims` is. The test
that every program dims is sorted asks each relation for it, and the
tests that reverse every `dims:` list reverse each relation's columns
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
…s:` still changes

The order of the entries under `dimensions:` changes nothing in the
program, but the typeset document lists the dimensions in that order, and
the dimension declared first takes a shared first letter as its index.
The sum's docstring and the golden caption no longer say "file order".

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

FBumann commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Prompt: Could it be condensed into less PRs? Its too chaotic for me atm

Note

The following content was generated by AI.

Superseded by #924, which squashes #914, #916 and #921 into one commit on main. This PR's mutation table for the program-side guards stays the record that #924 links to. The branch is kept.


Generated by Claude Code

@FBumann FBumann closed this Oct 21, 2026
FBumann pushed a commit that referenced this pull request Oct 21, 2026
Each conflict resolves to the tree of #921's head, which already held
#914, #916, #918 and #921 together. The changelog keeps #918's line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu
@FBumann
FBumann removed this pull request from stack #917 October 21, 2026 07:37
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.

2 participants