Skip to content

perf(sinks): a row is seated the way a column is, for both solvers - #421

Merged
FBumann merged 3 commits into
mainfrom
perf/polars-highs-rows-by-position
Jul 31, 2026
Merged

FBumann merged 3 commits into
mainfrom
perf/polars-highs-rows-by-position

Conversation

@FBumann

@FBumann FBumann commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Rebuilt on top of #418 — the original version of this PR predated the
solvers//writers/ split and targeted sinks/highs.py. Rewriting it against
the new structure turned out better than porting it would have: the change
belongs in the shared contract, not in one solver.

What

dense_columns exists because col is dense 0..n-1 and so is the position
a value belongs at. row is dense in exactly the same way — and neither solver
was treating it that way:

# solvers/highs.py and solvers/gurobi.py, identically
ordered_rows = model.rows.sort('row')
ordered_matrix = model.matrix.sort('row')
for lo, hi in model.row_chunks_by_nonzeros(batch):
    rows = ordered_rows.filter(pl.col('row').is_between(lo, hi, closed='left')).select(...)
    a = ordered_matrix.filter(pl.col('row').is_between(lo, hi, closed='left'))

fleet/l read the same 6M rows nine times over to hand each of them over once.

So the row half joins the column half in tables.py, where its docstring
already argues it belongs — two sinks computing the same projection separately
could disagree about the model they loaded
:

ModelTables.dense_rows(infinity) (sense, rhs) over the solver's row index
ModelTables.row_blocks(budget) each chunk of rows with the entries it owns

dense_rows stops at the sense rather than at bounds because that is where
the two solvers part: HiGHS wants a row's lower/upper, Gurobi its comparison and
right-hand side, and both are that pair spelled differently. SENSE_CODES is
the spelling neither owns — it was already in gurobi.py, as a replace_strict
run once per chunk.

row_blocks orders the matrix once and locates each chunk by binary search on
the label column. Searched in polars, not through numpy: pulling the column
out to search it there is marginally faster and holds a second copy of it for
the whole loop — 0.11 GB at transport/l, the wrong trade in a pass that exists
to stay bounded. I measured that version first.

Numbers

Paired in-process, alternating, min of five, same frames:

case shape main this PR Δ
fleet/l 6.0M rows / 14.0M nz 0.722 s 0.644 s −10.8%
transport/l 1.4M rows / 12.6M nz 0.354 s 0.308 s −13.0%
nodal/l 1.0M rows / 3.0M nz 0.140 s 0.131 s −6.7%
sector/l 1.0M rows / 1.0M nz 0.058 s 0.054 s −6.9%
dispatch/l 0.1M rows / 10.0M nz 0.403 s 0.379 s −6.0%
fleet/m 0.063 s 0.059 s −6.9%

The gain tracks rows-per-nonzero, which is the shape of what was removed:
dispatch has 100 nonzeros per row and gains least.

Gurobi does not move. Worth stating rather than burying: its own addMVar /
addMConstr take 1.7 s where HiGHS takes 0.06 s for the same fleet/m
model, so the polars work removed here is invisible inside it (all five cases
land within ±1.3%, which is noise at that ratio). The sharing is justified by
the duplication, not by a second speedup — but Gurobi is strictly doing less
work than before, and if gurobipy ever stops dominating, it inherits the win.

Peak is unchanged on both. The dense row vectors replace a sorted copy of
the row frame; what's left is inside the run-to-run spread (transport/l mins
over four samples: main 2.95–3.10 GB, this PR 3.00–3.09 GB).

Test

test_a_row_with_no_terms_keeps_its_seat_at_any_chunking, parametrised over
SOLVERS × four batch sizes. A masked variable orphans a constraint row, this
lane keeps it, and 0 == 5 must stay infeasible. Rows are now handed over by
position, so a row that fell out of the frame would take a comparison nothing
can fail, leave the constraint unenforced, and the model would come back solved
against one that cannot be. Ragged batches because the range loop is where a
seat would be lost; a round number is the one split that hides an off-by-one.

751 tests pass, including the 23 Gurobi ones.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of constraint senses, right-hand sides, and empty constraint rows across supported solvers.
    • Preserved empty constraint rows consistently, regardless of processing batch size.
    • Improved reliability of infeasibility detection for models with masked variables.
  • Tests

    • Added regression coverage across multiple solver configurations and batch sizes.

@FBumann FBumann added area:engine Lowering, IR, relational executor, the sink contract perf Wall time, memory, or I/O cost — not a wrong answer engine:polars Specific to the polars engine — the default labels Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@FBumann, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a436e53-2462-44e6-afdc-df7bf509501a

📥 Commits

Reviewing files that changed from the base of the PR and between 1bd07f2 and eb54191.

📒 Files selected for processing (3)
  • src/lpspec/relational/sinks/solvers/gurobi.py
  • src/lpspec/relational/sinks/tables.py
  • tests/test_relational.py
📝 Walkthrough

Walkthrough

The change adds dense row projections and reusable row blocks to ModelTables. Gurobi and HiGHS use these structures for constraint loading. A parametrized regression test covers empty rows across solvers and batch sizes.

Changes

Dense row solver integration

Layer / File(s) Summary
Dense row data and matrix blocks
src/lpspec/relational/sinks/tables.py
ModelTables adds numeric sense codes, dense row senses and right-hand sides, and bounded matrix row blocks with offsets.
Solver constraint loading
src/lpspec/relational/sinks/solvers/gurobi.py, src/lpspec/relational/sinks/solvers/highs.py
Both solver sinks consume dense row data and row_blocks instead of sorting and filtering row tables during constraint loading.
Empty-row regression coverage
tests/test_relational.py
The parametrized test checks masked models with empty constraint rows across supported solvers and batch sizes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ModelTables
  participant GurobiSink
  participant HiGHSSink
  participant SolverAPI
  ModelTables->>GurobiSink: Provide dense_rows and row_blocks
  GurobiSink->>SolverAPI: Add CSR constraint blocks
  ModelTables->>HiGHSSink: Provide dense_rows and row_blocks
  HiGHSSink->>SolverAPI: Add row bounds and matrix slices
Loading

Possibly related PRs

  • FBumann/lpspec#418: Refactors the shared ModelTables row projections and row_blocks consumed by both solver sinks.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% 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 identifies the performance change for row handling and correctly indicates that it applies to both solvers.
✨ 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 perf/polars-highs-rows-by-position

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 #33857321 | 📁 Comparing eb54191 against latest (8197084)

  🔍 Preview build  

6 files changed · ± 6 modified

± Modified

@FBumann
FBumann marked this pull request as draft July 31, 2026 16:32
@FBumann

FBumann commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Parked as draft — #418 moves sinks/highs.py to sinks/solvers/highs.py, so
this cannot rebase mechanically and should land after it.

Worth noting for whoever picks it up: the Gurobi sink has the same pattern,
so after #418 this belongs in the shared layer rather than in one solver.

# sinks/solvers/gurobi.py:162
ordered_rows = model.rows.sort('row')
ordered_matrix = model.matrix.sort('row')
for lo, hi in model.row_chunks_by_nonzeros(batch):
    rows = ordered_rows.filter(pl.col('row').is_between(lo, hi, closed='left')).select(...)
    a = ordered_matrix.filter(pl.col('row').is_between(lo, hi, closed='left'))

Both solvers sort the row frame and then scan it once per chunk to find a range
that row being dense 0..n-1 already locates. The measurement and the
argument in the description carry over unchanged; only the file it lands in
does not.

_by_label — the dense seating — is the piece both want. Where it lives is
#418's call, since #418 is the PR that decides what a solver shares.

`dense_columns` exists because `col` is dense `0..n-1` and so *is* the position
a value belongs at. `row` is dense in exactly the same way, and neither solver
was treating it that way: both sorted the row frame and then filtered it once
per chunk, so `fleet/l` read the same 6M rows nine times over to hand each of
them over once.

So the row half joins the column half in the contract, where tables.py already
argues it belongs: two sinks computing the same projection separately could
disagree about the model they loaded.

  ModelTables.dense_rows(infinity) -> (sense, rhs) over the solver's row index
  ModelTables.row_blocks(budget)   -> each chunk of rows with the entries it owns

`dense_rows` stops at the sense rather than at bounds because that is where the
two solvers part — HiGHS wants a row's lower/upper, Gurobi its comparison and
right-hand side, and both are that pair spelled differently. `SENSE_CODES` is
the spelling neither owns; it was already inside gurobi.py as a per-chunk
`replace_strict`.

`row_blocks` orders the matrix once and locates each chunk by binary search on
the label column. Searched in polars rather than through numpy: pulling the
column out to search it there is marginally faster and holds a second copy of
it for the whole loop, 0.11 GB at `transport/l`, which is the wrong trade in a
pass that exists to stay bounded.

Paired in-process, alternating, min of five, same frames:

    highs   fleet/l      6.0M rows / 14.0M nz   0.722s -> 0.644s  -10.8%
            transport/l  1.4M rows / 12.6M nz   0.354s -> 0.308s  -13.0%
            nodal/l      1.0M rows /  3.0M nz   0.140s -> 0.131s   -6.7%
            sector/l     1.0M rows /  1.0M nz   0.058s -> 0.054s   -6.9%
            dispatch/l   0.1M rows / 10.0M nz   0.403s -> 0.379s   -6.0%
            fleet/m                             0.063s -> 0.059s   -6.9%

**Gurobi does not move**, and that is worth stating rather than hiding: its own
`addMVar`/`addMConstr` take 1.7 s where HiGHS takes 0.06 s for the same model,
so the polars work this removes is invisible inside it. The sharing is
justified by the duplication, not by a second speedup.

Peak is unchanged on both — the dense row vectors replace a sorted copy of the
row frame, and what is left is inside the run-to-run spread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann force-pushed the perf/polars-highs-rows-by-position branch from 7566848 to 1bd07f2 Compare July 31, 2026 17:17
@FBumann FBumann changed the title perf(polars): the HiGHS sink seats a row the way it seats a column perf(sinks): a row is seated the way a column is, for both solvers Jul 31, 2026
@FBumann
FBumann marked this pull request as ready for review July 31, 2026 17:17

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

🧹 Nitpick comments (1)
src/lpspec/relational/sinks/solvers/gurobi.py (1)

164-173: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Derive Gurobi operator positions from SENSE_CODES.

Line 166 duplicates the numeric order from src/lpspec/relational/sinks/tables.py. If SENSE_CODES changes, Gurobi can apply an incorrect operator without an error. Import SENSE_CODES and build the operator lookup from its values.

🤖 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/solvers/gurobi.py` around lines 164 - 173, Update
the Gurobi solver’s operator lookup near the `spelling` definition by importing
and using `SENSE_CODES` from the relational sinks tables module, deriving the
array positions from its values instead of hardcoding the LESS_EQUAL,
GREATER_EQUAL, EQUAL order. Keep the existing `sense[...]` indexing and
`addMConstr` flow unchanged.
🤖 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.

Nitpick comments:
In `@src/lpspec/relational/sinks/solvers/gurobi.py`:
- Around line 164-173: Update the Gurobi solver’s operator lookup near the
`spelling` definition by importing and using `SENSE_CODES` from the relational
sinks tables module, deriving the array positions from its values instead of
hardcoding the LESS_EQUAL, GREATER_EQUAL, EQUAL order. Keep the existing
`sense[...]` indexing and `addMConstr` flow unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e01bb86f-c32b-4f1e-82f0-fed33427b59d

📥 Commits

Reviewing files that changed from the base of the PR and between 393a910 and 1bd07f2.

📒 Files selected for processing (4)
  • src/lpspec/relational/sinks/solvers/gurobi.py
  • src/lpspec/relational/sinks/solvers/highs.py
  • src/lpspec/relational/sinks/tables.py
  • tests/test_relational.py

The array `addMConstr` indexes was written out in `SENSE_CODES`' order, so the
two had to be kept in step by hand. Nothing checks that: a wrong order is a
model whose comparisons are silently permuted, and every solver would answer it
confidently.

Built from the mapping instead, keyed by name. A sense added to `SENSE_CODES`
and not to `_GUROBI_SENSE` raises where it is built rather than mislabelling a
row.

Raised by CodeRabbit on #421.

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

FBumann commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Line 166 duplicates the numeric order from SENSE_CODES. If SENSE_CODES changes, Gurobi can apply an incorrect operator without an error.

Good catch — fixed in 0a30684. It is the failure mode this repo argues against
elsewhere (_edge: "the two are exact complements, so they are one filter
negated rather than two conditions to keep in step"
), and I had noticed the
coupling enough to write a comment about it, which is the wrong answer to
noticing.

The table is now built from the mapping rather than retyped in its order:

_GUROBI_SENSE = {'<=': 'LESS_EQUAL', '>=': 'GREATER_EQUAL', '==': 'EQUAL'}

def _spelled(gurobipy):
    spelling = np.empty(len(SENSE_CODES), dtype='<U1')
    for sense, code in SENSE_CODES.items():
        spelling[code] = getattr(gurobipy.GRB, _GUROBI_SENSE[sense])
    return spelling

Order-independent, and a sense added to SENSE_CODES without a Gurobi spelling
raises where the table is built rather than mislabelling a row. Attribute
names rather than values because gurobipy is an optional import and this is
module level.

#434 landed while this branch was open, and the two changes disagree about the
same function for good reasons on both sides. #434 measured that Gurobi's
`addMConstr` charges ~42 ns per *model column* per call whatever the block
holds — 0.23 s in one call against 0.89 s in forty — and stopped batching it.
This branch moves both solvers onto a shared `row_blocks` so they cannot come
to disagree about the model they load.

Resolved by putting #434's finding *into* the shared contract rather than
around it: `row_blocks(budget=None)` yields one block, and its docstring says
that whether splitting pays is a property of the API being fed and not of the
model — HiGHS's budget bounds a temporary, Gurobi's per-call cost scales with
the model's width. Both answers now come out of the same code, and `gurobi.py`
passes `batch_rows` straight through un-defaulted.

    row_blocks(None) -> 1 block      addMConstr calls: 1
    row_blocks(5)    -> 4 blocks     addMConstr calls: 4
    batch_rows=1                     addMConstr calls: 20
    objective 190.0 in every case

Kept from this branch over main's: the Gurobi sense spelling derived from
`SENSE_CODES` rather than written out beside it. main's version is a literal
array in an order that has to match by hand, and a wrong order there is a model
whose comparisons are silently permuted — which every solver answers
confidently.

752 passed, 23 of them Gurobi's, ruff + pyrefly clean.
@FBumann
FBumann enabled auto-merge (squash) July 31, 2026 17:59
@FBumann
FBumann merged commit 31ba48c into main Jul 31, 2026
4 checks passed
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>
fluxopt-release-bot Bot added a commit that referenced this pull request Sep 10, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.0.1-alpha.317](v0.0.1-alpha.316...v0.0.1-alpha.317)
(2026-09-10)


### Documentation

* descriptions set as prose under math-spec's escaping, pinned to the
head of
energy-models/math-spec[#421](#421)
([#1565](#1565))
([5c6b606](5c6b606))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: fluxopt-release-bot[bot] <307443024+fluxopt-release-bot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:engine Lowering, IR, relational executor, the sink contract engine:polars Specific to the polars engine — the default perf Wall time, memory, or I/O cost — not a wrong answer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant