Skip to content

feat(spec)!: a loaded spec is sorted by name and cannot be changed, writes back the fields its file wrote, and prints one canonical text for two files that differ only in order or spelling - #924

Open
FBumann wants to merge 37 commits into
mainfrom
claude/pensive-goldberg-s12y69
Open

FBumann wants to merge 37 commits into
mainfrom
claude/pensive-goldberg-s12y69

Conversation

@FBumann

@FBumann FBumann commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

I wanted to leave off things a bit cleaner and combined the 3 topic related PRs into this big one. Hope this is better to work with. The other ones are drafts now and lost their label.

Prompt: Why should we not do one PR per topic? Keep the enum one, combine the "missing" ones, and combine the ones making the spec fully sorted and predictable?

Prompt: i think the sorting one should replace the others with the clear goal of making everything sorted and pedictable, saying the others are superseeded and making them drafts. Not closing them

Note

The following content was generated by AI.

A loaded spec is sorted by name and cannot be changed. to_yaml() writes back the fields its file wrote. Two files that differ only in order, defaults or the spelling of a condition write one canonical text.

This PR supersedes #918, #923 and #927, which stay open as drafts and keep their review history. It also still holds #914, #916 and #921, which were squashed into it earlier.

Part From What changes
Sorted this PR Entries, dims: lists, relation columns, cases: regions, a macro's kwargs: and the lists of names in a call are sorted by name
Cannot be changed #923 A field cannot be set. A section is a read-only Frozen mapping, and a list is a tuple. override is how you change a spec
Writes back what the file wrote #918 to_yaml() writes the fields the file set, plus version:. The canonical form writes no defaults. A patch may change only description, ordered and missing of a dimension or a relation that its base declares
One text per condition #927 The canonical form prints each where:, when:, condition: and holds: from its parsed tree. AND/OR operands are sorted, keywords are upper case, and spacing and brackets are made the same
Breaking changes, for the release notes

Spec API (#923).

  • A spec field cannot be set, and a section cannot gain or lose an entry. Use override(spec, [patch]).
  • A list in a spec is a tuple: spec.variables['p'].dims == ('g',). to_dict() and to_yaml() still write lists.
  • A section is a Frozen[str, X] mapping, not a dict. Code that tests isinstance(section, dict) must test Mapping.
  • Sealed in _sealed.py is now Frozen in _frozen.py. The same class holds the program's groups.

Order (this PR).

  • Mask.dims is a tuple sorted by name, not a frozenset. A tool that compares it with a set must build a set first.
  • Relation.columns lists the key's columns first, and each side is sorted by role. Relation.dims lists each dimension once, sorted by name.
  • Every dims and frame in the program is sorted by name. specsolve's result layouts change.
  • A spec's sections iterate in name order, and to_yaml() writes them in that order.

Patches (#918). A patch entry over a dimension or a relation that its base declares may write only description, ordered (only to true) and missing. Otherwise it must declare the whole entry, and a field it leaves out reads as its default.

Written output (#918). to_yaml() keeps ordered: false and missing: refused where the file wrote them, and no longer adds dtype: str, dtype: float or domain: continuous where the file did not write them. It always writes version:.

How sorting and freezing work together

#923 and the sorting part conflicted in spec.py, _lowering.py and four test files. The rule is: sort first, then freeze.

New probe: test_a_loaded_spec_cannot_be_written_to[a-relation-column-removed]. Before it, a relation side written as a mapping could come back as a plain dict after sorting, and no test failed (row 2 below).

Gates and mutation table

Gates on 188f13e, which also merges main (0.4.0, #946, #947):

  • pixi run lint: passed.
  • pixi run test: 3591 passed, 1 skipped (3590 before the new probe).
  • pixi run docs-build: passed.
  • Every generator re-run (schema, golden output, gallery, notation, spec_math, home_math, expansion_math): no diff.
  • compile-tex: did not run. Tectonic cannot download its bundle in this sandbox. CI runs it.

Mutation table for the sort-then-freeze rule, on 76f3ff2 and on 188f13e for the probe. Each change was made by a script. __pycache__ was dropped before and after each run, the full suite ran, the file was restored with git checkout --, and the tree came back clean each time.

Change Caught by
_by_name returns a dict 3 failures: test_a_loaded_spec_cannot_be_written_to[an-entry-added], [an-entry-removed], [a-case-removed]
_sorted_by_role returns a dict for a mapping nothing on 76f3ff2. On 188f13e: [a-relation-column-removed]
_sorted returns a list 130 failures, among them test_a_patch_names_only_the_field_it_changes

The mutation tables of each part are in their own PRs, and those rows were not re-run here: sorting in this PR's earlier body (below), the write-back and patch rules in #918, the frozen spec in #923, and the canonical conditions in #927. The merges changed no line those rows mutate, except the three rows above.

Sorting: method and tests (from the earlier body of this PR)

Six validators on the spec models do the sorting, and nothing after the load sorts again. RelationSpec._sorted_by_role sorts key and values. field_validator('*') on Spec and on GivenSpec, and field_validator('cases') on ExpressionSpec, sort a mapping by key through _by_name. The canonical form's normalised sorts a NameListNode and the columns of a ColumnsNode. The program reads the spec's order through sorted_dims.

Piecewise links and a macro's args: keep the file's order, because their position carries meaning.

Change Caught by
a dims: list kept as written 447 failures
a relation side written as a list kept as written 16 failures
a relation side written as a mapping kept as written 31 failures
GivenSpec keeps the file's order 121 failures
cases: keeps the file's order 5 failures
Spec keeps the file's order 346 failures
the form prints a list of names as written 1 failure
the form prints a relation's columns as written 1 failure
a macro's kwargs: kept as written 1 failure

Those rows ran on cc2d044 and 887723f.

Not done

🤖 Generated with Claude Code

https://claude.ai/code/session_01W7L7U7Av5v8wnKV6eDpXfi

claude added 26 commits October 20, 2026 15:32
…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
`_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
…lation sorted by name

Their order reaches nothing a consumer reads since the program sorts
them, so the spec sorts them once as it loads, and every reader after it
reads them as they are. A spec keeps its entries in the order its file
lists them, because the typeset document prints them in that order.

Two files that differ only in a list's order load to equal specs and
write back one text. The program, the namespace and the canonical form
no longer sort them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
…onical form writes no default

`to_yaml()` wrote some defaults a file left out, such as `domain:
continuous`, and dropped others it wrote, such as `ordered: false`, by a
rule per class. It now writes the fields the input set, as pydantic
records them, and always `version:`. The canonical form leaves out every
field at its default, whether the file wrote it or not, so two files
that state one spec still write one text. An entry written as `{}`
stays, since it still declares its name.

Composition compared fragments by the dicts the writer gave, so what the
writer left out changed what two fragments were said to say. It now
compares entries as the schema reads them: two peers by value, prose
aside, and a reading by each field it sets against the introducer's
value, a default included. A merged spec writes the fields its fragments
wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNjZXCjH1FpCvxUvDGjWNw
…stated description is the patch's

`_claim` and `_joined` become `_same`, which compares two entries field
by field with prose, `ordered` and the named fields aside, and
`_ordered`, which joins a dimension's `ordered`. A patch that restates a
dimension or a relation now gives it the patch's description.

The writer leaves out a field that holds a null or an empty mapping, so
`g: {description: null}` writes back as `g: {}` rather than vanishing,
and an empty kind under `given:` is not written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gt59g1PnLYFC7aRkfjKFAG
…a patch may change only a description

A restated dimension or relation lays its description over the base's
the way a relation's `missing:` is laid over, and leaves it out of the
comparison. `null` puts back the default, as it does on any field a
patch writes, so a patch that writes `description: null` removes the
base's description. A patch that writes only a description is no longer
compared as a whole restatement of the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gt59g1PnLYFC7aRkfjKFAG
…nd description over the base, and restates its space in full

Each field of a shared entry has one role. `CLAIMS` names the claims,
`ordered` and `missing`, and the description is prose; every other field
is the space. A patch lays the claims and the description over the base,
`null` putting back the default, and a patch that writes a field of the
space restates the whole space, as before.

A patch that wrote only `ordered: true` was compared as a whole
restatement, so over a dimension of another dtype it was refused.
`ordered: null` over an ordered dimension was refused by the schema
rather than with the rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gt59g1PnLYFC7aRkfjKFAG
…which fields a patch may change

The how-to, the module docstring and the refusal say that a patch may
change a description, a dimension's `ordered` and a relation's
`missing`, and nothing else of an entry its base declares, without the
words space, claim, prose and laid over. `CLAIMS` is now `SETTINGS`.
The rules do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gt59g1PnLYFC7aRkfjKFAG
…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
… with the rewrite

`relations: {port_bus: {}}` and `{values: bus}` failed with the schema's
`key: Field required`, without naming the patch or saying how to restate
the relation. They now get the refusal a dimension gets. A misspelled key
in the restatement still fails as a schema error that names the key it
meant.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzRHmodLpxhaZRQc9sgBdZ
…ack out is always the one its program comes from

Every spec model is frozen, each section and mapping is a Sealed
mapping, and each list is a tuple that is written back out as a list.
`override` is the edit. The Expression docstring says that lowering
states a node's dims; a node built by hand holds the dims it was given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsnGSGGQso2HHaNdgWeAt6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsnGSGGQso2HHaNdgWeAt6
…tion columns and `cases:` regions sorted by name, so the order a file writes them in reaches no output

A `Spec` sorts by name, as it loads, the entries of every section and of
each kind under `given:`, each `dims:` list, each side of a relation (the
key's columns first) and the regions under `cases:`. The program, the
typeset document, `to_yaml()` and the canonical form read that one order
and sort nothing again.

The program API changes: `Mask.dims` is a tuple sorted by name, not a
`frozenset`; `Relation.columns` lists the key's columns first, each side
sorted by role; `Relation.dims` lists each dimension once, sorted by name.

Piecewise links and macro arguments keep the file's order, because their
position carries meaning.

This squashes #914, #916 and #921.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu
@FBumann
FBumann requested a review from brynpickering October 21, 2026 07:27
@read-the-docs-community

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

Copy link
Copy Markdown

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
…, so their order reaches no output either

A macro's `kwargs:` are passed by name, so the spec sorts them as it loads.
A list of names in a call, such as `over=[snapshot, generator]`, and the
columns of a relation in a call, such as `by=slot_of[technology, bus]`,
name a set, so the canonical form prints them sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu
…tree, so two conditions that differ only in spelling write one text

A `where:`, `when:`, `condition:` or `holds:` string, and a condition or an
assumption written as a bare string, is parsed and printed rather than kept
as written. `AND`, `OR` and `NOT` print in upper case, the operands of a
chain of `AND` or of `OR` are sorted, a bracket stays only where the grammar
needs it or around a comparison under `NOT`, and each side of a comparison is
normalised as an expression is.

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

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

I'm fine with imposing ordering strictly everywhere we can. It might come up against opposition later when people see their beautifully crafted specs reordered in a way they didn't expect, but let's see if people complain about that before we make concessions on this. Maximising the value of a spec diff should be our priority and ordering enables that.

…s called Frozen

`frozen` is the word Python uses for this, in `frozenset`,
`dataclass(frozen=True)` and pydantic's `frozen=True`. The module is
`_frozen.py`.

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

FBumann commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

I'm fine with imposing ordering strictly everywhere we can. It might come up against opposition later when people see their beautifully crafted specs reordered in a way they didn't expect, but let's see if people complain about that before we make concessions on this. Maximising the value of a spec diff should be our priority and ordering enables that.

My approach would be to do this now, uniformly and strictly, and soften it if users demand it.

If users always had a sorted spec, they will not miss custom sorting that much I think. And if they do, they probably have good reasons to.

Im not entirely sure if you agree to do it like this. Could you clarify?

…ensive-goldberg-s12y69

# Conflicts:
#	CHANGELOG.md
Each validator that sorts now returns a tuple or a Frozen mapping, so a
sorted field is as frozen as the rest of the spec.

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

The sort on a relation side returned a plain dict, and no test wrote into
one, so a sorted side could have come back writable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7L7U7Av5v8wnKV6eDpXfi
@FBumann FBumann changed the title feat(language)!: a loaded spec holds its entries, dims: lists, relation columns and cases: regions sorted by name, so the order a file writes them in reaches no output feat(spec)!: a loaded spec is sorted by name and cannot be changed, writes back the fields its file wrote, and prints one canonical text for two files that differ only in order or spelling Oct 23, 2026

This branch has not been deployed

No deployments
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.

3 participants