Skip to content

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 into
claude/festive-carson-8x46sq-outputsfrom
claude/festive-carson-8x46sq
Oct 8, 2026
Merged

FBumann merged 13 commits into
claude/festive-carson-8x46sq-outputsfrom
claude/festive-carson-8x46sq

Conversation

@FBumann

@FBumann FBumann commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: Lets work on this as a pr from main

…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 is dual's on every sink. A result, a sweep, their saves, spills and archives all read it.

What it is, and what it refuses
  • The value is the rate at which the optimal objective rises as the variable's binding bound rises. That is 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.
  • How it is computed: ∇f(x) − Σ yᵣ∇gᵣ(x), in one numpy pass over the CSR matrix (readback.reduced_costs). The pass includes a quadratic objective's gradient and a quadratic row's gradient (Gurobi with QCPDual). No sink changes.
  • An output under feat(api): every answer holds an optional output only when outputs= asks for it, and a sweep can hold the activity too #1865's rule. Result.reduced_cost and Sweep.reduced_cost, kind='reduced_cost' on the bridges, reduced_cost/<name>.parquet in a save, a spill and an archive, and a catalog row per variable. No LAYOUT bump: an older answer reads it as not asked for.
  • Where it is refused: wherever dual refuses, with the duals' own reason (mixed-integer, a quadratic row without QCPDual, a run with no basis), through the reader and the bridges alike. That reason is saved once in reasons.parquet. PRICED = {'dual', 'reduced_cost'} is the one home of "exists exactly where the duals do", read by Result._carried and the sweep readers. It is also refused when not asked for, for a closed result, and with NoSolutionError when there are no values.
  • Computed only when asked, so a default solve pays neither the time nor the memory.
Verification

Gates: ruff check ., ruff format --check ., pyrefly check, the full suite (pytest -n auto, with HiGHS, Gurobi and Xpress installed), zensical build --strict, and test_docs_math.py + test_docstring_links.py with the docs group installed.

The review fix (768ae3e, 9ebc0a2) was gated at 6a03f5d with ruff 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.py has three oracles:

  • A finite difference: raise the binding bound by 1e-3, solve again, and the objective moves at the reduced cost. Run on every sink, for six ways of writing the balance (min/max × >=/<= reversed/==).
  • Each solver's own value: HiGHS col_dual, Gurobi RC, Xpress getRedCosts. All three agree with the computed value for both senses, so no sink needs a sign conversion.
  • A hand-derived optimum.

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 through to_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_dataset case 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 strict xfail (768ae3e), seen failing live and loaded, then fixed by giving the duals' reason where any output is read (9ebc0a2).

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
the objective cost term (readback.py:164) caught
the rows weighted by their duals (readback.py:165) caught
the quadratic objective gradient (readback.py:167) caught
the quadratic rows weighted by their duals (readback.py:168) caught
the gradient on the left column of a pair (readback.py:170) caught
the gradient on the right column of a pair (readback.py:171) caught
a priced output gives the duals' reason (result.py:605-606, at 9ebc0a2) caught
a slice skips an output its result left empty (strategy.py:611, at 3fcba30, by hand: if laid: → if True:) caught (36 failed)
a sweep gives the duals' reason for a priced kind (sweep.py:391, by hand: kind in PRICED → kind == 'dual') caught (3 failed)

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._answered was timed with and without outputs={'reduced_cost'}, alternating, median of 7. The solve is the median of 3. Three runs, load average under 1.1 with nothing else running.

run 1 run 2 run 3
read-back, no outputs 0.1 ms 0.1 ms 0.1 ms
read-back, outputs={'reduced_cost'} 54.8 ms 66.9 ms 55.5 ms
HiGHS solve 20.68 s 20.27 s 20.29 s

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
  • No language construct (reduced_cost(x) in a spec).
  • The name reduced_cost was 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.
  • Slack is the next output, in its own PR stacked on this one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS

@codspeed

codspeed Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 307 skipped benchmarks1


Comparing claude/festive-carson-8x46sq (2ce4c0e) with claude/festive-carson-8x46sq-outputs (b136129)

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

@read-the-docs-community

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

Copy link
Copy Markdown

@FBumann
FBumann force-pushed the claude/festive-carson-8x46sq branch from c250c06 to 28aa7d6 Compare October 6, 2026 22:25
@FBumann FBumann changed the title feat(api): a solved result reads the reduced cost of each variable, with the same sign on every solver 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 Oct 6, 2026
@FBumann
FBumann changed the base branch from main to claude/festive-carson-8x46sq-outputs October 6, 2026 22:25
claude added 3 commits October 6, 2026 22:39
…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
claude added 8 commits October 7, 2026 06:06
…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
FBumann removed this pull request from stack #1874 October 8, 2026 08:06
@FBumann
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
FBumann merged commit b07b0bc into main Oct 8, 2026
13 checks passed
@FBumann
FBumann deleted the claude/festive-carson-8x46sq branch October 8, 2026 14:16
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>
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