Repository navigation
feat(api): a solve or a sweep asked for outputs={'slack'} reads how far each constraint is from binding - #1868
Merged
FBumann merged 10 commits intoOct 8, 2026
Conversation
…ar each constraint is from binding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Merging this PR will degrade performance by 5.47%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_window[commitment-s-specsolve-highs-one] |
1.5 MB | 1.6 MB | -5.47% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/festive-carson-8x46sq-slack (5b54514) with claude/festive-carson-8x46sq (2ce4c0e)
Footnotes
-
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. ↩
This was referenced Oct 6, 2026
FBumann
added this pull request to stack #1874
October 7, 2026 06:01
…n-8x46sq-slack # Conflicts: # src/specsolve/relational/engine/engine.py # src/specsolve/relational/result.py # src/specsolve/sweep.py
FBumann
removed this pull request from stack #1874
October 8, 2026 08:06
FBumann
added this pull request to stack #1886
October 8, 2026 08:07
…slack Brings main through the stack: polars 2.0, the 0.7.0 release and load_inputs. The collects this branch added go through collected. 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 8, 2026
…sks for it, and a sweep can hold the activity too (#1865) > **Prompt:** Let’s design the api no properly. How should users configure this? Is this maintainable and stable? What should be optional, what not? Two follow-ups set scope: "This should be properly typed etc! And immutable..." and "I think Output CAN live in .types!" A review of the stack added: "Can it be simpler? Or a smaller diff?" > [!NOTE] > The following content was generated by AI. `outputs=` on `solve`, `Model.solve` and `solve_over` says what an answer holds beyond the primal, duals and declared expressions. Activity is the first output: it is now opt-in everywhere, and a sweep can hold it. `LAYOUT` goes 3 → 4. <details><summary>The rule, and how it is built</summary> - **One rule for every shape an answer takes**: a live, saved, scanned or archived `Result`, and a `Sweep` held in memory, run on a thread or process pool, spilled, saved, scanned or archived. Each holds what the solve asked for. Reading anything else refuses with one message: `Solve again with outputs={'activity'}`. - **Typed and immutable.** `specsolve.types.Output = Literal['activity']` is what a caller writes. `OUTPUTS: Mapping[Output, Per]` is the one table the code reads: a `MappingProxyType` giving per variable or per constraint. `checked_outputs` returns a `frozenset[Output]`. The checks run before any build. They refuse a bare string, an unknown name, and a core kind such as `primal`. - **The table drives everything**: `KINDS`, the readers' `kind=`, the spill, `Sweep.save`, the archive's `answer/` and its catalog (`_answered_under`). Each kind's reader is found by the kind's name (`expression` → `evaluate`), so the bridges hold no dispatch table. Adding an output is a table row, one `case` in `Engine._output`, a reader on `Result` and `Sweep`, and a test. - **On disk**: `format.json` gains `"outputs": [...]`, so a reader can tell "not asked for" from "not defined". A spill's `sweep.json` records them too. A `spill_to=` directory solved with other outputs is refused rather than resumed, because its slices hold only what they were solved with. A name in the stamp that this package does not know is dropped, so a later output needs no `LAYOUT` bump. AGENTS.md now says so in one sentence. - `Result` holds `_outputs: Mapping[Output, Mapping[str, LazyFrame]]` in place of `_activities`. `Engine._output` lays out each output asked for and nothing else, and a sweep slice collects those frames as the result holds them. </details> <details><summary>Breaks</summary> - `Result.activity` refuses unless the solve passed `outputs={'activity'}`, and `save()` writes no `activity/` by default. - `LAYOUT` 3 → 4: an answer, spill or archive written by 0.6 is refused by name. - `specsolve.types` gains `Output`. `tests/test_architecture.py` and architecture.md now allow `Literal` vocabularies in `types`. Coverage moved: tests that read activity now ask for it (`test_activity.py`, `conftest.assert_agrees_with_highs`, `test_cases.py`, the sink tests, `differential.differential(outputs=)`). `test_a_saved_solution_carries_the_activities` became `…_it_was_asked_for` and also loads the answer back. The default-save listing in `test_api.py` no longer has `activity/`. `test_a_solution_is_read_back_in_label_order_without_sorting_for_it` no longer reads activity through `_read_back`. Activity is laid out by the same per-constraint helper as the duals, so the dual's assertion covers it. **The bench read arm now measures the default answer.** `test_read` reads every primal and dual into frames, or `Result.save`s, with no activity. So CodSpeed's `test_read[*]` benchmarks move with this PR: the default answer no longer carries activity. That move is this PR's effect, not noise. Only specsolve has a read arm, so no cross-library comparison changes. The PyPSA parity harness still asks for activity, because it counts rows per block through `activity()`. </details> <details><summary>Why activity is opt-in: its share of a saved answer</summary> Measured with `Result.save` on the bench models, rung `m`, HiGHS, at `79258b1`. Each figure is activity's bytes against everything `save()` wrote: | model | activity | whole answer | |---|---|---| | nodal | 0.8 MB | 1.7 MB | | sector | 0.8 MB | 1.5 MB | | transport | 1.1 MB | 2.5 MB | | storage | 0.1 MB | 0.8 MB | | dispatch | 0.1 MB | 0.4 MB | | fleet | 0.1 MB | 0.3 MB | </details> <details><summary>Verification</summary> Gates: `ruff check .`, `ruff format --check .`, `pyrefly check`, the full suite (`pytest -n auto`, with HiGHS, Gurobi and Xpress installed), `pytest bench/test_harness.py`, `zensical build --strict`, and `test_docs_math.py` + `test_docstring_links.py` with the docs group installed. The review simplification (`909967d`, `3ab29cb`, `7e84f24`) was gated at `7e84f24` with `ruff check .`, `ruff format --check .`, `pyrefly check` (0 errors), and the full suite with the dev group, Gurobi and Xpress installed: 5087 passed, 564 skipped, 1 xfailed. The docs build and `bench/test_harness.py` were not re-run. `tests/test_outputs.py` (81 cases) covers the following: - every request refusal, on all three verbs, and before any model is built; - every result shape and every sweep shape, each one asked for activity and one not; - the refusal through the reader, `to_pandas` and `to_dataset`, and the read through `to_pandas` where pandas is installed (CI's floors job has none, so that check skips there); - no `activity/` written anywhere by default; - the stamp on a result, a spill and a saved sweep; - the refused and the accepted spill resume; - a stamp naming an output from a later version; - a window sweep's stitched and per-window activity, live and archived. Mutation table, `python -m tools.mutate` against the full suite, unless marked by hand. Line numbers are at the commit each row was taken on: | guard | result | |---|---| | a bare string refused (`answer_layout.py:63-67`) | **caught** | | a name that is no output refused (`answer_layout.py:69-74`) | **caught** | | a result refuses an output not asked for (`result.py:575-576`) | **caught** | | a result names the frames of an output (`result.py:626-627`) | **caught** | | a result saves its outputs (`result.py:701-703`) | **caught** | | a spill solved with other outputs refused (`sweep.py:179-185`) | **caught** | | a sweep refuses an output not asked for (`sweep.py:374-375`) | **caught** | | the refusal on every sweep reader (`sweep.py:383`) | **caught** | | the refusal on the sweep bridges (`sweep.py:629`) | **caught** | | a slice carries its outputs (`strategy.py:610-612`, re-taken at `3ab29cb`) | **caught** | | `solve` checks outputs before the build (`api.py:543`) | still green at first → probe `test_a_misspelled_output_is_refused_before_anything_is_built` added → **caught** | | `solve_over` checks before a slice (`strategy.py:279`, by hand: `asked = frozenset(outputs)`) | **caught** (2 failed) | Tree clean after each run. Not tabled: - `Engine._output` laying out only what was asked for. Laying out more is invisible to every reader, since `_outputs` decides what is readable. - The slice skipping an output its result left empty (`strategy.py:611`). With activity alone it is unreachable, because activity travels with the primal. #1856 makes it reachable and tables it. </details> <details><summary>Deliberately not done</summary> - `dual_ray` is still live-only on a `Result`. Making it an output (saved, and per slice in a sweep) is a follow-up PR. - Reduced costs ([#1856](#1856)) and slack ([#1868](#1868)) are stacked on this as outputs, in their own PRs. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1 https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS --------- Co-authored-by: Claude <noreply@anthropic.com>
FBumann
added a commit
that referenced
this pull request
Oct 8, 2026
…onstraint_basis'} reads the basis it ended on, in the same words on every solver (#1876) > **Prompt:** Add stacked PRs about the new things we discussed The discussion it refers to began with: "What about basis, warmstaer initial solution etc? What kind of things would be reasonable to add to boost sovler performance?" > [!NOTE] > The following content was generated by AI. `outputs={'variable_basis', 'constraint_basis'}` makes a solve or a sweep carry the basis status it ended on. The words are the same on HiGHS, Gurobi and Xpress. This is #1869 part 1, and the warm start stacked on it reads it. <details><summary>What it is</summary> - **One vocabulary** (`BASIS_STATUSES`), read back as an `Enum`: `basic`, `at_lower`, `at_upper`, `fixed`, `superbasic`. A row's bound is its right-hand side, so a binding `<=` row is `at_upper`, a binding `>=` row `at_lower`, and a binding `==` row `fixed`. A column at equal bounds is `fixed`, whichever bound its solver names. - **Two outputs, not one**, because a constraint may share a variable's name (mathspec's flat namespace leaves constraints out). Each output holds one frame per declaration, as every other output does. - **How each member reads its basis**: `_basis()` maps the solver's own codes to the shared ones. It reads only basic or not, and which bound a column is at. `Solver.run(handoff, basis=True)` then reads each nonbasic status against the model (`settled`): a row's bound from its sense, `fixed` from equal bounds. That is also why all three sinks agree, even though Gurobi says only "nonbasic" for a row and Xpress names a row's slack. - **Read only when asked**: the engine passes `basis=True` only where `outputs` names one of the two. - **Where there is no basis** — a mixed-integer model, a quadratic constraint, interior point with crossover off — the reader raises `NO_BASIS`, which names the solver options that give one. A sweep raises it only where some slice solved. Otherwise it gives its "nothing to read" message. - An output under #1865's rule, stacked on #1868: `Result.variable_basis` / `constraint_basis`, the same on `Sweep`, `kind=` on the bridges, `<output>/<name>.parquet` in a save, a spill and an archive, and a catalog row per declaration. No `LAYOUT` bump. </details> <details><summary>Verification</summary> Gates: `ruff check .`, `ruff format --check .`, `pyrefly check`, the full suite (`pytest -n auto`, with HiGHS, Gurobi and Xpress installed), `zensical build --strict`, `test_docstring_links.py` and `test_docs_math.py`. The local runs above were taken while one test of the base, `test_a_solution_is_read_back_in_label_order_without_sorting_for_it`, failed there, so they deselect it. The base has fixed it since, it is merged in, and CI runs the whole suite. `tests/test_basis.py` covers the following: - The vertex of a five-variable, four-row LP, worked out by hand so that every status but `superbasic` appears, read in the same words on every sink. - The `Enum` dtype. - A save that loads back. - The refusal when not asked for, per output. - No basis after a mixed-integer solve and after interior point without crossover, on each sink. - No basis with a quadratic constraint, on Gurobi. - A sweep held, on a process pool, spilled and archived. - A sweep whose every slice ended on no basis. - A basis a member reads short, refused. Mutation table, from `python -m tools.mutate` against the full suite (that one test deselected through `PYTEST_ADDOPTS`), except one row taken by hand with the same three precautions: | guard | result | |---|---| | a basis that does not span the model refused (`base.py:271-272`) | **caught** | | a column at equal bounds reads `fixed` (`base.py:274`) | **caught** | | a nonbasic row reads its bound off its sense (`base.py:277`) | **caught** | | a result that ended on no basis says so (`result.py:666-667`) | **caught** | | a sweep whose slices ended on no basis says so (`sweep.py:733-734`) | **caught**, on a second run; the first was interrupted by the load of another run on the box and proved nothing | | HiGHS: an invalid basis is none (`highs.py:244-245`) | **caught** | | Xpress: no basis after a mixed-integer solve (`xpress.py:140-141`) | **caught** | | Xpress: no basis after barrier without crossover (by hand: the `try`/`except SolverError` removed) | **caught** (1 failed) | The tree was clean after each run. One guard is not in the table: `run` reads a basis only after a run that left a primal. Reading one after a failed run would be harmless and invisible, because the reader refuses on the status first. </details> <details><summary>Deliberately not done</summary> - Gurobi's `_basis` has no guard for a quadratic constraint: Gurobi refuses `VBasis` there itself, which the new test shows. - No `fixed` read from a solver for a row: every row is single-sided, so its nonbasic status follows from its sense. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1 --------- 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.
…the second item of a plan whose text was: "Slack: nearly free. It's right-hand side minus row activity, and you already read activity."
Note
The following content was generated by AI.
outputs={'slack'}makes a solve or a sweep carry each constraint's distance to binding. It is non-negative wherever the row holds, however the row is written, and readable wherever the activity is.What it is
rhs − lhsfor<=,lhs − rhsfor>=, and−|rhs − lhs|for==, which holds only at zero. A binding row reads 0. A violated one, such as an incumbent inside the solver's tolerance, reads below 0 by how much.rhs − activity, which the plan named and Gurobi'sSlackis: that sign depends on which side the model writes a term on.x >= 1and1 <= xwould read opposite signs. The distance to binding reads the same for both, and its sign answers "does the row hold?" on every comparator. For a<=row the two agree.readback.slacks, one numpy pass over the rows). So it exists wherever the activity does, a mixed-integer incumbent included. No sink changes.Result.slack,Sweep.slack,kind='slack'on the bridges,slack/<name>.parquetin a save, a spill and an archive, and a catalog row per constraint. NoLAYOUTbump. After the stack's review simplification, the engine side is onecaseinEngine._output.Verification
Gates:
ruff check .,ruff format --check .,pyrefly check, the full suite (pytest -n auto, with HiGHS, Gurobi and Xpress installed), andzensical build --strictwith the docs group installed.After merging the simplified #1856, the branch was gated at
f8489f2withruff check .,ruff format --check .,pyrefly check(0 errors), and the full suite with the dev group, Gurobi and Xpress installed: 5168 passed, 566 skipped, 1 xfailed. The docs build was not re-run.tests/test_slack.pycovers the following:−0.5.Mutation table:
python -m tools.mutatefor the first row, the rest by hand with its three precautions (a clean tree,git checkout --to restore,__pycache__dropped on both sides). Full suite each time.engine.py:324-330, re-taken at8129926)<=readsrhs − lhs(by hand: →lhs − rhs)>=readslhs − rhs(by hand: →rhs − lhs)==reads−|rhs − lhs|(by hand: →rhs − lhs)Tree clean after each run.
Deliberately not done
test_activity.py, and the natives disagree on sign: Gurobi givesrhs − lhson every comparator.dual_rayoutput, which is still live-only. That is next, if wanted.🤖 Generated with Claude Code
https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS