Skip to content

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

FBumann merged 10 commits into
claude/festive-carson-8x46sqfrom
claude/festive-carson-8x46sq-slack

Conversation

@FBumann

@FBumann FBumann commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

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.

What it is
  • 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 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, stacked on 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: 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.
Verification

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.

Deliberately not done
  • 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.

🤖 Generated with Claude Code

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

claude added 2 commits October 6, 2026 22:39
…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
@codspeed

codspeed Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 5.47%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 66 untouched benchmarks
⏩ 307 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

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

…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
FBumann merged commit cdfd578 into main Oct 8, 2026
13 checks passed
@FBumann
FBumann deleted the claude/festive-carson-8x46sq-slack 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
…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>
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