Repository navigation
feat(api): a solve or a sweep can start from tables of values and basis statuses, and a sweep from an earlier sweep or from the slice before it - #1882
Merged
FBumann merged 13 commits intoOct 8, 2026
Conversation
…ed under basis/variable/ and basis/constraint/ The two halves of a basis were two outputs, and only both together started a solve. One output now carries both kinds; the readers keep their names. They nest under basis/ because a constraint may share a variable's name. From the review of the stack ending in #1878: - the basis vocabulary (BASIS_STATUSES, the BASIS dtype, the codes) moves to answer_layout, beside the outputs that are read back in it - the engine matches a start to a Basis or to values and branches on it, rather than returning a closure, and reads an earlier answer through Result._basis and Result._values - Result and Sweep dispatch an output kind explicitly rather than by getattr, and the five output readers share one path - each sink's write-back table names the status it maps - the spill manifest check compares the key first, then the outputs - Result.dual no longer says slacks are not read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
… values and basis statuses, each matched by coordinate
start= takes an earlier Result or a Start: under the name of the reader that
returns it ('primal', 'variable_basis', 'constraint_basis'), a table per
declaration in that reader's shape. An LP starts from a basis where one is
given, else from values; a mixed-integer model starts from values. So an LP
answer solved without outputs={'basis'} still starts a solve from its primal.
Values for an LP follow each sink'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. Refused before the
solver loads: a key that names no reader, a table naming no declaration or
lacking its dims, a basis status outside the five words, a basis alone for a
mixed-integer model, and a start that lands on no coordinate.
The basis is saved flat again, under variable_basis/ and constraint_basis/, so
the kind is one name for reader, kind=, start key and directory.
warm-start.md prose: n 54, avg 16.4, median 17, over 25: 5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ery slice from one answer solve_over takes start= as solve does, and an earlier Sweep: each slice starts from the earlier slice of the same key, an EachWindow window from the same window, under an executor too. An answer or a Start starts every slice the same; EachWindow refuses that, its windows sharing their local index. start= now combines with keep='solver' only, on solve and on solve_over: keep='progress' and keep='nothing' also say what the solve begins from, so the two together are refused until the follow-up settles keep. Refused before a slice is taken: an earlier sweep keyed by another column, lacking a key, holding a slice of one with no values, or archived without its windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ns each slice from the one before it solve_over takes start: Result | Sweep | Start | Literal['previous'] | None. A start table that carries the sliced dimension gives each slice its own rows, as a source does: a scenario its scenario's, a window the coordinates it covers, lookahead included, over its local index; a hand-built axis cuts on its key column. A table without it reaches every slice whole. An earlier sweep is its answer, so each slice starts from the earlier slice of its key and each window from the hours it covers; an archive without kept windows starts a sweep too. Every Slice now carries the cut it applied to its sources, and the start goes through the same one. 'previous' starts each slice from the answer of the one before it, matched by the slice model's coordinates; the first slice, and one after a slice that left no values, starts cold. Refused under an executor. A slice read back off a spill starts the next from its frames on disk. Refused before a slice is built: a word other than 'previous', 'previous' under an executor, a table over an EachWindow sweep's local index alone, a start that leaves a slice no row, and a table solve refuses. The per-key rules of the previous commit are gone; an earlier sweep keyed by another column is renamed onto this one's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
… source takes #1878 gained values in any shape a parameter's source takes, read by the parameter reader. Here that reader serves every table of a Start, under all three readers: read_start in sources.py reads primal tables as numbers and basis tables as strings held to the five words, against the build that solves. The engine takes the start already read. A sweep checks names before a slice is built, cuts any table-shaped start by its axis, and passes a shape that is not a table, such as one number, to every slice whole. start='previous' hands each slice the Result before it, so its frames are matched by coordinate rather than read as a caller's table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Merging this PR will not alter performance
Comparing Footnotes
|
…e's basis to the next, asked for or not
Under start='previous' each slice is solved with outputs | {'basis'}, so an
LP slice starts from the basis the slice before it ended on rather than from
its values. The slice's answer, spill and archive keep only the outputs the
sweep asked for. A slice read back off a spill still starts the next from what
the spill holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…t was not asked for Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…olver', which start= now requires start= combines with keep='solver' only, so the warm arm can no longer ask for keep='nothing'. It now runs first after each update; the cut that update gained changes the structure, so the solver is loaded fresh anyway, and an assertion on Result.kept holds every arm to a fresh solver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
FBumann
added this pull request to stack #1874
October 7, 2026 15:04
FBumann
removed this pull request from stack #1874
October 8, 2026 08:06
…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
added this pull request to stack #1886
October 8, 2026 08:07
Main brings polars 2.0, the 0.7.0 release, load_inputs with its own INPUTS_LAYOUT, and the dimension called value. An answer's stamp keeps the outputs it carries, and ANSWER_LAYOUT is 4 above main's 3. The collects this branch added go through collected, and the scalar reader test asks for the activity it reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om The base now holds main too; every conflict keeps this branch's side, and the tree is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…hing, rather than refusing the sweep A table short of a slice's key, or an earlier sweep whose slice left no values, used to refuse the whole sweep before a slice was built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pGzyDMmp1WD4B65wFR8op
This was referenced Oct 8, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_overalso takes an earlierSweepor'previous', cut by the axis as a source is.outputs={'basis'}asks for the basis.The API
Startis aTypedDictinspecsolve.types:{'primal': {name: table}, 'variable_basis': {...}, 'constraint_basis': {...}}. Each table takes every shape a parameter's source takes (feat(api): a mixed-integer solve can start from values, an earlier answer's or any source a parameter takes, matched by coordinate #1878), read by the same reader. Basis tables are read as strings and held tobasic,at_lower,at_upper,fixedandsuperbasic.outputs={'basis'}now starts a solve from its primal rather than being refused.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, whichloadLPSolcannot take. A warning asks the user to report a gain at the issue tracker.Slicecarries the cut it applied to its sources, and the start goes through the same function.'previous'starts each slice from the answer of the one before it, matched by the slice model's own coordinates (localtfor a window). Each slice is solved with its basis for the next to start from, whether or notoutputs=asks for it; the answer, the spill and the archive keep only whatoutputs=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. (refactor(api): keep= is gone, and a re-solve carries on from where the last solve ended unless start=None says otherwise #1884 changes this.)start=combines withkeep='solver'only.keep='progress'andkeep='nothing'also say what the solve begins from, so the two together are refused, onsolveandsolve_over.outputs={'basis'}replaces{'variable_basis', 'constraint_basis'}. The readers keep their names, and the files stay undervariable_basis/andconstraint_basis/.From the review of #1865–#1878
BASIS_STATUSES, theBASISdtype, the codes) moved from the sinks toanswer_layout, beside the outputs read back in it.Basisor to values and branches on it, rather than returning a closure. It reads an earlier answer throughResult._startrather than its private frames.ResultandSweepdispatch an output kind explicitly rather than bygetattr, and the output readers share one path.Result.dualno longer says slacks are not read.Values for an LP: the probe behind
lp_valuesA 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.
setSolution(simplex iterations)PStartloadLPSolSeparately, on HiGHS,
DISPATCHat 40 snapshots started from avariable_basisalone (rows left at their default) took 40 iterations against 29 cold. The test asserts only that such a start is taken.Mutation tables
python -m tools.mutate, scoped totests/test_start.py(andtest_basis,test_warm_start,test_outputsfor the first group), rather than the full suite. A guard the subset catches, the full suite catches too.sources.py)sources.py)sources.py)lp_valuesrefused (engine.py)lp_valuesno_gain warns (engine.py, body →pass)engine.py)readback.py)result.py)previous(strategy.py)previousunder an executor (strategy.py)strategy.py)strategy.py:407-413)strategy.py)api.py, taken before the merge inengine.py)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.
Verification
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.pixi run checkgave 5329 passed, 566 skipped, 1 xfailed.docs-testgave 112 passed. The new test was a strictxfailfirst, 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 thekeep/start=rule, which refusedbench/warm_payoff.py's warm arm (keep='nothing'besidestart=). CI caught it. The warm arm now asks forkeep='solver'and runs first after each update, where the gained cut loads a fresh solver anyway, and an assertion onResult.keptholds both arms to a fresh solver.pixi run -e docs docs-testanddocs-build --strictpass.'primal'.Deliberately not done, and departures
keepis unchanged apart from the refusal besidestart=. Whetherstart=replaceskeep='progress', and which alignment'previous'uses for windows, needs a measurement first: a follow-up.PStartshowed no gain, and duals would need the sign conversion reversed per solver. Measure first.outputs={'variable_basis', 'constraint_basis'}, and its title would need the same edit.🤖 Generated with Claude Code
https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz