Skip to content

refactor(api): keep= is gone, and a re-solve carries on from where the last solve ended unless start=None says otherwise - #1884

Merged
FBumann merged 14 commits into
claude/wizardly-lamport-e3g6omfrom
claude/wizardly-lamport-e3g6om-keep
Oct 8, 2026
Merged

FBumann merged 14 commits into
claude/wizardly-lamport-e3g6omfrom
claude/wizardly-lamport-e3g6om-keep

Conversation

@FBumann

@FBumann FBumann commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: Lets do the follow up removeing keep, and also bacnhmarking potential differences!

Prompt: Users should be able to opt out of start. We want the api to make that possible. But per default do the best thing.

Note

The following content was generated by AI.

keep= and Result.kept are removed. start='previous' is now the default of Model.solve and solve_over: a re-solve carries on in the loaded solver, as keep='progress' did, at the same speed. start=None begins from nothing. An answer given is matched by coordinate.

What a solve begins from
start= solver still loaded update loaded the solver again, or a sweep slice under an executor
'previous' (default) carries on; nothing read or matched from nothing; nothing read or matched
None from nothing (forget()) from nothing
an answer, a Start, an earlier sweep forget(), then matched by coordinate matched by coordinate
  • A word other than 'previous' is refused by Model.solve, and by solve_over before a slice is built.
  • A start from an answer with no values (infeasible, or no incumbent) raises NoSolutionError, whether or not it is the model's last answer.
Why carrying on is the default

It won in every LP case measured here, on HiGHS and Gurobi: 1.5× to 5.7× less wall time over six re-solves (tables below). Mixed-integer commitment s showed no difference.

It lost 18× in #815: 76.6 s against 4.3 s on HiGHS, on bench/expansion/sub.yaml (10k generators × 24 snapshots). Presolve solves that model in 0 iterations, and a solver that carries on skips its presolve. On Gurobi the same model took 231 iterations against 120. The how-to names this case and the opt-out.

Gurobi: old keep='progress' against carrying on, and the cost of a matched basis

The user's Mac with a full Gurobi licence. keep_vs_start.py (cases from bench/cases.py, 6 updates scaling load by 1 + 0.02·sin(k), median of 3 rounds with the arm order rotated). Old is 9b046ce. New is this branch at 2dede07/817b012, start= the previous answer, which takes the same carry-on path as today's default. Load average not recorded. Wall seconds over the 6 re-solves.

case cold (old / new) old keep='progress' new carry-on old start= with basis read and matched warm iterations
dispatch s 0.599 / 0.609 0.390 0.389 0.617 3700
storage s 1.611 / 1.663 0.857 0.875 1.143 11159
dispatch m 9.469 / 9.775 4.323 4.435 6.579 37123
storage m 31.779 / 31.604 20.811 21.056 23.488 202182

Cold iterations: 6000, 299811, 60000, 1288980. commitment s (new tree only): 1.261 cold against 1.252, 256 against 254 iterations, 6 nodes each.

At dispatch m, a matched basis costs about 0.38 s per solve: about 0.11 s inside the solve phase (reset, set and read VBasis/CBasis), the rest outside it. One profile of the matching on HiGHS, on a cloud container: 0.17–0.21 s for matched_basis per solve at dispatch m.

HiGHS: old keep='progress' against new start= previous

Same updates. One process per run, 3 rounds, order alternating. Load average below 0.7. Old is 9b046ce with keep='progress'; new is 9c816af with start= the previous answer.

case old keep='progress' (s) new (s) simplex iterations, both
dispatch s (100 × 1000) median 0.711 (0.696, 0.711, 0.865) median 0.712 (0.662, 0.712, 0.759) 3700
storage s median 2.581 (2.688, 2.581, 2.229) median 2.254 (2.502, 2.254, 2.165) 11514
dispatch m (100 × 10000) median 12.068 (12.314, 12.068, 11.876) median 12.257 (12.449, 12.257, 12.073) 37123

Cold against carry-on at 558a2c3, wall seconds / iterations: dispatch s 2.833 / 6000 against 0.723 / 3700; storage s 13.279 / 217137 against 2.342 / 11514; dispatch m 34.925 / 60000 against 12.339 / 37123.

Neither table was re-run after the default changed (dc4889b). The carry-on path it takes is the same one: no forget(), no read, no match.

What changed
  • keep= is removed from solve, Model.solve and solve_over, and Result.kept with it. Diagnostics.loads still counts the solves that loaded a solver.
  • Model.solve(start='previous') is the default. solve_over(start='previous') is the default too, and is no longer refused under an executor; there every slice begins from nothing.
  • A sweep no longer reads each slice's basis, and a slice read back from a spill no longer starts the next one. The solve mark on Result is gone: an answer given is always matched.
  • bench/warm_payoff.py passes start=None for its cold arm. The bench window arm still runs against a checkout that takes keep.
  • Docs: the warm-start how-to is rewritten around the default, with feat(api): a solve says how much of the session it keeps #815's loss and this PR's numbers. reference/sweeps.md, the glossary's start · previous entry, debug.md, the README, the sinks README and the docstrings follow.
Mutation table

python -m tools.mutate for the deletions, scoped to tests/test_start.py, tests/test_warm_start.py and tests/test_strategy.py. The hand mutation was made with sed, __pycache__ was dropped on both sides, and it was restored with git checkout --. The tree was clean after every run.

guard result
forget() unless the solve carries on (engine.py:202-203) caught
the start word refused by Model.solve (api.py:367) caught
the start word refused before a slice is built (strategy.py:356) caught, by a probe added for it; green before the probe
by hand: carry_on = False caught (5 failed)
Verification
  • On 23f5630, after merging the updated base: pixi run check gave 5323 passed, 566 skipped, 1 xfailed, with ruff, format and pyrefly clean. docs-test gave 112 passed.
  • On dc4889b: docs-build reported no issues. test-bench gave 263 passed, run with --i-know-another-is-running because the suite had just loaded the box.
  • The reported bug that an infeasible last answer carried on silently was reproduced as a strict xfail first.
Deliberately not done, and coverage that moved
  • No replacement for keep='nothing'. On Xpress, forget() only sets keepbasis=0. For a fully fresh solver, close the model and build again, as the how-to says.
  • After an update that loads the solver again, the default starts cold, not from the previous answer matched by coordinate. That match needs a basis read on every solve. Pass the answer as start= to get it.
  • Gurobi's matched-basis path is not made faster. Only HiGHS's conversion was (perf(highs): handing a basis to HiGHS takes milliseconds rather than a second at a million columns #1885).
  • Tests moved: the keep tests became tests of the default and of start=None. test_update's walk and the resumed-carry test pass start=None, since carrying on can end on another vertex of a degenerate optimum. The spill-read-back start test is deleted with the feature. The test for refusing 'previous' under an executor became one where every slice begins from nothing.

🤖 Generated with Claude Code

https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op

claude added 2 commits October 7, 2026 16:31
…tart= says so, and keep= is gone

Model.solve and solve_over no longer take keep=, and Result.kept is gone. A
model keeps its solver loaded between solves where the structure allows, as
before, and each solve begins from nothing on it, as keep='solver' did.
start= the answer the model's last solve produced carries on in the solver
that still holds it, with nothing read or matched, which is what
keep='progress' did; where an update loaded the solver again, that answer is
matched by coordinate like any other. Diagnostics.loads counts the solves
that loaded the solver, which is what kept='nothing' reported.

keep='nothing' has no replacement in the API: close the model and build it
again for a solver no state survives in. The bench's cold window drops the
held solver itself, and its arms call the engine as the checkout under test
spells it, with or without keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…last answer carries on

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

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

Copy link
Copy Markdown

@codspeed

codspeed Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 307 skipped benchmarks1


Comparing claude/wizardly-lamport-e3g6om-keep (c0f7071) with claude/wizardly-lamport-e3g6om (ef01170)

Open in CodSpeed

Footnotes

  1. 307 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. ↩

FBumann added a commit that referenced this pull request Oct 8, 2026
…a second at a million columns (#1885)

> **Prompt:** yes, stack both PRs on 1882

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

The HiGHS sink converted each basis status with a Python call. Now numpy
picks each status. On dispatch `m`, 6 re-solves that start from a basis
and read one back take 21.2 s instead of 30.0 s, with the same
iterations.

<details><summary>Benchmark</summary>

HiGHS, base 9b046ce (the head of #1882) against 6ecab89. Each process
builds the case and solves it with `outputs={'basis'}`. It then
re-solves it after 6 updates that scale `load` by `1 + 0.02·sin(k)`,
each with `start=` the previous answer and `outputs={'basis'}`. Each
re-solve so reads, matches and writes a basis. The time is the wall time
of the 6 `solve()` calls. One process per run, 3 rounds, the order
alternating each round. Load average 0.4 to 1.1. Both sides took the
same iterations and reached the same objectives in every round.

| case | base (s) | this PR (s) | change | simplex iterations, both |
|---|---|---|---|---|
| dispatch `s` (100 × 1000) | 2.668, 2.287, 2.222 (median 2.287) |
1.474, 1.786, 1.694 (median 1.694) | −26% | 3700 |
| storage `s` | 5.013, 5.375, 4.843 (median 5.013) | 4.092, 4.270, 3.967
(median 4.092) | −18% | 10639 |
| dispatch `m` (100 × 10000) | 29.011, 30.076, 29.997 (median 29.997) |
21.252, 20.712, 21.241 (median 21.241) | −29% | 37123 |

A first run started at load average 8.5, just after the test suite, and
is not counted. Its medians were within 0.2 s of these.

Microbenchmark on highspy 1.15.1, 1M statuses, one run:

| step | before | after |
|---|---|---|
| statuses into `HighsBasis.col_status` (`_warm`) | 1.04 s (a
`HighsBasisStatus(int)` call each) | 0.064 s (numpy index into the five
members) |
| integers out of the returned list (`_basis`) | 0.44 s (`int()` each) |
0.19 s (`np.fromiter` over `.value`) |
| the highspy getter that makes the list | 0.9 to 2.0 s | unchanged:
highspy has no numpy path |

On 200k codes, building the `BASIS` series by `gather` instead of from
strings took 0.003 s against 0.12 s.

A profile of dispatch `s` at the base, per re-solve: write 0.158 s, read
0.093 s, match 0.034 s, status table 0.027 s. With this change, the
write falls to 0.009 s. The read stays at 0.092 s, which is mostly the
highspy getter.

</details>

<details><summary>Verification</summary>

- Full suite (`pytest -n 4`, with HiGHS, Gurobi and Xpress): 5301
passed, 566 skipped, 1 xfailed.
- `ruff check .`, `ruff format --check .` and `pyrefly check`: 0 errors.
- Not run: `test-bench` and `docs-test`. This PR changes no file under
`bench/` or `docs/`, and only private docstrings.

</details>

<details><summary>Deliberately not done</summary>

- **No change to Gurobi or Xpress.** Their sinks already pass numpy
arrays.
- **No way round the highspy getter.** `writeBasis` to a file took 0.165
s for 200k columns before any parsing, so it does not pay. A numpy
accessor in highspy would remove the rest.
- **No mutation table.** The change adds no guard. The existing basis
tests on HiGHS (`test_basis`, `test_start`, `test_warm_start`) cover the
round trip.
- This PR is stacked on #1882 beside #1884, not on it. The code changes
do not overlap. Both add their `CHANGELOG.md` line after #1882's, so the
second to merge has a one-line conflict there.

</details>

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

https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz

---------

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann
FBumann added this pull request to stack #1886 October 8, 2026 08:07
claude added 11 commits October 8, 2026 08:11
…into claude/wizardly-lamport-e3g6om-keep

# Conflicts:
#	CHANGELOG.md
…ff starts an LP from its values

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
The glossary still defined keep, and the sweep page still listed it.
start= names what a solve begins from, and start='previous' on a sweep
names the slice before; the entry says "never prior", as the glossary
says "never study" for a sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…6om-keep

Brings main through the base: polars 2.0, the 0.7.0 release and
load_inputs. The sweep's collects go through collected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…e that has not solved

The engine's mark starts as an object rather than None, so the separate
None guard on a start's mark goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op
… refused, as an older one is

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op
…6om-keep

The stack below now holds main too; the tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…-lamport-e3g6om-keep

Joins the two commits pushed meanwhile with the merge of the stack
below, which now holds main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…unless start=None says otherwise

start='previous' is the default of Model.solve and solve_over. It carries
on in the loaded solver, and begins from nothing where an update loaded the
solver again or a sweep slice runs under an executor. start=None begins
from nothing. An answer given is always matched by coordinate, so the
solve mark on Result goes, and a sweep no longer reads a basis per slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op
@FBumann FBumann changed the title refactor(api): keep= is gone, and a solve started from the model's last answer carries on in the solver that holds it refactor(api): keep= is gone, and a re-solve carries on from where the last solve ended unless start=None says otherwise Oct 8, 2026
…into review/1884-tidy

# Conflicts:
#	docs/reference/sweeps.md
FBumann added a commit that referenced this pull request Oct 8, 2026
…rder (#1893)

> **Prompt:** fix the flaky test too
> In a separate PR on main

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

`test_a_zero_edge_writes_its_rows_like_any_other_fill` collected the
bare shift's frame unsorted, so its row order varied and it failed 8 of
40 runs on `main`. It now sorts by `snapshot`, as the same test's first
frame does: 0 of 40.

<details><summary>Method and gates</summary>

- Reproduced on 1487031 (`origin/main`): `pixi run pytest -q -p
no:randomly tests/test_compiler.py -k zero_edge_writes`, 40 runs, 8
failed.
- After the change: the same 40 runs, 0 failed.
- `pixi run check`: 5030 passed, 564 skipped, 1 xfailed; `ruff`, format
and `pyrefly` clean.
- `test` type, so no changelog line.
- Not done: no search for other unsorted `collect()` calls in the tests.
This one showed up as a red run on #1884.

</details>

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

https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann
FBumann merged commit dc2935e into main Oct 8, 2026
13 checks passed
@FBumann
FBumann deleted the claude/wizardly-lamport-e3g6om-keep branch October 8, 2026 14:16
FBumann added a commit that referenced this pull request Oct 8, 2026
…is statuses, and a sweep from an earlier sweep or from the slice before it (#1882)

> **Prompt:** Review the R stack with 1878. Look for room for
simplification, refactoring, better naming, better seams and if better
encapsulation.

> **Prompt:** Id like to unify the start for lp and mip. But we need to
find a simple and intuitive api. Users need to be able to pass basis
values easily too!

> **Prompt:** I think start on a sweep is prefectly reasonable! And it
makes no sense for it to not exist if its availlable on regular solve!

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

`start=` is one shape for an LP and a mixed-integer model: an earlier
answer, or tables keyed by reader (`primal`, `variable_basis`,
`constraint_basis`). `solve_over` also takes an earlier `Sweep` or
`'previous'`, cut by the axis as a source is. `outputs={'basis'}` asks
for the basis.

<details><summary>The API</summary>

```python
start: Result | Start | None                              # solve, Model.solve
start: Result | Sweep | Start | Literal['previous'] | None   # solve_over
```

- **`Start` is a `TypedDict`** in `specsolve.types`: `{'primal': {name:
table}, 'variable_basis': {...}, 'constraint_basis': {...}}`. Each table
takes every shape a parameter's source takes (#1878), read by the same
reader. Basis tables are read as strings and held to `basic`,
`at_lower`, `at_upper`, `fixed` and `superbasic`.
- **An LP starts from a basis where one is given, else from values. A
mixed-integer model starts from values.** An LP answer solved without
`outputs={'basis'}` now starts a solve from its primal rather than being
refused.
- **Values for an LP follow each solver's `lp_values`.** HiGHS uses a
complete primal and warns of a partial one. Gurobi warns of either.
Xpress warns of a complete one and refuses a partial one, which
`loadLPSol` cannot take. A warning asks the user to report a gain at the
issue tracker.
- **A sweep's start is cut by its axis, as a source is.** A table over
the sliced dimension gives each slice its rows: a scenario its
scenario's, a window the coordinates it covers, lookahead included, over
its local index. A table without it reaches every slice whole. An
earlier sweep is its answer, so an archive without kept windows starts a
sweep too. Each `Slice` carries the cut it applied to its sources, and
the start goes through the same function.
- **A slice its start leaves no row starts from nothing, with a
warning.** That covers a table short of the slice's key, and an earlier
sweep whose slice left no values. A missing start costs time, never the
answer, so it no longer refuses the sweep. This changed after review:
"For empty tables we should warn, not error".
- **`'previous'`** starts each slice from the answer of the one before
it, matched by the slice model's own coordinates (local `t` for a
window). Each slice is solved with its basis for the next to start from,
whether or not `outputs=` asks for it; the answer, the spill and the
archive keep only what `outputs=` asks for. The first slice, and a slice
after one that left no values, starts cold. Refused under an executor. A
slice read back off a spill starts the next from what the spill holds.
(#1884 changes this.)
- **`start=` combines with `keep='solver'` only.** `keep='progress'` and
`keep='nothing'` also say what the solve begins from, so the two
together are refused, on `solve` and `solve_over`.
- **`outputs={'basis'}`** replaces `{'variable_basis',
'constraint_basis'}`. The readers keep their names, and the files stay
under `variable_basis/` and `constraint_basis/`.

</details>

<details><summary>From the review of #1865–#1878</summary>

- The basis vocabulary (`BASIS_STATUSES`, the `BASIS` dtype, the codes)
moved from the sinks to `answer_layout`, beside the outputs read back in
it.
- The engine matches a start to a `Basis` or to values and branches on
it, rather than returning a closure. It reads an earlier answer through
`Result._start` rather than its private frames.
- `Result` and `Sweep` dispatch an output kind explicitly rather than by
`getattr`, and the output readers share one path.
- Each sink's write-back table names the status it maps rather than
listing bare codes.
- The spill manifest check compares the key first, then the outputs.
- `Result.dual` no longer says slacks are not read.

</details>

<details><summary>Values for an LP: the probe behind
<code>lp_values</code></summary>

A random 300×400 LP (5% density, seed 0), solved once cold and once
started from its own optimum, outside specsolve. This is the best case
for a values start, one run each, and it was not taken on a machine
checked for idleness.

| | cold | from the full primal | from half the columns |
|---|---|---|---|
| HiGHS `setSolution` (simplex iterations) | 661 | 0 | 661 |
| Gurobi `PStart` | 877 | 877 | — |
| Xpress `loadLPSol` | 760 | 760 | refused: needs every column |

Separately, on HiGHS, `DISPATCH` at 40 snapshots started from a
`variable_basis` alone (rows left at their default) took 40 iterations
against 29 cold. The test asserts only that such a start is taken.

</details>

<details><summary>Mutation tables</summary>

`python -m tools.mutate`, scoped to `tests/test_start.py` (and
`test_basis`, `test_warm_start`, `test_outputs` for the first group),
rather than the full suite. A guard the subset catches, the full suite
catches too.

| guard | result |
|---|---|
| a key that names no reader (`sources.py`) | **caught** |
| a name its reader has no declaration for (`sources.py`) | **caught** |
| a basis status outside the five words (`sources.py`) | **caught** |
| `lp_values` refused (`engine.py`) | **caught** |
| `lp_values` no_gain warns (`engine.py`, body → `pass`) | **caught** |
| nothing the model starts from (`engine.py`) | **caught** |
| a start that lands nowhere (`readback.py`) | **caught** |
| start beside keep progress or nothing (`result.py`) | **caught** |
| a word other than `previous` (`strategy.py`) | **caught** |
| `previous` under an executor (`strategy.py`) | **caught** |
| a table over the local index alone (`strategy.py`) | **caught** |
| the warning for a slice its start leaves no row
(`strategy.py:407-413`) | **caught** |
| by hand: that slice's empty start passed on rather than none |
**caught** (3 failed) |
| a slice after one with no values starts cold (`strategy.py`) |
**caught** |
| a word on one solve (`api.py`, taken before the merge in `engine.py`)
| **caught** |
| a constraint named as a variable keeps its own basis (by hand: both
halves in one directory) | **caught** |
| a slice keeps only the outputs asked for (by hand: every output the
result holds copied) | **caught** |

Three guards first survived, and each got a purpose-built probe before
the table was taken: the up-front check on a sweep's start and the
up-front windows check, which now spy on the build, and the outputs
filter, whose test now looks for a basis in the spill. The tree was
clean after every run.

</details>

<details><summary>Verification</summary>

- `ruff check .`, `ruff format --check .`, `pyrefly check`, and the full
suite (`pytest -n 4`, with HiGHS, Gurobi and Xpress): 5301 passed, 0
failed, on the tree of the last source change. The collected tests
(5867) are the same as at the commit before it. An earlier run on the
merge reported 5311 passed; I have not found why the two counts differ.
- On ef01170 (the empty-start warning): `pixi run check` gave 5329
passed, 566 skipped, 1 xfailed. `docs-test` gave 112 passed. The new
test was a strict `xfail` first, and failed on the refusal.
- `pixi run test-bench`: 263 passed. An earlier version of this line
said it passed, which was wrong: it had last run before the
`keep`/`start=` rule, which refused `bench/warm_payoff.py`'s warm arm
(`keep='nothing'` beside `start=`). CI caught it. The warm arm now asks
for `keep='solver'` and runs first after each update, where the gained
cut loads a fresh solver anyway, and an assertion on `Result.kept` holds
both arms to a fresh solver.
- `pixi run -e docs docs-test` and `docs-build --strict` pass.
- #1878's branch moved while this was open (any-shape values). It is
merged in here, not rebased, and its tests now pass their tables under
`'primal'`.

</details>

<details><summary>Deliberately not done, and departures</summary>

- **One PR rather than a stack.** The review refactors, the basis
output, the unified start and the sweep start all reshape #1876–#1878
before release, and each builds on the one before.
- **`keep` is unchanged** apart from the refusal beside `start=`.
Whether `start=` replaces `keep='progress'`, and which alignment
`'previous'` uses for windows, needs a measurement first: a follow-up.
- **No dual values in a start.** Gurobi's `PStart` showed no gain, and
duals would need the sign conversion reversed per solver. Measure first.
- **The other PRs' changelog lines are unchanged.** #1876's line still
reads `outputs={'variable_basis', 'constraint_basis'}`, and its title
would need the same edit.

</details>

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

https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Oct 8, 2026
start='previous' is now the default and carries the solver on, so a
chain no longer restarts it: each slice passes its own start to
Model.solve, and the basis read per slice goes. Under an executor a
chain is one task only where an axis carries; 'previous' alone no
longer groups slices, since it is the default and would leave a sweep
of one axis a single task. 'previous' with an executor is no longer
refused, and a start that leaves a slice no row warns rather than
refusing the sweep.

Tests: the chain-cold test goes with the behaviour, and the executor
test counts one build per chain. A main test moves carry= onto its axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
FBumann pushed a commit that referenced this pull request Oct 8, 2026
Main holds the stack below as squash commits: its tree is #1884's last
head, merged in the commit before this one, plus #1893's test fix,
taken here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
FBumann added a commit that referenced this pull request Oct 9, 2026
> **Prompt:** Prepare the latest release of specsolve.
>
> **Prompt:** Im wondering if we bumped the layout stuff multiple times
in this release. We should only increment once since the last release!

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

Releases 0.8.0 on merge. The answer layout rose twice since 0.7.0, and
the new inputs layout once after it was added. Each now rises once:
`ANSWER_LAYOUT` 3 → 4, `INPUTS_LAYOUT` 1.

<details><summary>The layout numbers</summary>

`v0.7.0..origin/main` on `archive_layout.py` and
`relational/answer_layout.py`:

| PR | change |
|---|---|
| #1883 | `LAYOUT` renamed `ANSWER_LAYOUT` (3); `INPUTS_LAYOUT = 1`
added |
| #1865 | `ANSWER_LAYOUT` 3 → 4 |
| #1904 | `ANSWER_LAYOUT` 4 → 5 |
| #1913 | `INPUTS_LAYOUT` 1 → 2 |

No release wrote the intermediate numbers, so this PR sets
`ANSWER_LAYOUT = 4` and `INPUTS_LAYOUT = 1`. The `format.json` example
in `Result.save`'s docstring follows. A 0.7.0 answer stamps 3 and a
0.7.0 archive has no root stamp, so both stay refused.

The cost is on unreleased files only. An answer written from `main`
between #1865 and #1904 stamps 4 and is no longer refused, though it
still carries `model_digest`. An archive written from `main` between
#1883 and #1913 stamps inputs 1 and is no longer refused, though it may
hold `sources.parquet`.

No test asserts a layout number; the refusal tests use 0, 99 and no
stamp.

</details>

<details><summary>Release notes and gates</summary>

- `## Upcoming version` → `## 0.8.0 (2026-10-09)`, with a paragraph, a
**Breaks** list and the lines grouped under Solving, Reading an answer,
Archives, Data and Examples. An empty `## Upcoming version` stays above
it. All 21 lines are kept; #1876's line says `outputs={'basis'}`, the
spelling #1882 gave it.
- **Minor, not patch**: #1884 removes `keep=` and `Result.kept`, #1913
removes the digests, and the layout refuses 0.7.0 files.
- `python -m tools.changelog check` → `releases 0.8.0 on merge`.
- `pixi run check`: 5523 passed, 566 skipped, 4 xfailed.
- Not run: `pixi run docs-test`.
- Departure: the layout fix rides in this PR rather than its own,
because the release must not ship the doubled numbers. It is its own
commit.

</details>

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

https://claude.ai/code/session_01SUzrD57WeiMZtKUnw7wsG8

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Oct 11, 2026
FBumann added a commit that referenced this pull request Oct 12, 2026
…y scenarios (#1985)

> **Prompt:** The decomposition is always motivated by solve time! So we
should make the tutorial about solving models faster in various ways?
And explain the downside or limitation of each approach...?

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

There are two new how-tos. "Making a solve faster" lists eight ways,
each with whether it keeps the optimum and what it costs. "Solving many
scenarios" takes in the scenario steps that the last tutorial dropped.

<details><summary>What each page holds, what moved, and what
ran</summary>

**Making a solve faster** (`docs/howto/faster.md`). One table, and then
one short section for each way. The ways that keep the answer come
first:

| Way | Keeps the optimum | What it costs |
|---|---|---|
| Keep the model loaded | yes | only data that moves no mask or label |
| Start from an earlier answer | yes | can make a solve slower |
| Build many small models faster | yes | only below 250,000 columns and
rows |
| Accept a gap (`mip_rel_gap`) | within the gap | the answer is close,
not optimal |
| Relax the integers | no, a bound | the schedule is fractional |
| Solve in windows | no | the windows cannot see past their end; they
run one after the other |
| Decompose | within the gap | you write the loop; links the Benders
example |
| Make the model smaller | no | it answers a different question |

Each section has a code block and links to the page that has the detail.
The measurements for starting from an earlier answer (#1884, #815) stay
on that page, and the new page links there instead of copying them.

**Solving many scenarios** (`docs/howto/scenarios.md`). It puts the
scenarios in one table with a `scenario` column and solves once for each
label with `EachCoordinate`. It reads the answers with that column,
varies a second table with the same column, and links the parallel
how-to. The page uses `dispatch.yaml` and the tutorial data: mild 10500,
cold 16530, and cold with gas at 80 costs 24540.

**Moved.** `warm-start.md` is now "Starting from an earlier answer",
which is what it covers. Its section "Build many small models faster"
moves to the new page. The nav lists the three pages together. "Change
it and solve again" and the last tutorial link the new titles.

**Checked by hand, not by the suite.** The HiGHS option name
`mip_rel_gap` (the solver accepts it, and the commitment model solves to
14000), and the relaxed commitment bound (10875 below 14000). The code
blocks on the faster page do not run in the build, as on the warm-start
page.

**Tests.** `tests/test_tutorials.py` runs the scenarios page. It checks
the two objectives, that gas covers the extra cold load, and that the
second table changes only the cold scenario.

**Gates.**

- `zensical build --strict`: no issues.
- `pytest tests/test_tutorials.py tests/test_docs_site.py
tests/test_tutorial_models.py`: 35 passed.
- `ruff check` and `ruff format --check`: clean.
- The full suite was not run.

**Stack:** … ← #1984 ← this PR.

</details>

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

https://claude.ai/code/session_01Wrzfzr2yLiZFMv19fzokoy

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Wrzfzr2yLiZFMv19fzokoy)_

---------

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