Skip to content

feat(api)!: a saved answer is stamped with layout 1 and the specsolve version that wrote it, and one written by 0.1.0 or earlier is refused by name - #1765

Merged
FBumann merged 5 commits into
mainfrom
claude/answer-layout-stamp
Sep 25, 2026
Merged

FBumann merged 5 commits into
mainfrom
claude/answer-layout-stamp

Conversation

@FBumann

@FBumann FBumann commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: we should add some sort of archive version or specsolve version to the archives to ensure backwards compat if we change sth later

Later: "use layout: 1".

Note

The following content was generated by AI.

Every saved answer, archives included, carried format.json as {"answer": 0}, whatever build wrote it. It now reads {"layout": 1, "specsolve": "<version>"}: a layout number that covers everything specsolve writes, and the version that wrote it. Breaking: 0.1.0 shipped without this, so an answer 0.1.0 wrote is refused.

What changes, why these choices, tests, gates, not done

What was there. relational/parquet.py stamped every answer directory with format.json. Every reader refused any other number with a LayoutError: load_result, scan_result, a sweep, a spill resume, and an archive's answer/. The number was held at 0 "while the layout is still moving", so every answer since the stamp was added, 0.1.0 and the alphas included, read as current. A layout change from here on could not have been told apart from any of them.

What changes

  • The key is layout and the constant is LAYOUT = 1. The number covers what a result, a sweep and an archive write. A release that changes any of them raises it, and its notes name the change. The constant's comment, Result.save's docstring (which LayoutError points to) and AGENTS.md (under "Breaking changes are free") say so.
  • format.json records specsolve: the version from the installed metadata, or null from a source tree nothing installed. It is written for the refusal message and for a human reading the file; no reader branches on it.
  • The refusal names what it found: … holds a saved answer in layout 0, written by specsolve 0.0.1a359, and this package reads layout 1. … A stamp with no layout (what 0.1.0 and every earlier build wrote, {"answer": 0}) and a missing format.json both read … with no layout stamp …. Either way the message says to solve again and save.
  • Answers written by 0.1.0 or earlier are refused by name, instead of being read as though their layout were current. Before 1.0 that is the stated rule, and the ! in the title marks the break for the release notes. An archive that 0.1.0 wrote still holds the spec and the data to solve again.

Why one stamp. An archive's answer/ is a saved answer and carries the same format.json, and reading an archive reads it through load_result. So one number covers results, sweeps and archives, and a second stamp at the archive root would be a second home for the same fact. The key is layout, not answer, because the number covers the archive's other members too.

Why the version comes from importlib.metadata: relational/ may import nothing from the package but errors (test_engine_is_isolated). This reads the same installed metadata as specsolve.__version__.

Tests

  • test_a_saved_answer_is_stamped_with_its_layout_and_the_specsolve_that_wrote_it (new): a fresh save holds {"layout": 1, "specsolve": sps.__version__}.
  • test_an_answer_in_another_layout_is_refused_by_name covers three cases: layout 0 from 0.0.1a359 (names the layout and the writer), 0.1.0's {"answer": 0}, and no format.json (both "with no layout stamp").
  • On the first commit, both tests failed against the previous parquet.py and passed on the new one. That was checked by swapping the file back and restoring it from git.

Mutation table, from tools/mutate.py on fa8fc6a; the tree was clean after the run:

mutation result
a stamp other than this layout is refused (parquet.py:73-82) caught

Gates on the merge of main after #1769, in a uv environment (dev + docs groups, gurobi and xpress extras), because pixi cannot be installed here:

  • ruff check . and ruff format --check . pass.
  • pyrefly check: 0 errors.
  • zensical build --strict builds, and the rendered API page carries the stamp paragraph under Result.save.
  • pytest tests/test_docs_math.py tests/test_docstring_links.py: 70 passed.
  • pytest -n auto: 4114 passed, 250 skipped, 1 xfailed.

Not done

  • No reader for another layout. Before 1.0 nothing reads another layout back, and an archive holds the spec and the data to solve again.
  • No mathspec version in the stamp. spec.yaml states the language version the file is written in.

🤖 Generated with Claude Code

https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw

…version that wrote it, and one written before 0.1.0 is refused by name

`format.json` was `{"answer": 0}` for every answer written so far, alpha
builds included, so a later layout change could not be told from any of
them. It now counts from 1, the layout 0.1.0 writes, and carries the
version that wrote it: `{"answer": 1, "specsolve": "0.1.0"}`. A layout
other than 1 is refused as before, and the message names the writer.

AGENTS.md says a change to what a result, a sweep or an archive writes
raises ANSWER_FORMAT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw
@codspeed

codspeed Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks
⏩ 82 skipped benchmarks1


Comparing claude/answer-layout-stamp (6529257) with main (8c783ca)2

Open in CodSpeed

Footnotes

  1. 82 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (c7ad4f2) during the generation of this report, so 8c783ca was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@read-the-docs-community

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

Copy link
Copy Markdown

… and says so when a file has none

`format.json` holds `{"layout": 1, "specsolve": "<version>"}`: the key
names what the number covers, a result, a sweep or an archive, rather
than the answer directory it happens to sit in. The constant is LAYOUT.
A stamp with no `layout`, which is every answer written before 0.1.0, is
refused as having no layout stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw
0.1.0 shipped before this, writing {"answer": 0}, so the layout 1 stamp is
what the next release writes and an answer 0.1.0 wrote is refused. The
comment, the reference row, the test messages and the changelog line say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw
@FBumann FBumann changed the title feat(api): a saved answer is stamped with layout 1 and the specsolve version that wrote it, and one written before 0.1.0 is refused by name feat(api)!: a saved answer is stamped with layout 1 and the specsolve version that wrote it, and one written by 0.1.0 or earlier is refused by name Sep 25, 2026
FBumann added a commit that referenced this pull request Sep 25, 2026
… of the name it belongs to (#1769)

> **Prompt:** Is there even more duplication in the docs now? Especially
in Reference? As a stacked PR

> [!NOTE]
> The following content was generated by AI.

The API page is now the rendered reference plus two rules that every
verb shares. Each task-section fact that no docstring held moved into
the docstring of its name. The `keep=` guidance moved to the debugging
how-to. Four drifts are fixed.

<details><summary>What moved where</summary>

| api.md section (deleted) | Its unique facts now live in |
|---|---|
| The spec argument, Checking a spec | `check`: the spec shapes, "a
`Spec` is not read again", "no verb takes a `Program`", the
`expand('piecewise')` / `expand()` choice, and the CI-verb sentence |
| Checking against a sink | `check`: "`solve` and `write` read the same
table", and a refusal names the construct and the sinks that take it.
The intro of "What each sink takes" |
| The sources argument | `build` `sources`, which links the data
contract |
| Building a model | `Model` (already covered) |
| Reading one row | `Model.row` (a label its dimension cannot hold, no
reader for a column). `ConstraintRow`, now rendered (linopy's format,
`display_terms` summary) |
| Reading a result | `dual` (an expanded set, duals only where a solver
ran, no reduced costs or slacks). `dual_ray` (`InfUnbdInfo` /
`presolve`, live only). `Result.evaluate` (an undeclared expression is
not a kind, the archive model check, a new parameter is a build).
`to_pandas` (pandas is not installed) |
| Writing a file | `write` `out` |
| Re-solving with new numbers | `Model.update` (a raise releases the
model, the reason behind `DataError`, `solve_over` is the loop written
for you) |
| How much of the session a solve keeps | The contract was already in
`Model.solve` `keep` and `Result.kept`. The when-to-use guidance, the
#815 numbers and #382 moved to howto/debug §6 |
| Archiving a model, Loading or scanning | howto/archiving (already
covered), `Model.solve` `archive` (sources through the build door,
uncompressed), `solve_over` `archive` (a sliced source archived whole, a
hand-built axis refused), `SolveArchive` (spec as written, digest
caveats), `scan_result` (re-read at every collect) |
| Diagnostics | the `Diagnostics` and `Metrics` attributes (already
covered) |
| Choosing a solver | `Model.solve` `solver_name` / `solver_options`
(xpress, a time limit in three vocabularies, Gurobi environment
options). `solve` now says "As `Model.solve` takes it" |

Other duplicates:
- sweeps.md: "The axes" table became links to `EachCoordinate` and
`EachWindow` plus the hand-built row. The `SliceMetrics` and "asked
before it is sliced" rows point at the entries. `EachWindow` now states
the window checks rather than naming a private method. The remote-Gurobi
sentence that #1771 added now links `Model.solve`.
- glossary: "Row types" became one-liners that link the entries.

**Drifts fixed:**
- `solver_name` omitted xpress.
- `sweep.record` columns were listed in three forms (6, 8 and 6). The
real record has 9 columns. `Sweep.record`, sweeps.md and howto/parallel
now say "one `Record` per slice", so the column list has one home.
- `check`'s Returns said every verb takes the `Program` back. No verb
does.
- howto/data said index *levels* attach by name. A `MultiIndex` is
refused.
- The sink table said HiGHS rewrites SOS to binaries. It refuses and
names `Spec.expand()`.
- Two docstrings linked `../../docs/reference/sweeps.md`, which is
broken when rendered. They now use the site URL. `ConstraintRow` linked
a private helper; that is now plain text.

**Gates** on the merge of #1766 (which now carries main through #1771),
in a `uv` environment with the dev and docs groups and the `gurobi` and
`xpress` extras:
- `ruff check .` and `ruff format --check .`: clean.
- `pyrefly check`: 0 errors.
- `zensical build --strict`: no issues. Every `api.md#…` anchor used
under `docs/` exists in the rendered page.
- Docs tests (`test_docs_math`, `test_docstring_links`): 70 passed.
- Full suite: 4115 passed, 250 skipped, 1 xfailed. It collects fewer
tests than #1766 (4153) because `test_doc_examples` runs one test per
fence, and the deleted sections held fences.

**Not done:** the task sections were not rewritten as new how-tos. Their
examples already exist in howto/archiving, howto/debug and
interactive.md. #1765 edits the api.md archive table that this PR
deletes, so whichever PR merges second resolves that conflict.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw

---------

Co-authored-by: Claude <noreply@anthropic.com>
The API page's archive table is gone, so the layout stamp is stated in
Result.save's docstring, and LayoutError points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw
@FBumann
FBumann merged commit 9870a3b into main Sep 25, 2026
14 of 15 checks passed
@FBumann
FBumann deleted the claude/answer-layout-stamp branch September 25, 2026 16:41
@FBumann FBumann mentioned this pull request Sep 25, 2026
FBumann added a commit that referenced this pull request Sep 25, 2026
> **Prompt:** Prepare the release. make it 0.1.1

Then: "It should be 0.2.0!! Its more truthfull".

> [!NOTE]
> The following content was generated by AI.

Renames `## Upcoming version` to `## 0.2.0 (2026-09-25)` and edits it
into release notes: a summary paragraph, the one break (#1765 refuses
answers and archives that 0.1.0 wrote), and the four PRs since 0.1.0.
Merging tags `v0.2.0` and publishes to PyPI.

<details><summary>Checks</summary>

**The version is a minor bump** because #1765 breaks reading archives
that 0.1.0 wrote, and AGENTS.md says a release that breaks a model file
raises the minor version.

**Checked:**
- `python -m tools.changelog check`: `releases 0.2.0 on merge`.
- `python -m tools.changelog pending`: `0.2.0`.
- `main` at 9870a3b carries all four PRs. Its CI state is whatever
GitHub shows; I did not re-run the suite for a changelog-only diff.

**Not done:** the branch keeps its name `claude/release-0.1.1`, which
nothing reads. The merge and the `pypi` environment approval are yours.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw

---------

Co-authored-by: Claude <noreply@anthropic.com>
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