Repository navigation
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
Conversation
…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
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JzRHmodLpxhaZRQc9sgBdZ
# Conflicts: # CHANGELOG.md
… 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
…dy are 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
…m API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu
# Conflicts: # CHANGELOG.md
…, 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu
brynpickering
left a comment
There was a problem hiding this comment.
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
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
dims: lists, relation columns and cases: regions sorted by name, so the order a file writes them in reaches no output
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.
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.
dims:lists, relation columns,cases:regions, a macro'skwargs:and the lists of names in a call are sorted by nameFrozenmapping, and a list is a tuple.overrideis how you change a specto_yaml()writes the fields the file set, plusversion:. The canonical form writes no defaults. A patch may change onlydescription,orderedandmissingof a dimension or a relation that its base declareswhere:,when:,condition:andholds:from its parsed tree.AND/ORoperands are sorted, keywords are upper case, and spacing and brackets are made the sameBreaking changes, for the release notes
Spec API (#923).
override(spec, [patch]).spec.variables['p'].dims == ('g',).to_dict()andto_yaml()still write lists.Frozen[str, X]mapping, not adict. Code that testsisinstance(section, dict)must testMapping.Sealedin_sealed.pyis nowFrozenin_frozen.py. The same class holds the program's groups.Order (this PR).
Mask.dimsis a tuple sorted by name, not afrozenset. A tool that compares it with a set must build a set first.Relation.columnslists the key's columns first, and each side is sorted by role.Relation.dimslists each dimension once, sorted by name.dimsandframein the program is sorted by name. specsolve's result layouts change.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 totrue) andmissing. Otherwise it must declare the whole entry, and a field it leaves out reads as its default.Written output (#918).
to_yaml()keepsordered: falseandmissing: refusedwhere the file wrote them, and no longer addsdtype: str,dtype: floatordomain: continuouswhere the file did not write them. It always writesversion:.How sorting and freezing work together
#923 and the sorting part conflicted in
spec.py,_lowering.pyand four test files. The rule is: sort first, then freeze.Dims = Annotated[Listed[str], AfterValidator(_sorted)].Listedis feat(spec)!: a loaded spec cannot be changed, so the file it writes back out is always the one its program comes from #923's tuple that is written back out as a list._sortedreturns a sorted tuple. A macro'skwargs:uses the same validator._by_name, which sorts a section's entries and the regions undercases:, now returns aFrozenmapping.RelationSpec._sorted_by_rolereturns a sorted tuple, or a sortedFrozenmapping._frame_ofstays deleted, as the sorting part deleted it.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 plaindictafter sorting, and no test failed (row 2 below).Gates and mutation table
Gates on
188f13e, which also mergesmain(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.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
76f3ff2and on188f13efor 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 withgit checkout --, and the tree came back clean each time._by_namereturns adicttest_a_loaded_spec_cannot_be_written_to[an-entry-added],[an-entry-removed],[a-case-removed]_sorted_by_rolereturns adictfor a mapping76f3ff2. On188f13e:[a-relation-column-removed]_sortedreturns a listtest_a_patch_names_only_the_field_it_changesThe 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_rolesortskeyandvalues.field_validator('*')onSpecand onGivenSpec, andfield_validator('cases')onExpressionSpec, sort a mapping by key through_by_name. The canonical form'snormalisedsorts aNameListNodeand the columns of aColumnsNode. The program reads the spec's order throughsorted_dims.Piecewise links and a macro's
args:keep the file's order, because their position carries meaning.dims:list kept as writtenGivenSpeckeeps the file's ordercases:keeps the file's orderSpeckeeps the file's orderkwargs:kept as writtenThose rows ran on
cc2d044and887723f.Not done
0 < p_maxtop_max > 0), in a condition or in an expression.to_yaml()writes an expression or a condition string as the file wrote it. Only the canonical form reprints them.to_yaml(). That would needruamel.yaml, a new runtime dependency.one_of:stays in feat(language): a dimension may list the labels it allows, and a condition that names another label fails at load #915, andmissing:stays in feat(language)!: a missing row reads as the value the file names, andmissing: neutralis gone #894. Each is its own topic.🤖 Generated with Claude Code
https://claude.ai/code/session_01W7L7U7Av5v8wnKV6eDpXfi