Skip to content

feat(spec)!: a loaded spec cannot be changed, so the file it writes back out is always the one its program comes from - #923

Draft
FBumann wants to merge 4 commits into
mainfrom
claude/sharp-volta-7qv93g
Draft

FBumann wants to merge 4 commits into
mainfrom
claude/sharp-volta-7qv93g

Conversation

@FBumann

@FBumann FBumann commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: Would t fully freezing be much safer and predictable? I see no reason not to

Do a pr for this from main

Note

The following content was generated by AI.

What this changes

A Spec cannot be changed after it loads. Each field is frozen, each section and mapping is a read-only Frozen, and each list is a tuple. To change a spec, lay a patch over it with override.

How
  • _StrictSpec is frozen=True, so assigning a field raises ValidationError at every level.
  • Each section, and each mapping inside an entry, is Frozen[str, X]. Frozen gains __get_pydantic_core_schema__: it validates as the dict it freezes, then freezes it, and it is written back out as a dict. cases: checks its length before it freezes, so the schema keeps minProperties: 1 and the refusal keeps pydantic's own words.
  • Frozen was called Sealed, in _sealed.py. It is now Frozen, in _frozen.py, because frozen is the word Python uses for this (frozenset, dataclass(frozen=True), pydantic's frozen=True). The program's groups and a parsed node's keyword arguments use the same class, so the rename reaches them too.
  • Each list field is Listed[X]: a tuple once the spec loads, and a list again in to_dict() and to_yaml(). Plain data stays plain, so merge, override and the canonical form read what they read before.
  • Code that tested a spec field for dict now tests for Mapping: side_columns, the relation check in _validation, Spec._names_are_names, and OWNED_SECTIONS in _composition. Three refusals print a field's dims with list(...), so they quote [] as the file wrote it, not ().
  • Lowering reads a spec's dims as the tuples they already are (9a6fd86).
  • schema/mathspec.schema.json changes only in the Spec description.
  • The Expression docstring now says that lowering states each node's dims, and that a node built by hand holds the dims it was given. The constructors check nothing, as the module docstring already says.
  • docs/reference/reading.md says that neither a spec nor its program changes after it loads, and names override as the edit.

Why

spec.program is lowered once, when the spec loads. On main, spec.variables['x'].dims.append('nope') and spec.objective.expression = 'sum(c * y, over=g)' both succeed. Afterwards spec.program still holds the model as it loaded, but spec.to_yaml() writes the edited text, which does not load. specsolve writes spec.to_yaml() beside each answer it archives, so the archive could hold a model that no one solved.

Verification

On 5622b26; the next commit adds only the changelog line:

  • pixi run lint passes.
  • pixi run test: 3063 passed, 1 skipped.
  • pixi run docs-build passes.
  • pixi run compile-tex did not run: this container's proxy blocks the TeX bundle download (relay.fullyjustified.net). CI runs it. The golden typesetter output did not change.
  • A pickled spec compares equal to the original. Two loads of one file compare equal and hash alike.

On f6d2e27, the rename: pixi run lint passes, pixi run test gives 3063 passed and 1 skipped, pixi run docs-build passes, and every generator re-run gives no diff. No seal is left in src/, tests/ or docs/, apart from the history in CHANGELOG.md.

Mutation table. A script deleted each guard and ran the full suite. It restored the file with git checkout --, dropped __pycache__ before and after each run, and the tree came back clean every time. It ran on 5622b26, before the rename, which changes no logic.

Guard deleted Caught by
frozen=True test_a_loaded_spec_cannot_be_written_to[a-top-level-field], [a-field-of-an-entry], [a-field-two-levels-down]
Listed is a tuple [a-name-appended-to-a-list], test_the_loader_yields_plain_types and 12 tests that compare a frame
Frozen freezes what it validates [an-entry-added], [an-entry-removed]
cases: is frozen [a-case-removed]

[a-given-entry-added] writes into a given: section that defaults to Frozen({}). That default is never validated, so it is frozen whatever the hook does.

Breaking, for the release notes
  • A spec field cannot be assigned, 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',), not ['g']. to_dict() and to_yaml() still write lists.
  • A section's type is Frozen[str, X], a Mapping, not a dict. Code that tests isinstance(section, dict) must test Mapping.

Tests that compared a spec field with a list now compare it with a tuple: test_composition, test_given, test_piecewise, test_terms, test_validation, test_yaml_loading and typesetting/test_walk.

Not done

🤖 Generated with Claude Code

https://claude.ai/code/session_019j1TmBS3MvzmuarWjhCHHu

…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
@read-the-docs-community

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

Copy link
Copy Markdown

Documentation build overview

📚 mathspec | 🛠️ Build #35306345 | 📁 Comparing f6d2e27 against latest (0f892c0)

  🔍 Preview build  

4 files changed
± reference/changelog/index.html
± reference/program/index.html
± reference/reading/index.html
± reference/spec/index.html

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

Seems reasonable to do it, leaving override as the safe way to make changes.

Only change I would suggest is Sealed -> Frozen since frozen is used in this context already in Python, so it's more readily understood.

…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 removed the v0.4.0 label Oct 22, 2026
FBumann pushed a commit that referenced this pull request Oct 23, 2026
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
@FBumann
FBumann marked this pull request as draft October 23, 2026 13:26

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 76f3ff2, together with #918 and #927. The conflict in spec.py is resolved as "sort first, then freeze": each sorting validator returns a tuple or a Frozen mapping. #924's body has the mutation table for that. This PR stays open as a draft for its review history.


Generated by Claude Code

@FBumann FBumann removed the v0.5.0 label 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants