Repository navigation
feat(api): a solve or a sweep asked for outputs={'reduced_cost'} reads each variable's reduced cost, with the same sign on every solver - #1856
Merged
FBumann merged 13 commits intoOct 8, 2026
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
Documentation build overview
12 files changed ·
|
FBumann
force-pushed
the
claude/festive-carson-8x46sq
branch
from
October 6, 2026 22:25
c250c06 to
28aa7d6
Compare
FBumann
changed the base branch from
main
to
claude/festive-carson-8x46sq-outputs
October 6, 2026 22:25
…ith the same sign on every solver 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
…hive carry them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
FBumann
force-pushed
the
claude/festive-carson-8x46sq
branch
from
October 6, 2026 22:39
28aa7d6 to
ef1332c
Compare
…puts' into claude/festive-carson-8x46sq
This was referenced Oct 6, 2026
FBumann
added this pull request to stack #1874
October 7, 2026 06:01
…puts' into claude/festive-carson-8x46sq
…puts' into claude/festive-carson-8x46sq
…puts' into claude/festive-carson-8x46sq # Conflicts: # src/specsolve/relational/engine/engine.py # src/specsolve/relational/result.py # src/specsolve/strategy.py # src/specsolve/sweep.py
…gives the duals' reason Strict xfail: today the bridge lists no names and fails unpacking them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
…the duals' reason The duals' reason is given where an output is read, so the bridges and the reader share it rather than the reader alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
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
…-8x46sq 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
…ar each constraint is from binding (#1868) > **Prompt:** Then, we can continue with slack …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. <details><summary>What it is</summary> - **The value**: `rhs − lhs` for `<=`, `lhs − rhs` for `>=`, 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. - **Why not plain `rhs − activity`**, which the plan named and Gurobi's `Slack` is: that sign depends on which side the model writes a term on. `x >= 1` and `1 <= x` would 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. - **How it is computed**: from the activity the solver returns and the built model's right-hand sides (`readback.slacks`, one numpy pass over the rows). So it exists wherever the activity does, a mixed-integer incumbent included. No sink changes. - **An output under #1865's rule**, stacked on #1856: `Result.slack`, `Sweep.slack`, `kind='slack'` on the bridges, `slack/<name>.parquet` in a save, a spill and an archive, and a catalog row per constraint. No `LAYOUT` bump. After the stack's review simplification, the engine side is one `case` in `Engine._output`. </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), and `zensical build --strict` with the docs group installed. After merging the simplified #1856, the branch was gated at `f8489f2` with `ruff 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.py` covers the following: - A hand-derived optimum on every sink, with each row written three ways: variables on the left, variables on the right, constants moved across. All give the same slacks. - An equality off its right-hand side, on either side, reading `−0.5`. - A mixed-integer incumbent, which has a slack. - A save that loads back. - The refusal when not asked for. - A sweep held, spilled and archived. Mutation table: `python -m tools.mutate` for 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. | guard | result | |---|---| | the slack is laid out per constraint (`engine.py:324-330`, re-taken at `8129926`) | **caught** | | `<=` reads `rhs − lhs` (by hand: → `lhs − rhs`) | **caught** (10 failed) | | `>=` reads `lhs − rhs` (by hand: → `rhs − lhs`) | **caught** (10 failed) | | `==` reads `−\|rhs − lhs\|` (by hand: → `rhs − lhs`) | **caught** (1 failed, the purpose-built residual test) | Tree clean after each run. </details> <details><summary>Deliberately not done</summary> - No cross-check against each solver's native slack. The activity it is computed from is already the solver's own number, checked by `test_activity.py`, and the natives disagree on sign: Gurobi gives `rhs − lhs` on every comparator. - No `dual_ray` output, which is still live-only. That is next, if wanted. </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>
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.
…of a plan whose first item was: "Reduced costs computed from duals, with a cross-check test against each solver." A follow-up set scope: "Reduced cost must work for sweeps the same way as it does for regular solves!" It now builds on #1865's
outputs=.Note
The following content was generated by AI.
outputs={'reduced_cost'}makes a solve or a sweep carry each variable's reduced cost, computed from the duals, so its sign isdual's on every sink. A result, a sweep, their saves, spills and archives all read it.What it is, and what it refuses
dual's convention moved from a row's right-hand side to a column's bound, so one rule holds for both senses, every comparator, and either side a term is written on. A variable strictly between its bounds reads zero.readback.reduced_costs). The pass includes a quadratic objective's gradient and a quadratic row's gradient (Gurobi withQCPDual). No sink changes.Result.reduced_costandSweep.reduced_cost,kind='reduced_cost'on the bridges,reduced_cost/<name>.parquetin a save, a spill and an archive, and a catalog row per variable. NoLAYOUTbump: an older answer reads it as not asked for.dualrefuses, with the duals' own reason (mixed-integer, a quadratic row withoutQCPDual, a run with no basis), through the reader and the bridges alike. That reason is saved once inreasons.parquet.PRICED = {'dual', 'reduced_cost'}is the one home of "exists exactly where the duals do", read byResult._carriedand the sweep readers. It is also refused when not asked for, for a closed result, and withNoSolutionErrorwhen there are no values.Verification
Gates:
ruff check .,ruff format --check .,pyrefly check, the full suite (pytest -n auto, with HiGHS, Gurobi and Xpress installed),zensical build --strict, andtest_docs_math.py+test_docstring_links.pywith the docs group installed.The review fix (
768ae3e,9ebc0a2) was gated at6a03f5dwithruff check .,ruff format --check .,pyrefly check(0 errors), and the full suite with the dev group, Gurobi and Xpress installed: 5151 passed, 566 skipped, 1 xfailed. The docs build was not re-run.tests/test_reduced_cost.pyhas three oracles:>=/<=reversed/==).col_dual, GurobiRC, XpressgetRedCosts. All three agree with the computed value for both senses, so no sink needs a sign conversion.On top of those: quadratic objectives (HiGHS, Gurobi) and quadratic rows (Gurobi
QCPDual); a save that loads back; an integer answer, live and loaded, giving the duals' reason through the reader and throughto_dataset; and a two-scenario sweep held, spilled and archived. In that sweep each slice is priced at its own marginal generator, and an integer sweep gives the duals' reason.The
to_datasetcase was a bug found in review: a mixed-integer answer's bridge listed no names and failed unpacking them (ValueError). It was reproduced as a strictxfail(768ae3e), seen failing live and loaded, then fixed by giving the duals' reason where any output is read (9ebc0a2).Mutation table,
python -m tools.mutateagainst the full suite, unless marked by hand. Line numbers are at the commit each row was taken on:readback.py:164)readback.py:165)readback.py:167)readback.py:168)readback.py:170)readback.py:171)result.py:605-606, at9ebc0a2)strategy.py:611, at3fcba30, by hand:if laid:→if True:)sweep.py:391, by hand:kind in PRICED→kind == 'dual')Tree clean after each run.
Cost, only when asked
Method: a dispatch LP with 200,000 snapshots × 10 generators (2,000,000 columns, 200,000 rows, 2,000,000 nonzeros) on HiGHS. One build, at
28aa7d6.Engine._answeredwas timed with and withoutoutputs={'reduced_cost'}, alternating, median of 7. The solve is the median of 3. Three runs, load average under 1.1 with nothing else running.outputs={'reduced_cost'}Asking costs about 0.3% of the solve on this model. Not asking costs nothing. These numbers predate the review simplification, which changes where the computation is called from and not what it computes. They were not re-run.
Deliberately not done
reduced_cost(x)in a spec).reduced_costwas discussed and kept: it is the name HiGHS, Gurobi, Xpress and JuMP use. Its sign is stated in the docstring, because JuMP's convention (relaxing the bound) flips the sign at a lower bound.🤖 Generated with Claude Code
https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS