Skip to content

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 into
claude/festive-carson-8x46sq-mipstartfrom
claude/wizardly-lamport-e3g6om
Oct 8, 2026
Merged

FBumann merged 13 commits into
claude/festive-carson-8x46sq-mipstartfrom
claude/wizardly-lamport-e3g6om

Conversation

@FBumann

@FBumann FBumann commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

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.

The API
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 (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 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. (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 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/.
From the review of #1865–#1878
  • 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.
Values for an LP: the probe behind lp_values

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.

Mutation tables

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.

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.
  • 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.
  • feat(api): a mixed-integer solve can start from values, an earlier answer's or any source a parameter takes, matched by coordinate #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'.
Deliberately not done, and departures

🤖 Generated with Claude Code

https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz

claude added 6 commits October 7, 2026 09:07
…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
@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 (ef01170) with claude/festive-carson-8x46sq-mipstart (64fcb1c)

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

claude added 3 commits October 7, 2026 12:33
…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
…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 3 commits October 8, 2026 10:59
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
@FBumann
FBumann merged commit 59938d7 into main Oct 8, 2026
15 checks passed
@FBumann
FBumann deleted the claude/wizardly-lamport-e3g6om branch October 8, 2026 14:16
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>
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