Skip to content

feat(api): a result gives the rate at which its objective moves with each value of a parameter - #2073

Open
FBumann wants to merge 6 commits into
claude/specsolve-mathspec-update-1r2vw1from
claude/specsolve-feature-differentiation-w3yt75
Open

FBumann wants to merge 6 commits into
claude/specsolve-mathspec-update-1r2vw1from
claude/specsolve-feature-differentiation-w3yt75

Conversation

@FBumann

@FBumann FBumann commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: What nice feature suits specsolve well? What would modelers appreciate? Differentiating it from linopy

Prompt: Let’s work on 3, and add b as a named follow up in the pr

Note

The following content was generated by AI.

result.parameter_dual('p_max') returns the dual of each row of a parameter: the rate at which the optimal objective rises with it, in the parameter's own table shape. Every mask is held as built. Checked against central finite differences on HiGHS, Gurobi and Xpress.

result.parameter_dual('p_max')
# generator  value
# wind       -120.0   # one more unit of wind saves 120
# solar         0.0   # masked off by `where: p_max > 0`: held as built
# gas           0.0

Follow-up, built in #2074: at a mask threshold, report the rate as the value rises past it, or ±inf where the objective jumps, from the current solve and with no re-solve. #2074 holds the cases it decides and the ones it leaves as built.

Definition, how it works, verification, what was left out

Definition

The rate of the optimal objective per unit rise of one row of the parameter, in dual's sign convention, for every sense and sink. It is the derivative of the Lagrangian in that row, with the model held as built:

  • the objective's rate,
  • less each constraint row's rate times its dual,
  • plus a column's reduced cost where the parameter is the bound that binds. The sign of the reduced cost and the sense decide which bound binds.

A mask threshold is treated like a basis change: the rate is one-sided there, as dual already is at a degenerate solve. The docstring says so in one sentence and names Model.update plus a re-solve for the other side.

How

  • Compiler.varied names the one leaf that stays a term in a read. A parameter row is a column of its own, labelled by its row-major position. A variable keeps its own columns.
  • readback.parameter_dual compiles the objective and each constraint that reads the parameter, weights each row by dual(c), and adds the coefficients up per label.
  • A bound's reduced cost comes from the same pass, with the bounded variable held as the term. This keeps no solver handoff alive on a result. A quadratic term's rate reads the other label's value.
  • Live results, archives (scan_archive(...).result.parameter_dual) and results after Model.update answer. A result loaded with load_result has no model behind it, and says so.
  • Nothing is written to disk: no new output kind and no layout change.
  • Model._evaluator became Model._readers, returning the evaluator and the parameter dual. tests/differential.py follows the rename.
  • The architecture table's row for _engine/readback.py names the parameter dual.
  • Docs. A how-to, What a change is worth (docs/howto/prices.md), reads the dual, the reduced cost and the parameter dual by the question each answers. It ranks inputs by a 1% rise, and says when a scenario sweep answers instead. Your first model and Coming from linopy link to it. The tutorial Change it and solve again keeps one sentence pointing there.

The name

The reader started as marginal and was renamed parameter_dual in a861e06. GAMS calls both a dual and a reduced cost a marginal (.m), so marginal read as the reduced cost to much of the audience. A single marginal over constraints, variables and parameters was weighed and not taken: a constraint may share its name with a variable or a parameter, and dual, the archive's dual/ and the language's dual(c) would lose their word. The three readers now read as one family: dual, reduced_cost, parameter_dual.

Refused

  • A parameter that is divided by, raised to a power, or multiplied by itself is refused when parameter_dual is called, before any compile. The message names the rewrite: declare the quantity as a parameter of its own.
  • A mixed-integer result is refused with dual's message.

Verified

  • On 85d4e5d, pixi run check: 5891 passed, 579 skipped, 4 xfailed.
  • 38d37d6 turns an unreachable guard into an assert (below). On it, tests/test_marginal.py (123 passed, 5 skipped), lint and typecheck were run. The whole suite was not re-run unmutated.
  • On 8b18858: pixi run docs-build (strict), docs-test (116 passed), test_changelog, test_doc_examples and test_docs_site (162 passed) are clean.
  • On bc8e234 (the how-to): pixi run docs-build (strict), docs-test (116 passed), test_doc_examples and test_docs_site (123 passed) are clean. The suite was not re-run for that docs-only commit.
  • On a861e06 (the rename): pixi run check 5892 passed, 579 skipped, 4 xfailed; docs-build (strict) and docs-test (116 passed) are clean.
  • Not run: test-via-pyarrow, test-floors, test-bench.
  • tests/test_parameter_dual.py, 123 cases on HiGHS, Gurobi and Xpress (Xpress skips the quadratic model). Each row of each parameter is moved by ±1e-4, re-solved, and compared with the parameter dual at rel 1e-5. The models cover:
    • objective, quadratic and row coefficients;
    • right sides and scalars;
    • lower and upper bounds under both senses and all six ways of writing the balance (reused from test_reduced_cost);
    • a parameter under a cyclic shift, and one with missing: absent.
  • Hand checks: parameter_dual('load') == dual('balance'), and parameter_dual('cost') equals the summed primal.
  • One test pins the held-as-built rule: a masked-off generator reports 0, while a re-solve with capacity is cheaper.

Mutation table

Each guard was deleted in turn and the full suite was run. The first eight rows come from tools/mutate.py. The last four were taken by hand with the same precautions (git checkout -- restore, __pycache__ dropped on both sides), because the tool deletes lines and cannot negate a branch or change a constant. The tree came back clean after every run.

Guard Result
an unknown parameter is a KeyError (readback.parameter_dual) caught
no duals raises their reason (readback.parameter_dual) still green: Result.parameter_dual checks first, so no caller reaches it. Now an assert (38d37d6)
the nonlinear-read refusal is called caught
a constraint that does not read the name is skipped still green, as expected: the skip saves a compile and changes no value
a lower bound takes the reduced cost caught
an upper bound takes the reduced cost caught
a result with no duals raises their reason (Result.parameter_dual) caught
an answer with no model behind it says so caught
a divisor is refused (branch → False) caught
a power is refused (branch → False) caught
a square is refused (branch → False) caught
maximize flips which bound binds (toward = 1.0) caught

Deliberately not done

  • Sweep.parameter_dual.
  • to_pandas / to_dataarray for parameter duals.
  • Ranging, meaning the interval over which a rate holds.
  • Writing parameter duals into an archive.

Departure from AGENTS.md

The _reads skip survives its deletion and has no probe, because no value can tell it apart. Only the cost of a call can, and that was not measured.

🤖 Generated with Claude Code

https://claude.ai/code/session_018B3nA1Q4XLWBq2jJFFLFWR

…each value of a parameter

Result.marginal(name) returns one row per row of the parameter's table:
the derivative of the Lagrangian in that row, with every mask held as
built. The compiler reads the one varied leaf as a term in a read, so the
objective, each constraint row weighted by its dual, and each binding
bound's reduced cost add up to the rate. Live results and archives answer;
a parameter under a divisor, in a power or squared is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018B3nA1Q4XLWBq2jJFFLFWR
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: de097886-5200-41a7-9194-e903b4e459d3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codspeed

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

1 warning

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

⚡ 18 improved benchmarks
❌ 2 regressed benchmarks
✅ 84 untouched benchmarks
⏩ 307 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_read[fleet-s-specsolve-parquet] 955 KB 1,121.7 KB -14.86%
❌ test_read[network-s-specsolve-parquet] 1.9 MB 2.1 MB -8.8%
⚡ test_read[commitment-s-specsolve-parquet] 690 KB 220.7 KB ×3.1
⚡ test_read[storage-s-specsolve-parquet] 2 MB 1.4 MB +42.61%
⚡ test_emit[fleet-s-specsolve-lp] 31.5 MB 25.6 MB +23.33%
⚡ test_emit[fleet-s-specsolve-in-memory-lp] 31.5 MB 25.6 MB +23.33%
⚡ test_emit[network-s-specsolve-lp] 34.5 MB 28.2 MB +22.37%
⚡ test_emit[network-s-specsolve-in-memory-lp] 34.5 MB 28.2 MB +22.35%
⚡ test_emit[commitment-s-specsolve-lp] 3.2 MB 2.6 MB +22.07%
⚡ test_emit[commitment-s-specsolve-in-memory-lp] 3.2 MB 2.6 MB +22.05%
⚡ test_emit[milp-s-specsolve-lp] 47.7 MB 39.8 MB +19.96%
⚡ test_emit[milp-s-specsolve-in-memory-lp] 47.7 MB 39.8 MB +19.96%
⚡ test_emit[nodal-s-specsolve-lp] 6.8 MB 5.7 MB +18.68%
⚡ test_emit[nodal-s-specsolve-in-memory-lp] 6.8 MB 5.7 MB +18.66%
⚡ test_emit[sector-s-specsolve-lp] 11.8 MB 9.9 MB +18.28%
⚡ test_emit[sector-s-specsolve-in-memory-lp] 11.8 MB 9.9 MB +18.24%
⚡ test_emit[storage-s-specsolve-lp] 24.5 MB 20.8 MB +17.86%
⚡ test_emit[storage-s-specsolve-in-memory-lp] 24.5 MB 20.8 MB +17.86%
⚡ test_read[sector-s-specsolve-parquet] 1.6 MB 1.5 MB +11.17%
⚡ test_read[nodal-s-specsolve-parquet] 1.6 MB 1.5 MB +11.1%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/specsolve-feature-differentiation-w3yt75 (a861e06) with claude/specsolve-mathspec-update-1r2vw1 (792d441)

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 28, 2026 •

Copy link
Copy Markdown

… its pull request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018B3nA1Q4XLWBq2jJFFLFWR
…an input is worth, and which reader answers it

The marginal leaves the change-and-solve-again tutorial, whose path it
broke, for a how-to beside the dual and the reduced cost. It ranks inputs by
a 1% rise and says when a sweep answers instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018B3nA1Q4XLWBq2jJFFLFWR
…ual, beside dual and reduced_cost

GAMS calls both a dual and a reduced cost a marginal, so `marginal` read as
the reduced cost to much of the audience. The reader is the dual of a
parameter, in dual's convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018B3nA1Q4XLWBq2jJFFLFWR

This branch has not been deployed

No deployments
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