Skip to content

feat(spec): a spec writes back the fields its file wrote, and the canonical form writes no default - #918

Merged
FBumann merged 24 commits into
claude/pensive-goldberg-s12y69from
feat/write-as-set
Oct 23, 2026
Merged

FBumann merged 24 commits into
claude/pensive-goldberg-s12y69from
feat/write-as-set

Conversation

@FBumann

@FBumann FBumann commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: I like that. However, could we get closer to the file the user wrote initially?

Prompt: Yes! Implement it

Prompt: We should probably change how this comparision works...

Prompt: The callable claims concept seems to complex...

Prompt: Should we keep g: {description: null}? It might mean an overwrite with null…?

Prompt: Why do we not keep 1. More strict for now?

Prompt: I like the content. But it should be less jargon

Note

The following content was generated by AI.

to_yaml() writes back the fields the file wrote, plus version:. 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

Field Did the file write it? Before to_yaml() canonical
ordered: false yes dropped kept dropped
a dimension's dtype: str no added not written not written
a parameter's dtype: float no added not written not written
a variable's domain: continuous, missing: absent no added not written not written
version: 0 no added written written
missing: refused yes dropped kept dropped
  • One rule. Before, four classes each had their own serializer, and each dropped a different default. Now one serializer writes the fields the input set, and leaves out a null and an empty mapping. The four classes that can be written as a bare string keep their own serializer.
  • The canonical form leaves out every field that holds its default, except version:. So two files that differ only in a default they write out give the same text.
  • An entry that sets no field is still written. generator: {} and generator: {description: null} both write back as generator: {}, because the entry still declares the dimension. An empty kind under given: is not written.
  • A merged spec writes the fields its fragments wrote. docs/examples/library/composed.md loses 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:

Field On What the patch may do
description a dimension or a relation replace the base's text, or remove it with null
ordered a dimension set it to true, but not to false or null where the base has true
missing a relation replace the base's value, or put back the default with null

A 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, SETTINGS names ordered and missing. One helper, _same, compares two entries field by field and leaves out the fields its caller names. _declared, _claims, _reading_claims, _joined and the claims= 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_set the fields that an input sets, a default included. _as_set keeps those fields, except for a null or an empty mapping. Spec's own serializer adds version and removes nulls. A mapping that removing nulls left empty is dropped. A mapping written empty stays.

A branch that read exclude_defaults in 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.

Fixed in Bug
eb1ae90 g: {description: null} wrote back as nothing, so the written file named a dimension it did not declare.
eb1ae90 given: {parameters: {}} was written back.
eb1ae90 override accepted 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.
9d49497 A patch that wrote description: null kept the base's description.
9d49497 A patch that wrote only a description was refused over a dimension whose dtype is not the default, because the left-out dtype read as the default.
e06f56a A patch that wrote only ordered: true was refused in the same way.
e06f56a ordered: null over an ordered dimension failed with a schema error, not with the message that names the fix.

A null in a fragment for merge is not a bug. merge reads each fragment after it loads, and there a null means the field is not set.

846f33c changes only words: the how-to, the module docstring, the refusal message, and the name CLAIMS, which is now SETTINGS.

Tests.

  • test_a_spec_writes_the_fields_its_file_wrote_and_its_version replaces test_a_default_is_written_out_and_an_absence_is_not, which asserted the old rule.
  • test_a_dimension_writes_ordered_as_its_file_wrote_it replaces test_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_refused now has three cases: false with the dtype, false alone, and null.
  • New: 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, and test_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, and null on a dimension and on a relation.

Moving onto #924. 0c0f1ee merges #924 into this branch. Each conflict resolves to the tree of #921's head, which held #914, #916, #918 and #921 together. 3a461e7 merges #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 on 0c0f1ee. 3a461e7 changes only CHANGELOG.md.
  • Every generator re-run on 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 on eb1ae90, in code that later commits did not change. Rows 6 to 10 ran on e06f56a. 846f33c renames and rewords only. The rows were not re-run after the move onto #924, which changes no line this PR touches in src/.

Change Caught by
the writer keeps a null field 1 failure: test_an_entry_whose_only_field_is_null_still_declares_it
the writer keeps an empty-mapping field 13 failures, among them test_a_whole_model_writes_no_given_section
_same compares descriptions 13 failures and 12 errors, among them test_the_order_of_the_fragments_reaches_no_canonical_text[an-objective-on-a-sum]
a patch compares missing 2 failures, among them test_a_patch_changes_what_a_key_the_map_leaves_out_means[restated-absent]
fragments do not join ordered 2 failures, among them test_a_patch_restates_a_dimension_as_the_schema_reads_it[widened-to-ordered]
a patch may not change ordered or missing 11 failures, among them test_a_patch_restates_a_dimension_as_the_schema_reads_it[ordered-left-out]
a patch may set ordered to false 3 failures of test_a_patch_that_withdraws_ordered_is_refused
a patch that changes only these fields is checked as a full declaration 6 failures, among them test_a_patch_that_restates_a_shared_entry_gives_it_its_own_description[the-description-alone]
a null the patch writes is kept, not reset to the default 2 failures: test_a_patch_that_withdraws_ordered_is_refused[null] and test_a_patch_changes_what_a_key_the_map_leaves_out_means[null-puts-back-the-default]
fragments compare ordered 2 failures of test_a_dimension_one_fragment_declares_ordered_is_ordered_in_the_composition
Not done

🤖 Generated with Claude Code

https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu

claude added 11 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
@read-the-docs-community

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

Copy link
Copy Markdown

@FBumann
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
@FBumann FBumann added the v0.4.0 label Oct 21, 2026
…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
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
@FBumann
FBumann changed the base branch from feat/spec-sorts-lists to claude/pensive-goldberg-s12y69 October 21, 2026 07:37
@FBumann
FBumann added this pull request to stack #925 October 21, 2026 07:38
@FBumann FBumann added the v0.5.0 label Oct 21, 2026
@FBumann FBumann removed the v0.4.0 label Oct 22, 2026
@FBumann
FBumann merged commit 037502a into main Oct 23, 2026
11 checks passed

FBumann commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

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.

Superseded by #924. This branch was merged into #924 at 037502a, together with #923 and #927. The write-back and patch rules are unchanged there. This PR stays open as a draft for its review history.


Generated by Claude Code

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