Repository navigation
feat(spec): a spec writes back the fields its file wrote, and the canonical form writes no default - #918
Merged
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
FBumann
added this pull request to stack #917
October 20, 2026 19:48
…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
This was referenced Oct 21, 2026
# Conflicts: # CHANGELOG.md
FBumann
removed this pull request from stack #917
October 21, 2026 07:37
FBumann
changed the base branch from
feat/spec-sorts-lists
to
claude/pensive-goldberg-s12y69
October 21, 2026 07:37
FBumann
added this pull request to stack #925
October 21, 2026 07:38
This was referenced Oct 21, 2026
Merged
Contributor
Author
Note The following content was generated by AI. Superseded by #924. This branch was merged into #924 at Generated by Claude Code |
This was referenced Oct 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
The following content was generated by AI.
to_yaml()writes back the fields the file wrote, plusversion:. The canonical form leaves out every default. A patch may change three fields of a dimension or a relation its base declares, and no others.Stacked on #924, which squashes #914, #916 and #921.
What the writer writes
to_yaml()ordered: falsedtype: strdtype: floatdomain: continuous,missing: absentversion: 0missing: refusedversion:. So two files that differ only in a default they write out give the same text.generator: {}andgenerator: {description: null}both write back asgenerator: {}, because the entry still declares the dimension. An empty kind undergiven:is not written.docs/examples/library/composed.mdloses 7 lines of defaults that its fragments never wrote.What a patch may change
For a dimension or a relation that the base declares, a patch may change these three fields:
descriptionnullorderedtrue, but not tofalseornullwhere the base hastruemissingnullA patch entry that writes only these fields changes only these fields. An entry that writes any other field, or no field, must declare the dimension or the relation as the base does. A field it leaves out reads as its default. This is strict on purpose: a looser rule can come later without breaking a patch that loads now.
Between fragments in
merge, nothing changes. Two fragments that declare one dimension or relation must agree on every field except two. A dimension is ordered if one fragment says so. The first description given is kept.In the code,
SETTINGSnamesorderedandmissing. One helper,_same, compares two entries field by field and leaves out the fields its caller names._declared,_claims,_reading_claims,_joinedand theclaims=callable are gone.In the docs, the reading guide says what
to_yaml()writes. The how-to on composing has the table above, and its quoted refusals match what the code prints.Why
Before,
to_yaml()added some defaults that a file left out, and dropped others that it wrote. Each class had its own rule, so no one rule said what the writer writes. Now a file reads back as its author wrote it, apart from comments, layout and the sorted lists and entries from #924. Writing every default would not make a change of a default safe, because hand-written files leave defaults out.version:tells a reader which version of the language the file is written in, so the writer always writes it.Before, a patch was checked as "the same as the base, except for these fields", and each field needed its own decision about which list it was on. The bugs below came from that.
Method, bugs found, gate output, mutation table
Method. pydantic records in
model_fields_setthe fields that an input sets, a default included._as_setkeeps those fields, except for a null or an empty mapping.Spec's own serializer addsversionand removes nulls. A mapping that removing nulls left empty is dropped. A mapping written empty stays.A branch that read
exclude_defaultsin the serializer was removed. No input reaches it, because a field that the input did not set still holds its default: no validator sets one after validation. With the branch deleted, the suite stayed green.Bugs found in review. Each has a test that failed before its fix.
eb1ae90g: {description: null}wrote back as nothing, so the written file named a dimension it did not declare.eb1ae90given: {parameters: {}}was written back.eb1ae90overrideaccepted a patch that gave a dimension or a relation a new description, and then dropped the new text. On the base before this PR, the patch was refused.9d49497description: nullkept the base's description.9d49497dtypeis not the default, because the left-outdtyperead as the default.e06f56aordered: truewas refused in the same way.e06f56aordered: nullover an ordered dimension failed with a schema error, not with the message that names the fix.A
nullin a fragment formergeis not a bug.mergereads each fragment after it loads, and there a null means the field is not set.846f33cchanges only words: the how-to, the module docstring, the refusal message, and the nameCLAIMS, which is nowSETTINGS.Tests.
test_a_spec_writes_the_fields_its_file_wrote_and_its_versionreplacestest_a_default_is_written_out_and_an_absence_is_not, which asserted the old rule.test_a_dimension_writes_ordered_as_its_file_wrote_itreplacestest_a_dimension_writes_ordered_only_where_it_is_true. It has three cases: left out, written false, written true.test_a_patch_that_withdraws_ordered_is_refusednow has three cases: false with the dtype, false alone, andnull.test_the_canonical_form_writes_no_default,test_an_entry_whose_only_field_is_null_still_declares_it,test_an_empty_kind_under_given_is_not_written,test_a_patch_may_add_ordered_alone, andtest_a_patch_that_restates_a_shared_entry_gives_it_its_own_description. The last one has five cases: a dimension, a relation, the description alone, andnullon a dimension and on a relation.Moving onto #924.
0c0f1eemerges #924 into this branch. Each conflict resolves to the tree of #921's head, which held #914, #916, #918 and #921 together.3a461e7merges #924's changelog commit. Against #924 this PR's diff is its own 10 files again, +304 and −152.Gates on
3a461e7:pixi run lint: passed.pixi run test: 3550 passed, 1 skipped.pixi run docs-build: passed on0c0f1ee.3a461e7changes onlyCHANGELOG.md.0c0f1ee: no diff.compile-tex: did not run here, because Tectonic could not download its bundle in the sandbox. CI runs it.Mutation table. A script made each change and ran the full suite. It restored the file with
git checkout --, deleted__pycache__before and after each run, and checked that the tree came back clean each time. Rows 1 to 5 ran oneb1ae90, in code that later commits did not change. Rows 6 to 10 ran one06f56a.846f33crenames and rewords only. The rows were not re-run after the move onto #924, which changes no line this PR touches insrc/.test_an_entry_whose_only_field_is_null_still_declares_ittest_a_whole_model_writes_no_given_section_samecompares descriptionstest_the_order_of_the_fragments_reaches_no_canonical_text[an-objective-on-a-sum]missingtest_a_patch_changes_what_a_key_the_map_leaves_out_means[restated-absent]orderedtest_a_patch_restates_a_dimension_as_the_schema_reads_it[widened-to-ordered]orderedormissingtest_a_patch_restates_a_dimension_as_the_schema_reads_it[ordered-left-out]orderedto falsetest_a_patch_that_withdraws_ordered_is_refusedtest_a_patch_that_restates_a_shared_entry_gives_it_its_own_description[the-description-alone]nullthe patch writes is kept, not reset to the defaulttest_a_patch_that_withdraws_ordered_is_refused[null]andtest_a_patch_changes_what_a_key_the_map_leaves_out_means[null-puts-back-the-default]orderedtest_a_dimension_one_fragment_declares_ordered_is_ordered_in_the_compositionNot done
ruamel.yaml, which is a new runtime dependency. It would help only a spec that is loaded from one file and written back, and there the file itself is the exact copy.==does not comparemodel_fields_set, so a spec that writesordered: falseequals one that leaves it out. The canonical form gives the same text where==is true.0bound comes back as0.0, because the field is afloat.version:in a hand-written file is a separate question for before 1.0.merge's docs ("claim", "laid over") is not changed, because this PR does not change those rules.spec.py.🤖 Generated with Claude Code
https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu