Skip to content

feat(sinks): gurobi, and the two families it revealed - #418

Merged
FBumann merged 7 commits into
mainfrom
feat/gurobi-sink
Jul 31, 2026
Merged

FBumann merged 7 commits into
mainfrom
feat/gurobi-sink

Conversation

@FBumann

@FBumann FBumann commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

A second solver, the reorganisation it argued for, and the licence handling it
turned out to need.

1. The sink

A second solver reading the same ModelTables as HiGHS: dense column vectors
in one addMVar, the matrix in CSR blocks chunked by nonzeros. No
float→text→parse round trip.

lps.solve('model.yaml', sources, solver_name='gurobi')

scipy is in the [gurobi] extra, and is not a transitive convenience.
gurobipy's matrix API takes a matrix object where HiGHS takes the three CSR
arrays, so scipy.sparse.csr_matrix over the arrays the labels already laid
out is the only way to hand over a block of rows without one Python call per
row.

The condition map is linopy's, minus three entries it gets wrong. linopy
calls SOLUTION_LIMIT optimal and WORK_LIMIT / MEM_LIMIT
internal_solver_error; all three contradict Gurobi's documented status codes,
and the last also contradicts linopy's own HiGHS map, which sends
kMemoryLimit to resource_interrupt. Each is declared in
_LINOPY_DIVERGENCES with its reason, and test_solve_status.py pins both
directions — everything else still matches linopy, and every declared
divergence is still one, so if linopy fixes it the test fails and the exception
goes away.

2. Two families

The second solver made the flat sinks/ bag read wrong: its members are two
different shapes, and the naming still said solver_direct — a mechanism, when
what varies is which solver. How many solvers there are will change; what a
solver has to answer will not.
So the taxonomy is the tree:

relational/sinks/
├── tables.py          the contract both families read
├── solvers/           chosen by name    — highs.py, gurobi.py, SOLVERS
└── writers/           chosen by suffix  — lp_file.py, WRITERS + PLANNED_WRITERS

A solver is four things that must agree — module named for the solver,
solve_<name>, a build_<name> seam for bench/, and the SOLVERS key — and
tests/test_architecture.py reads all four off the path, plus "no sink reaches
a sibling", which is what keeps gurobipy off the import path of a HiGHS
caller. Adding a solver is a module and a line: no method on the executor, no
branch in api.py, no name on the Python surface. api.write asks the writer
family instead of branching on suffix, and planned lives there too, so .mps
answers for itself.

Renamed rather than aliased, per CONTRIBUTING's breaking changes are free:
ex.write_lp(path) → ex.write(path), solve_direct → solve_highs, and the
RelationalBuildError deprecation alias is gone.

3. Licence and environment

Checked against linopy, which has iterated on this:

  • solve_gurobi disposes model then environment in a finally — their order,
    and their comment says it is required for the license to be released even
    while user code still holds a solver_model reference
    .
  • build_gurobi hands the model over with a weakref.finalize disposing the
    environment when the model is dropped. gurobipy has no Model.getEnv(), so a
    caller given only the model could not release it at all; linopy's equivalent
    case leaves it to gurobipy to free whenever.
  • Solver options go to Env(params=...), not setParam. WLSAccessID /
    ComputeServer / TokenServer can only be set before an environment starts
    — applying options to the model locked out every Compute-Server and WLS user.

What needs a solver handle rather than a finalizer — one environment across
many solves, warm starts, in-place re-solve — is #424.

Evidence

tests/test_gurobi_sink.py, skipped without gurobipy: HiGHS is the oracle for
objective, primal and duals (including under maximize, where a sign
convention could differ and nothing else would notice), all 11 ported models
reach their published optima on gurobi
, plus ragged block boundaries, the
MIP-has-no-duals path, options landing on the environment, the build seam, and
that nothing keeps a built model alive. Two architecture tests pin the
families; the closed-solver-set test lives in test_api.py so it runs without
the extra.

gurobipy's wheel carries a size-limited licence, so lpspec[gurobi] is in the
dev group and CI exercises the sink with nobody's licence; verified green with
gurobipy hidden as well.

Groundwork for #106, and Track 3's uneven capability table is now concrete
rather than hypothetical.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds an optional Gurobi solver sink, shared dense model projections, and solver_name dispatch through the public API. It adds status, result, dependency, and integration tests. Documentation now describes installation, solver selection, sink differences, and updated capability status.

Changes

Solver expansion

Layer / File(s) Summary
Shared solver projection
pyproject.toml, src/lpspec/relational/sinks/tables.py, src/lpspec/relational/sinks/highs.py
Adds the Gurobi dependency extra and moves dense column projection into ModelTables, which both sinks can use.
Gurobi sink implementation
src/lpspec/relational/sinks/gurobi.py, tests/test_gurobi_sink.py, tests/test_solve_status.py
Adds Gurobi model construction, CSR loading, option forwarding, status mapping, primal and dual extraction, dependency diagnostics, and comprehensive tests.
Solver registry and API dispatch
src/lpspec/api.py, src/lpspec/relational/engines/polars/executor.py, src/lpspec/relational/sinks/__init__.py, docs/api.md, docs/ARCHITECTURE.md
Adds solver_name, registers highs and gurobi, and dispatches solves through the selected sink.
Setup and capability documentation
CONTRIBUTING.md, README.md, docs/index.md, docs/SPEC.md, docs/ROADMAP.md, src/lpspec/relational/sinks/README.md
Documents the Gurobi extra, licensing and availability boundaries, sink-specific capabilities, and updated roadmap entries.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant API as lpspec.api.solve
  participant Executor as PolarsExecutor.solve
  participant Registry as solver
  participant Gurobi as solve_gurobi
  participant Tables as ModelTables
  API->>Executor: Forward solver_name and solver_options
  Executor->>Registry: Look up selected solver
  Registry-->>Executor: Return solve_gurobi
  Executor->>Gurobi: Pass ModelTables and solver options
  Gurobi->>Tables: Read dense solver projections
  Gurobi-->>Executor: Return status, objective, primal, and dual results
Loading

Possibly related PRs

  • FBumann/lpspec#87: Updates overlapping architecture, roadmap, specification, README, and API documentation areas.
  • FBumann/lpspec#388: Reclassifies quadratic support around solver sink capabilities in related roadmap and architecture documentation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 94.12% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Gurobi sink as the primary change and remains related to the solver-family updates.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gurobi-sink

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@read-the-docs-community

read-the-docs-community Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 lpspec | 🛠️ Build #33856314 | 📁 Comparing 6a374dd against latest (8197084)

  🔍 Preview build  

5 files changed · ± 5 modified

± Modified

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/lpspec/relational/sinks/gurobi.py (2)

228-243: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Validate the concatenated dual length against row_count.

_labelled assigns row labels from np.arange(len(values)), so the dual frame is correct only if the blocks tile 0..row_count-1 exactly. _duals cannot see row_count, so a tiling gap in row_chunks_by_nonzeros would shift every dual label after the gap and stay silent. The HiGHS sink does not carry this exposure, because it passes model.row_count to its own _labelled (src/lpspec/relational/sinks/highs.py Line 173).

Pass model.row_count into _duals and assert the concatenated length.

♻️ Proposed change
-def _duals(blocks: list[Any]) -> pl.DataFrame | None:
+def _duals(blocks: list[Any], row_count: int) -> pl.DataFrame | None:
@@
     try:
         values = [block.Pi for block in blocks]
     except (AttributeError, gurobipy.GurobiError):
         return None
-    return _labelled('row', np.concatenate(values) if values else np.empty(0, dtype=np.float64))
+    duals = np.concatenate(values) if values else np.empty(0, dtype=np.float64)
+    if len(duals) != row_count:
+        msg = f'gurobi returned {len(duals)} duals for {row_count} rows'
+        raise AssertionError(msg)
+    return _labelled('row', duals)

Update the call site at Line 129 to _duals(blocks, model.row_count).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lpspec/relational/sinks/gurobi.py` around lines 228 - 243, Update
`_duals` to accept `row_count`, and update its call site to pass
`model.row_count`. After concatenating the block dual values, validate that
their length equals `row_count` before calling `_labelled`; preserve the
existing `None` behavior when duals are unavailable.

122-129: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Dispose the Gurobi model and environment after solving.

_load() creates an explicit gurobipy.Env and gurobipy.Model(env=...), but solve_gurobi() returns after optimize() without deterministic cleanup. With remote/token licenses, the license remains held until GC collects the model; the size-limited wheel license does not show this behavior. The dependency constraint is gurobipy>=11, where the Python context-manager protocol is supported. Keep build_gurobi() unchanged because the caller owns the returned model.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lpspec/relational/sinks/gurobi.py` around lines 122 - 129, Update
solve_gurobi() to deterministically dispose of the model and its explicitly
created environment after optimize(), including the early return for unreadable
status, using gurobipy>=11 context-manager support; leave build_gurobi()
unchanged because its caller owns the returned model.
src/lpspec/relational/sinks/tables.py (1)

68-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate the NumPy arrays instead of using Any.

numpy is a base runtime dependency, so numpy.typing can be imported under TYPE_CHECKING and the four vectors can carry real types. The current Any return hides the element type from every caller, including the Gurobi sink, which passes integral to np.where.

♻️ Proposed typing change
 if TYPE_CHECKING:
+    import numpy as np
+    import numpy.typing as npt
-    def dense_columns(self, infinity: float) -> tuple[Any, Any, Any, Any]:
+    def dense_columns(
+        self, infinity: float
+    ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.bool_]]:
-def _scattered(count: int, at: Any, values: Any, absent: Any) -> Any:
+def _scattered(count: int, at: npt.NDArray[Any], values: npt.NDArray[_T], absent: object) -> npt.NDArray[_T]:
Note: the guideline "Keep pyrefly strict checking at zero errors; fix types rather than widening them" applies here. As per coding guidelines.

Also applies to: 109-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lpspec/relational/sinks/tables.py` at line 68, Update the dense_columns
return annotation and its related typing imports to use numpy.typing array types
for all four vectors instead of Any, importing the NumPy typing module under
TYPE_CHECKING as needed. Preserve the existing runtime behavior while exposing
the vectors’ element types to callers such as the Gurobi sink and keeping strict
type checking error-free.

Source: Coding guidelines

tests/test_solve_status.py (1)

118-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Let this helper serve the HiGHS test too.

_linopy_condition_map repeats the AST walk that test_the_highs_mapping_matches_linopy performs inline at Lines 63-85. The only difference is the node attribute read from each key and value pair: the HiGHS map uses ast.Attribute (.attr), and the Gurobi map uses ast.Constant (.value). Both tests state that they are brittle on purpose, so the two copies also rot in two places.

Parametrize the helper by the node type it expects, then call it from both tests.

♻️ Proposed shape
-def _linopy_condition_map(solver: str) -> dict[int, str]:
+def _linopy_condition_map(solver: str, keyed_by: type[ast.expr], attribute: str) -> dict[Any, str]:
@@
     return {
-        key.value: value.value
+        getattr(key, attribute): getattr(value, attribute)
         for key, value in zip(literals[0].keys, literals[0].values, strict=True)
-        if isinstance(key, ast.Constant) and isinstance(value, ast.Constant)
+        if isinstance(key, keyed_by) and isinstance(value, keyed_by)
     }

test_the_highs_mapping_matches_linopy then reads _linopy_condition_map('Highs', ast.Attribute, 'attr'), and the Gurobi test reads _linopy_condition_map('Gurobi', ast.Constant, 'value').

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_solve_status.py` around lines 118 - 144, Generalize
_linopy_condition_map to accept the expected AST key/value node type and
attribute name, then move its existing literal extraction and mapping logic into
that parameterized helper. Replace the inline AST walk in
test_the_highs_mapping_matches_linopy with _linopy_condition_map('Highs',
ast.Attribute, 'attr'), and call it for Gurobi with
_linopy_condition_map('Gurobi', ast.Constant, 'value'), preserving each test’s
existing assertions.
tests/test_gurobi_sink.py (1)

199-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test does not need gurobipy, and the module-level skip hides it.

Line 27 skips the whole module when gurobipy is absent. test_the_set_of_solver_sinks_is_closed asserts registry behavior only: an unknown solver_name raises, and SOLVERS holds exactly two names. Neither assertion loads Gurobi. On a contributor machine or a CI job without the [gurobi] extra, the closed-set contract therefore goes unchecked.

Move this test to the registry test module, so it runs in every configuration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gurobi_sink.py` around lines 199 - 205, Move
test_the_set_of_solver_sinks_is_closed from tests/test_gurobi_sink.py into the
registry test module, preserving its unknown-solver exception assertion and
exact sinks.SOLVERS membership check. Ensure it is no longer covered by the
module-level gurobipy skip and runs without the Gurobi extra.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/api.md`:
- Line 21: Update the `lps.solve` example on the Gurobi line to assign its
return value to the result variable, matching the preceding HiGHS invocation so
subsequent `result.*` expressions use the Gurobi result.

In `@src/lpspec/relational/sinks/__init__.py`:
- Around line 33-59: Update the solver registry near SOLVERS by storing the
entries in a private mutable dictionary, then expose SOLVERS as a read-only
MappingProxyType view of that registry. Ensure solver(name) continues resolving
through SOLVERS so the exported mapping cannot be modified while preserving the
existing solver entries and behavior.

In `@src/lpspec/relational/sinks/README.md`:
- Around line 49-52: Update the sink authoring guide’s solver return contract to
document the required four-tuple: status, objective, primal values, and dual
values. Keep the writer sink’s None behavior unchanged, and align the solver
wording with the SOLVERS registry and PolarsExecutor.solve() unpacking contract.

---

Nitpick comments:
In `@src/lpspec/relational/sinks/gurobi.py`:
- Around line 228-243: Update `_duals` to accept `row_count`, and update its
call site to pass `model.row_count`. After concatenating the block dual values,
validate that their length equals `row_count` before calling `_labelled`;
preserve the existing `None` behavior when duals are unavailable.
- Around line 122-129: Update solve_gurobi() to deterministically dispose of the
model and its explicitly created environment after optimize(), including the
early return for unreadable status, using gurobipy>=11 context-manager support;
leave build_gurobi() unchanged because its caller owns the returned model.

In `@src/lpspec/relational/sinks/tables.py`:
- Line 68: Update the dense_columns return annotation and its related typing
imports to use numpy.typing array types for all four vectors instead of Any,
importing the NumPy typing module under TYPE_CHECKING as needed. Preserve the
existing runtime behavior while exposing the vectors’ element types to callers
such as the Gurobi sink and keeping strict type checking error-free.

In `@tests/test_gurobi_sink.py`:
- Around line 199-205: Move test_the_set_of_solver_sinks_is_closed from
tests/test_gurobi_sink.py into the registry test module, preserving its
unknown-solver exception assertion and exact sinks.SOLVERS membership check.
Ensure it is no longer covered by the module-level gurobipy skip and runs
without the Gurobi extra.

In `@tests/test_solve_status.py`:
- Around line 118-144: Generalize _linopy_condition_map to accept the expected
AST key/value node type and attribute name, then move its existing literal
extraction and mapping logic into that parameterized helper. Replace the inline
AST walk in test_the_highs_mapping_matches_linopy with
_linopy_condition_map('Highs', ast.Attribute, 'attr'), and call it for Gurobi
with _linopy_condition_map('Gurobi', ast.Constant, 'value'), preserving each
test’s existing assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f5753c9-f78d-4314-b0f8-cac28f1314c0

📥 Commits

Reviewing files that changed from the base of the PR and between 8197084 and 876814c.

📒 Files selected for processing (17)
  • CONTRIBUTING.md
  • README.md
  • docs/ARCHITECTURE.md
  • docs/ROADMAP.md
  • docs/SPEC.md
  • docs/api.md
  • docs/index.md
  • pyproject.toml
  • src/lpspec/api.py
  • src/lpspec/relational/engines/polars/executor.py
  • src/lpspec/relational/sinks/README.md
  • src/lpspec/relational/sinks/__init__.py
  • src/lpspec/relational/sinks/gurobi.py
  • src/lpspec/relational/sinks/highs.py
  • src/lpspec/relational/sinks/tables.py
  • tests/test_gurobi_sink.py
  • tests/test_solve_status.py

Comment thread docs/api.md Outdated
schema = lps.load_schema('model.yaml') # MathSchema

result = lps.solve('model.yaml', sources, solver_options={'time_limit': 60})
lps.solve('model.yaml', sources, solver_name='gurobi') # the other solver sink

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bind the Gurobi result in the example.

Line 20 stores the HiGHS result. Line 21 discards the Gurobi result. The following result.* expressions therefore still refer to HiGHS.

-lps.solve('model.yaml', sources, solver_name='gurobi')  # the other solver sink
+result = lps.solve('model.yaml', sources, solver_name='gurobi')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
lps.solve('model.yaml', sources, solver_name='gurobi') # the other solver sink
result = lps.solve('model.yaml', sources, solver_name='gurobi')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/api.md` at line 21, Update the `lps.solve` example on the Gurobi line to
assign its return value to the result variable, matching the preceding HiGHS
invocation so subsequent `result.*` expressions use the Gurobi result.

Comment thread src/lpspec/relational/sinks/__init__.py Outdated
Comment thread src/lpspec/relational/sinks/README.md Outdated
@FBumann FBumann changed the title feat(sinks): gurobi, and who gets to choose the solver feat(sinks)!: gurobi, and the two families it revealed Jul 31, 2026
FBumann and others added 4 commits July 31, 2026 18:32
A second solver sink reading the same ModelTables as HiGHS: dense column
vectors and CSR row blocks, no float→text→parse round trip. `solver_name`
at the call picks between them — a caller's choice, never a file's, since
no YAML key names a solver and a model means the same thing either way.

The column vectors move onto ModelTables so the two sinks cannot drift
into loading different models; the hand-off code stays separate, because
gurobipy takes a matrix object where HiGHS takes the three CSR arrays.
That is what the scipy in the [gurobi] extra buys.

The condition map is linopy's, minus three entries it gets wrong against
Gurobi's own status codes — declared, and pinned in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sink was a flat bag whose members happened to be two different shapes,
and the naming still said `solver_direct`: a mechanism, when the thing
that actually varies is which solver. So the taxonomy is now the tree.

A solver runs the tables and returns an answer, is chosen by name, and
lives in `solvers/<name>.py` — one module per solver, named for it, with
`solve_<name>`, a `build_<name>` seam and a line in `SOLVERS`. A writer
renders them to a file, is chosen by the output's suffix, and lives in
`writers/`. Both registries are closed dict literals, and a test reads
the shape off the path, so adding a solver is a module and a line with
nothing above it to teach.

`api.write` stops branching on suffix and asks the writer family, which
is also where "planned" now lives — `.mps` answers for itself.

Renamed rather than aliased, per CONTRIBUTING: `ex.write_lp(path)` is
`ex.write(path)`, the suffix picking the writer the way `lps.write`
already did, and `solve_direct` is `solve_highs` in
`sinks.solvers.highs`.
An alias kept "so an existing except keeps catching what it used to" is
a deprecation cycle, and CONTRIBUTING says this project does not run
one while it is 0.0.1aN. The engine's failures are LanguageError and
DataError, both under LpspecError; catch those.
The gurobi PR argued the same point in five places — the family split in
two module docstrings, the README, ARCHITECTURE and api.md; the caller-
chooses-the-solver rule in six. Reference pages carry rules, design notes
carry arguments (CLAUDE.md), so each now lives in one layer and the rest
point at it. No behaviour, no test coverage, ~120 lines of prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann force-pushed the feat/gurobi-sink branch from ec37479 to fb16628 Compare July 31, 2026 16:34
@FBumann FBumann changed the title feat(sinks)!: gurobi, and the two families it revealed feat(sinks): gurobi, and the two families it revealed Jul 31, 2026
FBumann and others added 3 commits July 31, 2026 18:41
Five review findings, verified against the tree:

- `solve_gurobi` disposes the model and its environment in a `finally`,
  so a licence token is held for the solve rather than until whatever
  frees the last reference. gurobipy has no `Model.getEnv()`, so `_load`
  hands the environment back. `build_gurobi` keeps its model — its
  caller owns it.
- `_duals` takes `row_count` and refuses a vector that does not cover
  every row. The read-back join is positional, so a short one would drop
  rows in silence.
- `dense_columns` says what it returns: three float vectors and a bool
  mask, not four `Any`.
- `api.solve` refuses an unknown solver *before* the build, for the
  reason `write` checks the suffix first.
- The closed-solver-set test moves to `test_api.py`, out of the module
  that skips without the extra — the set is the package's property, not
  gurobi's — and one reader now serves both linopy condition maps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A licence parameter — WLSAccessID, ComputeServer, TokenServer — can only
be set before an environment starts; `setParam` on the model refuses it
with "unable to modify parameter after environment started". Applying
options to the model therefore locked out every Compute-Server and WLS
user, which is the whole population that does not solve on a local node
licence.

Nothing else moves: an environment's parameters are the defaults of the
models built on it, an unknown name still raises at the same point, and
OutputFlag leads so a caller can put the log back by passing their own.

Checked against linopy, which reached the same place: it takes `env=` as
a dict of parameters for exactly this, and disposes model-before-env
while leaving a user-supplied environment alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`build_gurobi` hands the caller a model whose environment holds a
licence, and gurobipy has no `Model.getEnv()` — so the caller had no way
to release it and nothing was going to. A finalizer disposes the
environment when the model is collected, which under refcounting is when
the caller drops it: one thing to own rather than two, and no pair to
return.

That closes the licence story for both paths without a solver handle:
one-shot solves dispose in a `finally`, a handed-over model disposes on
drop. What still needs a handle is sharing one environment across many
solves — a token-server round trip per solve today — and warm starts.
Filed as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann

FBumann commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Licence/environment handling is now closed for both paths without a solver handle, after checking how linopy does it:

  • solve_gurobi disposes model then environment in a finally — linopy's order, and their comment says it is required for the license to be released even while user code still holds a solver_model reference.
  • build_gurobi hands the model over with a weakref.finalize disposing the environment when the model is dropped. gurobipy has no Model.getEnv(), so a caller given only the model could not release it otherwise; linopy's equivalent (a model detached from its Solver) leaves it to gurobipy to free whenever.
  • Solver options moved to Env(params=...): WLSAccessID / ComputeServer / TokenServer can only be set before an environment starts, so applying them to the model locked out every Compute-Server and WLS user.

What genuinely needs a handle — one environment shared across many solves (a token-server round trip per solve today) and warm starts / in-place re-solve — is filed as #424, with linopy's Solver as the reference shape.

@FBumann
FBumann merged commit 8730498 into main Jul 31, 2026
5 checks passed
@FBumann FBumann added feature New capability area:solver Solver sink, options, statuses, duals, value read-back area:file-out How a model leaves as text: LP/MPS output, formatting, stable bytes area:engine Lowering, IR, relational executor, the sink contract area:api Native entry points and CLI area:packaging Distribution, extras, dependency footprint labels Jul 31, 2026
FBumann added a commit that referenced this pull request Jul 31, 2026
#418 split `sinks/` into `solvers/` (highs, gurobi — chosen by name) and
`writers/` (lp_file — chosen by suffix), dropped `write_lp_file`,
`solve_direct` and `build_highs` from the package's exports, renamed
`write_lp` to `write`, and gave `ModelTables` a `dense_columns` projection.

The conflict is at the seam this branch exists to create. main added `write`
and `solve(solver_name=...)` to the polars executor; here they live on
`Engine`. So main's 47 lines of executor change become ~10 on the base class —
and **the duckdb engine gets gurobi and writer-by-suffix without a line of its
own**, which is the claim `engines/` makes, paying out on a feature written
after it and unaware of it:

    LPSPEC_ENGINE=duckdb
    solvers available: ['gurobi', 'highs']    writers: ['.lp']  planned: ['.mps']
    ex.write(path)  dispatches on the suffix; .mps names itself as planned
    ex.solve(solver_name='highs')  -> 3.0

`tables.py` took both sides: main's `dense_columns` and `DenseColumns`, and
this branch's `COLS`/`OBJ`/`ROWS`/`MATRIX`/`DTYPES`/`VTYPE`. They answer
different questions about the same contract and neither replaces the other.

Also followed rather than resurrected: `RelationalBuildError`, which main
deleted under #420's "breaking changes are free while we are 0.0.1aN", and
which the merge would otherwise have brought back without its import. And
`test_lp_text`'s second build — added by #404 on this branch — called
`write_lp`, the verb main had just renamed.

765 passed on polars, 764 passed / 5 skipped on duckdb, ruff + pyrefly (0
errors) + mkdocs --strict clean.
FBumann added a commit that referenced this pull request Jul 31, 2026
…ug made (#441)

* docs(bench): re-measure the published tables, and reverse a conclusion

The committed results were taken at `ef8558c` — version `0.0.0a27`, before the
package was even renamed. Eight perf changes have landed since (#408, #412,
#413, #414, #415, #434, #421, #433), so every table on the page understated the
engine, and one paragraph drew a conclusion the numbers no longer support.

Re-run in one sitting on an idle machine: the ladder, the density sweep and the
scaling run, so all three files describe the same tree. Parity gate passes on
all six cases (`fleet` to 4.6e-16) before anything is timed.

**What moved**, against the previously published numbers, `l` rung, our arm:
wall -10% to -39% on nine of twelve rungs, peak -2% to -21% on nine.

**What got worse, and is not buried.** `dispatch` build is 10-12% slower —
that is `maintain_order='left'` from #433, which buys -21% peak on the same
rung and is the trade that PR argued for. `fleet/highs` peak is up 8.2%,
outside its 1.0% floor, and I have no explanation for it.

**A conclusion is reversed.** `## Absurd sizes` read 1.07x peak at 120M
variables and concluded that our memory headroom "is a small-and-sparse-model
property, not a scaling one". Peak is now 0.73-0.83x across four orders of
magnitude. Two things moved it and only one is us: #433 took ~20% off this
case, and the earlier reading came from a run *that section itself flagged as
noisy* — it recorded linopy at 38.5 s and 106.2 s for the same rung. The old
number was a conclusion drawn across the noise floor. The section now says so
rather than quietly presenting a better one.

**`profiled` is no longer the case in the ladder we lose** (1.09x -> 0.74x
through the highs sink), and `fleet/lp` has crossed on wall (1.03x -> 0.90x).
Both paragraphs are rewritten to say what changed rather than to delete the
sentences that admitted the losses. Two numbers still go against us and are
still named: `profiled` 1.31x on wall through the LP sink, `fleet` 1.47x on peak.

Not touched: `## Sink capabilities` still says Gurobi's row wants a spike before
it is relied on. #418 shipped a real Gurobi sink since, so that caveat is
probably obsolete — but exercising it is not this change, and asserting
capabilities I have not run is how that table would come to lie.

* docs(bench): re-measure at 98f382d, and correct a claim the harness bug made

Every number on the page now comes from one run at `98f382d` on a clean tree,
and the page says so — the fingerprint hash is what makes a published number
traceable to the code that produced it, and the previous one recorded
`782f76b-dirty`, which identifies nothing.

**The `highs` comparison was not like for like, and it flattered us.** #438
found that linopy's `to_highspy()` was being timed *with* variable naming, which
neither of our solver sinks does — 82% of its hand-off, and memory as well as
time. Corrected:

    highs sink, l rung        was      now
    dispatch  peak           0.87x    1.46x
    fleet     peak           0.76x    1.10x
    profiled  peak           0.82x    1.27x
    transport peak           0.75x    1.10x

So the summary changes from "ahead on both, on all six" to **ahead on wall on
five of six, and behind on peak on four of six through `highs`**. HiGHS's own
dense model dominates both arms there and our COO triples are what is left. The
`lp` sink barely moved, because that comparison was already like for like, and
it is where the representation actually pays: ahead on peak on four of six.

`profiled` is the case in the ladder we lose, on both sinks (1.27x, 1.07x).
Said plainly rather than left to the tables.

The scaling section's reversal stands and is re-measured: peak 0.68-0.78x from
10k to 120M variables, against the 1.07x this page used to conclude the
advantage "runs out" from. That section now also says it is the LP sink, and
therefore not evidence for the `highs` peak row.
FBumann added a commit that referenced this pull request Sep 10, 2026
… the head of energy-models/mathspec#421 (#1565)

> **Prompt:** Lets check if lpspec needs to adjust anything after we
merge the stack below
energy-models/mathspec#421 — Do a Draft pr
pinning the head of #421

> [!NOTE]
> The following content was generated by AI.

The pin is now the release tag `v0.0.0-alpha.82`, which carries the
whole five-PR stack for math-spec's issue #401 (#415, #416, #418, #420,
#421 — the last squash commit, a2dcee7, is an ancestor of the tag). It
replaces the commit `37f8b5136f8433a56653c1954d7ffe770902dd40`, the head
of
[energy-models/mathspec#421](energy-models/mathspec#421),
that this PR pinned while no release contained the stack.

lpspec needs no code change. Nothing it imports from math-spec moved;
the one consumer-visible change is that math-spec's Markdown typesetter
now escapes a `description:` the way its LaTeX and Typst formats already
did (`_`, `[`, `]`, `*` set as characters, a name in backticks as a code
span). So this PR is:

- the pin, in `pyproject.toml` and the three math-spec lines of
`uv.lock`
- the regenerated pages (`tools.gallery_math`, `tools.ladder`): every
changed line is an escape such as `'bp\_x'` or `\[0, 1\]`
- two descriptions written plain, in `examples/ports/pypsa_cvar.yaml`
and `examples/ports/pypsa_losses.yaml`, because their `*and*` now sets
as literal asterisks (and never set as emphasis in LaTeX or Typst); the
same two lines inside the YAML block the gallery pages embed verbatim,
which no tool writes

Against the commit pin the full suite on `main` at 2928e08 was 3754
passed, 251 skipped, 1 xfailed, 2 failed, the two failures being the
stale generated pages this PR regenerates.

<details><summary>Verified at the tag</summary>

- `uv lock` resolves math-spec to `v0.0.0a82` (commit 140ec94e); locked
with uv 0.12.11 so the lock diff is the three math-spec lines only
(older uv versions rewrite marker annotations across the file)
- `uv sync --all-extras`
- `uv run python -m tools.gallery_math`, `uv run python -m
tools.ladder`, `uv run python -m tools.constructs`: no page changed
against the commit pin, so the tag's tree is the pinned commit's tree
for everything lpspec renders
- `uv run pytest -q -n auto tests/test_models_gallery.py
tests/test_pypsa_ladder_page.py tests/test_typeset_gallery.py
tests/test_docs_site.py tests/test_ports.py`: 612 passed, 14 skipped
(the typst binary, and the MILP ports that record no duals)
- `ruff check .`, `ruff format --check .`, `pyrefly check` on both
configs: clean
- Left alone on purpose: the `*and*` in the comment of
`examples/symbols/transport.yaml` and the bold sentence in the
hand-written prose of `docs/examples/pypsa_losses.md`, neither being a
description
- Not run here: the full suite under pixi, and the typst-backed typeset
tests

</details>

<details><summary>The next math-spec release needs a follow-up in
lpspec</summary>

math-spec's `main` already carries #427 (`refactor(program): a
variable's domain is called domain in the program`), which renames
`VariableDeclaration.variable_type` to `domain`. It is **not** in
alpha.82, so this pin is unaffected. lpspec reads `variable_type` in
`src/lpspec/linopy/builder.py` at three sites (two in the variable
attach, one in the discrete-variable listing before the solve), and the
next math-spec release will need a follow-up in lpspec that renames
those reads. Not done here.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019d4fAY3RikKY317uerZ2Hc
https://claude.ai/code/session_017tCiQ1MTt4jG8X3qNEGNvZ

---------

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

area:api Native entry points and CLI area:engine Lowering, IR, relational executor, the sink contract area:file-out How a model leaves as text: LP/MPS output, formatting, stable bytes area:packaging Distribution, extras, dependency footprint area:solver Solver sink, options, statuses, duals, value read-back feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant