Skip to content

feat(language): a patch file extends a model rather than a copy of it - #505

Closed
FBumann wants to merge 5 commits into
mainfrom
claude/mathspec-gems-energy-vniseg
Closed

FBumann wants to merge 5 commits into
mainfrom
claude/mathspec-gems-energy-vniseg

Conversation

@FBumann

@FBumann FBumann commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Can we make the override predictable and usable" — and then "Do a stacked pr with those changes and the reasoning behind it"

Note

The following content was generated by AI.

What this changes

ms.override(base, patches) lays patch files over a base model, a field at a time. Three rules make every collision the caller did not ask for a refusal.

model = ms.override('base.yaml', {'carbon': 'carbon.yaml', 'operate': 'operate.yaml'})
spec = ms.to_spec(model)  # one mapping, checked here and nowhere else
Path('composed.yaml').write_text(spec.to_yaml())  # the file a reviewer diffs

The three rules, and what each one stops

Rule Without it
A partial entry edits, a whole one creates a mistyped name invents a declaration nothing refers to, and the model loads
Sibling patches are disjoint the order the patches are passed in decides the model, and no file records that order
A patch adds or restates an axis, never changes one a dimension changes under the expressions already written over it, silently

Each refusal names both sides and the rewrite:

patches 'pathway' and 'project': both write variables.dispatch.bounds.upper. Patches laid on one base are disjoint, so nothing decides which of two writes wins. Write the change in one patch, or lay one patch on the result of the other: override(override(base, {'pathway': …}), {'project': …}).

Layering is not lost — it is written out. override(override(base, …), …) puts the order on the page, where a reader sees it, rather than in an argument's position, where nobody does.

Why

#302 is the open decision, and this takes the layered side of it — #250's half, not #244's. merge is not ported here: peers and layers obey opposite laws, so one verb cannot be both, and the peer half is still #302's to decide.

#250 named the risk it did not answer: override is designed to collide, so a patch that quietly replaces a constraint the base relied on yields a valid model meaning something else. The three rules above narrow where a silent collision is allowed down to the one place the caller asked for it — the base and one patch, one field. What they do not catch is a patch that replaces a declaration correctly and changes what the model means. That is what the diff is for: Spec.to_yaml() writes the composed model, and the diff against the base is the review.

What is new against #250, and what is carried over

Carried over: the patch-over-base shape, field-by-field laying, null as a declaration-level removal, the stale-removal refusal with the near miss, and the base left untouched.

New here:

The compose verb, and why it is gone again

An earlier head of this branch added python -m math_spec compose, which wrote the composed model to -o and a per-patch account to stderr. 18685d2 removes it.

It had no caller in the tree, and the stderr account cost a change log threaded through eight functions in composition.py that only the shell front read — override() discarded it, and __main__.py imported two private names to reach it. A diff of the composed model says everything the account did, except which of several patches wrote each line, and nothing here needs that yet.

Spec.to_yaml() writes the file. The verb is 25 lines on the day something composes from a shell, and it will know its shape better then.

Why this sits on main rather than on #250's branch

claude/override-a-base is 250+ commits behind and predates the lookups → relations rename, so its examples/composed/ fragments no longer load. The verb is ported forward instead, at the current spellings, which is what the user chose when asked. #244 and #250 stay closed and unrebased.

Verified

No pixi in this environment, so the gates were run against a python 3.13 interpreter with the runtime and docs dependencies installed by pip, not the pinned solve.

  • pytest -q — 1302 passed, 6 skipped on 18685d2 (1278 on origin/main).
  • ruff check and ruff format --check — clean, at 0.15.8, where pixi.toml pins 0.16.1. Formatting could differ.
  • mkdocs build --strict — builds, after dropping docs.python.org/objects.inv from mkdocs.yml locally, which this environment's proxy refuses with a 403. The dropped line is not in the commit.
  • prettier --check (3.8.1) on the changed pages — clean. typos clean.
  • Sentence length on the new page: n 37, avg 12.1, median 11, over 25 words 0.

Not run: reuse, zizmor, taplo, and compile-tex — not installed here. pyrefly reports the same pre-existing stub errors with and without this diff.

Mutation table

Every guard deleted in turn, whole suite run, then restored. Taken on the head that still carried the shell front, so the counts include its two tests.

Mutation Result
a partial entry creates instead of refusing 4 failed
sibling patches are not compared 3 failed
an axis may be redeclared 1 failed
a stale removal is ignored 2 failed
a null removes at any depth 1 failed
the base and the patches are not copied 1 failed
restored 1303 passed

The last two were still green on the first pass, and both tests were the problem rather than the guards. The null-at-depth case set where on a variable whose base declared none, so deleting the key and setting it to None were the same mapping. The copy case asserted only that the base was equal afterwards, which a shared sub-object satisfies; it now asserts that no declaration in the result is the base's or the patch's own object.

Deliberately not done

  • No merge. The peer half of shared math has no composition verb, so a component library ships as generated YAML or a script #302 is a separate decision and a separate verb.
  • No shell verb, per above, and no provenance object for the same reason: the composed file is the artifact, and a report would be a second way to learn what the diff says.
  • No qualified names. chore(main): release 0.0.0-alpha.4 #29's ground, and unchanged by this.
  • examples/ is untouched. A patch file is a load error on its own, so it would need a NOT_MODELS entry in tools/render_tex.py; the worked example lives on the how-to page and in tests/test_composition.py instead.
  • One rename came with it. tests/fixtures.override is now varied, because the fixture and the verb cannot both be override in one suite. No coverage moved; the helper is unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU


Generated by Claude Code

…ne name means one thing

The fixture helper and the new file-level verb were both called `override`,
and a test file that wanted the second would import the first. The helper
replaces a dotted path wholesale; the verb lays a patch over a declaration
field by field. Two rules under one word is one too many.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
`override(base, patches)` lays each patch over the base a field at a time, so
a patch says only what it changes and a framework's model can be extended
without being copied. The composed mapping goes through `to_spec` like any
file, so nothing here resolves a name or checks a dim.

The verb is built to collide, so every collision the caller did not ask for is
refused:

- an entry naming some fields must land on a declaration the base has, and the
  refusal names the near miss and what the entry is short of;
- two patches writing one field are refused, both named, which is what stops
  the order they are given in from deciding a model;
- a dimension or a relation may be added or restated exactly, never changed.

`null` under a declaration's name removes it, and a removal the base cannot
satisfy is refused. The marker is positional, so `where: null` is still the
mask the schema takes.

`python -m math_spec compose base.yaml -p patch.yaml -o composed.yaml` writes
the composed model and says on stderr what each patch added, edited or removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
The recipe: write the base, write each patch as the change it makes, name the
patches, and read the composed file. Each refusal is quoted whole, since the
message names the rewrite. `limits.md` gains the paragraph that says why the
verb refuses what it refuses, which is the page that argues rather than
instructs.

Sentence length on the new page: n 37, avg 12.1, median 11, over 25 words 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
@read-the-docs-community

read-the-docs-community Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 math-spec | 🛠️ Build #34608005 | 📁 Comparing 18685d2 against latest (479b11b)

  🔍 Preview build  

3 files changed
+ howto/compose/index.html
+ reference/math_spec/composition/index.html
± about/limits/index.html

…pelling is not corrected

`_block` returns the schema's block class rather than `type[Any]`, so reading
`model_fields` off it is typed, and `_agrees` returns a bool rather than
whatever `==` gave it. Both were pyrefly errors that the local environment
could not run.

`balnce` joins `wher` and `generatr` in the typos allowance: it is the input to
the did-you-mean suggester in the composition tests and on the how-to page, so
correcting it deletes the case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
`compose` had no caller in the tree, and the per-patch summary it printed was
a change log threaded through eight functions that only it read. Both go, with
the two tests and the how-to step that documented them. `override` is what it
always was, and the composed model is written out with `Spec.to_yaml()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants