Skip to content

feat!: rebuild the relational engine on polars - #189

Merged
FBumann merged 36 commits into
mainfrom
polars-engine
Jul 28, 2026
Merged

feat!: rebuild the relational engine on polars#189
FBumann merged 36 commits into
mainfrom
polars-engine

Conversation

@FBumann

@FBumann FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Replaces duckdb with polars as the engine the relational lane runs on.

The decision this PR is asking for

polars — faster, and code you can read — or duckdb, which lets you name a memory ceiling and holds it.

That is the whole of it. Everything below is evidence, and both sides are real:

polars duckdb
Speed 2.6-5.2x faster, every case, at every size, to a loaded solver
Memory, engine to engine 8.30 GB at 120M variables 5.28 GB — ~1.6x lighter (1.29-1.49x at l)
Memory, with a budget no such knob 2.11 GB at a 1 GB ceiling, for ~no wall time
The code polars expressions; the lane is Python hand-assembled SQL strings, plus a sql.py to quote them
Dependencies polars duckdb + pyarrow
Ceiling bounded by RAM bounded by disk — if the budget fits the shape

What tips it, for me: the sink most callers use is the solver hand-off, and there this lane is ahead on peak on all six cases and on wall on five of six. duckdb's remaining memory advantage is real but smaller than it first looks, and it is bought with a knob most callers will never set.

What should give you pause: duckdb's budget is close to free. On dispatch a 1 GB ceiling costs it 0.99x, 0.89x and 1.08x of wall time at the top three rungs while saving up to 2.5x of peak. That is a genuinely good property and this PR gives it up: ROADMAP.md Track 5 becomes partition-wise emission, a promise this branch has not yet kept.

A correction worth reading before the tables. Every duckdb number this PR published before today was measured at that engine's 1 GB default, unstated. Re-run at its own unlimited, two things changed: duckdb needs more memory than it appeared to (5.28 GB, not 2.11, at 120M), and the budget turned out not to be the wall-time handicap I assumed. The arm now runs at both settings because a single number is whichever one the reader assumed.

The language does not move either way. plan.py is frozen engine-agnostic dataclasses, which is what made this a rewrite of one module rather than of the project — and what makes the decision reversible if it is wrong.

Scale, to 120M variables

dispatch, LP sink, best of two, on a 25 GB machine. Charts:
docs/benchmarks-scaling.html.

variables polars linopy duckdb duckdb@1GB
1M 0.13 s / 0.47 GB 0.34 s / 0.56 GB 0.42 s / 0.30 GB 0.40 s / 0.31 GB
10M 1.12 s / 2.04 GB 1.54 s / 2.13 GB 2.87 s / 0.76 GB 2.83 s / 0.74 GB
40M 5.99 s / 5.98 GB 8.80 s / 7.92 GB 22.97 s / 2.00 GB 20.40 s / 1.38 GB
120M 35.4 s / 8.30 GB 106.2 s / 7.77 GB 74.6 s / 5.28 GB 80.9 s / 2.11 GB

Nothing falls over, and polars is fastest at every rung. The 2xl row is
noisy between runs (polars has read 20.6 s and 35.4 s; linopy 38.5 s and
106.2 s) because 8-10 GB of peak on a 25 GB machine is where other things
start to matter — read the ordering there, not the seconds.

The budget is a real ceiling and it can be too low. On fleet — twelve
variable declarations rather than one — duckdb at 1 GB fails outright at the
xl rung, and builds in 26.4 s inside 2.40 GB when given 8 GB. No amount of
spilling fixes a budget below what the shape needs.

What it costs and buys, measured

Three engines in one run, on the current branch, against the linopy this
branch pins — the v1-semantics build from PyPSA/linopy#717, not the released
0.9.0. The duckdb arm runs main's own code from its own checkout and
interpreter, interleaved against one shared parquet cache. The parity gate
spans all three and they agree exactly on every case they share, which is
also what shows this branch's #234 and main's #239 build the same model.

Six cases, two sinks, four arms — duckdb runs at its own unlimited and
at its 1 GB default, because the two are different claims. Best of three, on
an idle machine. 655 timings, no failures.

arm engine commit
farkas polars engine, polars 1.43.0 ef8558c
linopy 0.8.0.post1.dev140+g346943317 (v1 semantics) shim at the same commit
duckdb duckdb 1.5.5 4a13d38 on main

Build → a loaded solver

The path a caller takes to get an answer, and what solver_direct exists for.
l rung, build + hand-off, run() never called:

l rung polars: build + hand-off linopy duckdb wall peak
dispatch 10M 0.15 + 0.54 = 0.69 s 0.28 + 1.65 = 1.93 s 1.62 + 1.12 = 2.77 s 0.36x 0.95x
fleet 12M 0.63 + 0.85 = 1.48 s 0.42 + 2.99 = 3.40 s 3.97 + 1.83 = 5.86 s 0.44x 0.84x
nodal 12M 0.24 + 0.25 = 0.49 s 0.47 + 0.87 = 1.34 s 1.82 + 0.68 = 2.58 s 0.37x 0.83x
sector 17M 0.26 + 0.12 = 0.38 s 0.65 + 0.59 = 1.25 s 1.02 + 0.21 = 1.40 s 0.30x 0.32x
transport 9.8M 0.88 + 0.67 = 1.55 s 0.72 + 1.89 = 2.61 s 2.58 + 1.50 = 4.09 s 0.59x 0.80x
profiled 12M 1.71 + 1.13 = 2.84 s 0.40 + 2.21 = 2.61 s 5.82 + 2.23 = 8.09 s 1.09x 0.88x

Ahead of linopy on peak on all six and on wall on five — 1.7-3.3x faster
to a solver-ready model on those five. profiled is level at 1.09x, and it is
the case in the ladder to be lost: a parameter dense over the whole variable
product is the array xarray already wants and a full-size join for us. It is
also the noisiest case here, its own repeats spanning 2.33-2.84 s at ~4 GB
resident, so read it as level rather than as either result.

fleet is new and immediately earned its place — twelve variable
declarations rather than one, which is the shape a real unit-commitment model
has. It is 0.44x here and the worst peak ratio in the set through the LP
sink (1.64x), which none of the single-declaration cases showed.

Both phases are shown because only their sum is comparable: linopy defers
~82% of its work to whatever consumes the model, so its build column is a
placeholder where ours is a finished matrix. Read either alone and it flatters
whichever lane defers more.

Against the engine this replaces

duckdb here is that arm unbounded, not at its 1 GB default — see the
correction above.

l rung lp sink highs sink
dispatch 2.5x faster / 2.6x lighter 4.0x faster / 1.49x lighter
fleet 2.9x faster / 2.8x lighter 4.0x faster / 1.42x lighter
nodal 3.1x faster / 2.3x lighter 5.2x faster / 1.47x lighter
profiled 1.7x faster / 1.6x lighter 2.8x faster / 1.29x lighter
sector 2.4x faster / 2.3x lighter 3.7x faster / 1.40x lighter
transport 1.9x faster / 1.9x lighter 2.6x faster / 1.29x lighter

polars is 1.7-5.2x faster than duckdb on every case and both sinks; duckdb
is 1.29-2.8x lighter.
That is the trade this PR makes, and neither half is
marginal. The memory half is much smaller on the sink most callers use, since
HiGHS's own dense model is resident either way — 1.29-1.49x there against
1.6-2.8x on the LP path.

And plainly, now the table shows it: duckdb is slower than the eager lane
from the m rung up.
The streaming engine's advantage was never wall time.

The LP-file path

The secondary route — a file for another tool rather than an answer.

l rung polars linopy duckdb wall peak
dispatch 10M 1.11 s / 2.02 GB 1.55 s / 2.25 GB 2.78 s / 0.78 GB 0.72x 0.90x
nodal 12M 0.63 s / 1.19 GB 0.84 s / 1.46 GB 1.93 s / 0.53 GB 0.74x 0.82x
sector 17M 0.62 s / 0.93 GB 1.14 s / 2.91 GB 1.52 s / 0.40 GB 0.55x 0.32x
transport 9.8M 2.37 s / 2.20 GB 2.49 s / 1.85 GB 4.55 s / 1.16 GB 0.95x 1.19x
fleet 12M 1.99 s / 2.69 GB 1.93 s / 1.65 GB 5.72 s / 0.96 GB 1.03x 1.64x
profiled 12M 4.48 s / 2.77 GB 3.06 s / 3.07 GB 7.64 s / 1.77 GB 1.46x 0.90x

This is where the losses are. profiled 1.46x and fleet 1.03x on wall;
fleet 1.64x and transport 1.19x on peak. transport was 1.69x until the
constraint section stopped sorting a whole model's worth of rendered text at
once (#245).

What is left on this route is structural in two ways. Most of an LP write is
turning doubles into text, work neither lane can avoid, so the wall ratio
compresses toward 1.00 however fast the build gets. And COO carries a
(row, col, coeff) triple per nonzero where a dense array carries one float,
which is fleet's 1.64x — the many-declaration shape is the one that shows it,
which is why that case is in the ladder now. profiled is the shape the eager
lane handles best — a parameter dense over the whole variable product is the
array xarray already wants, and a full-size join for us — and it too is in the
ladder to be lost.

Two costs, and both are real

The harness spawns one process per measurement, so every number above is a
first build. That is right for a caller who builds one model and solves it,
and wrong for a rolling horizon. Measured both ways to the same end point —
build and hand-off, because a build-only comparison would measure our
finished matrix against linopy's placeholder:

warm-up, once per process
this lane ~4-10 ms
duckdb ~21 ms
eager ~180 ms

138 ms of the eager lane's 180 is linopy and xarray's own first-call
machinery, measured with no farkas anywhere; our shim adds a constant ~2.3 ms.
At xs a first build reads 0.05-0.10x of linopy and steady reads ~0.4x — both
true, answering different questions.

What is in the number

Counted, every arm reading the parquet · building the model · assembling the matrix · the sink
Not counted, every arm import · the simplex — the hand-off stops before run() · reading the solution back
Only one arm pays farkas: routing paths to parameters vs dimensions sits outside the clock; it parses the YAML and reads no data. linopy and duckdb: none.
Known tilt toward us the eager arm is our shim, a constant ~2.3 ms over hand-written linopy · peak carries each lane's import footprint

sector postdates the duckdb checkout, so it has no duckdb column rather than
a lost one.

What it deletes

  • relational/sql.py and the quoting it existed for, plus the [A-Za-z_][A-Za-z0-9_]* restriction on declared names. This is a simplification, not a capability — finishing the quoting pass would lift the same restriction on duckdb.
  • The memory budget and the ceiling it enforced. Chunking itself came back
    with main (perf(sink): size the hand-off by nonzeros, under one budget and one chunking rule #195): chunking.py is a budget over the width of one unit,
    which is engine-agnostic, and the hand-off spends it. The budget here is
    1e5 elements, not the 2e6 main settled on — on duckdb a wider chunk was
    worth a third of the hand-off because each one re-ran an ordered query; here
    the columns are numpy slices, so 2e6 buys 0.09 s and costs 0.6 GB of peak.
  • The lifetime apparatus: workdir, tempdir, weakref.finalize. close() and with remain but nothing breaks without them.
  • Two dependencies: duckdb and pyarrow.

What it gives up

A settable memory ceiling, and models that exceed RAM. Hard rule 4 is rewritten from "peak is a function of the configured budget" to "peak tracks the model", and ROADMAP Track 5 becomes the memory axis with partition-wise emission named as the way to get a bound back without a database.

Whether that matters is a positioning question, not an engineering one — see the thread. The short version: build peak is ~10% of build-and-solve, so the ceiling pays on the write path and little else.

Riding along, and probably should not be

The terminal-aggregate elision (TermFragment.keyed, label_dims, _needs_aggregate) is new here and is not part of the swap. It is worth ~30% of build peak and belongs to #161. It has had two bugs during this PR — an inverted predicate, then group_sum over a broadcast dim producing duplicate (row, col) entries — and the differential suite caught neither, because transport has three term fragments and so forces the aggregate regardless. Both are fixed and covered now, but I would split it out and land it against #161 on its own evidence. Say the word.

Also in here

  • sol.dual ported from feat: expose duals on the solve path (sol.dual) #156, with its differential test.
  • A sector bench case — dense snapshots and carriers crossed with an 8% node/tech portfolio, which is where the sparsity claim is actually visible (0.42x peak against linopy).
  • Positional labels ported from perf(relational): a label is a position, so compute it instead of counting it #186, which the rewrite had dropped: transport's build went 2.57 s → 0.87 s. Engine-independent, and the largest single win measured here.
  • Deterministic LP output (LP output is not byte-reproducible: the file sinks emit unordered COPYs #109), which this PR claimed before it was true.
    The constraint section came out of a join, and a join hands back a group in
    whatever order it finished it — two writes of one model gave different bytes.
    The sink now emits one sorted stream of (row, ord, line). There is a test,
    and it fails on the old sink at 60 variables. Emit moved with it: -57% on
    sector, -40% nodal, -25% transport, and +13% on dispatch — the one
    case with few rows and many terms each, where the old group-by had little to
    group and the new sort has everything to sort. That regression is gone: perf(lp): write each section once, and build each line once #203
    took another 30–35% off all four, and dispatch now emits faster than it did
    before either change. Peak falls on all four.
  • A scatter hand-off: col is dense 0..n-1, so the sink writes each value
    at its own position instead of joining obj on and sorting by col. The
    hand-off is 2–5x faster (dispatch 1.02 s → 0.55 s); peak barely moves,
    because HiGHS's own copy dominates once the model is loaded.
  • A HiGHS load-status check: addCols/addRows were unchecked on main too, so a refused batch produced a confident answer to an unconstrained model.

Found on main while doing this, filed separately

#197 (objective terms with disjoint dims diverge between lanes), #198 (a second objective equations: entry is silently dropped), #199 (fk.build into a used workdir raises a raw duckdb exception). None caused or fixed here.

Open questions before merge

  1. Split the aggregate elision out?
  2. primal() returns polars.DataFrame. Should it return pandas, given the audience? Decoupled from the engine — one line either way.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Polars-based relational build/solve path.
    • Results now return labeled primal/dual as Polars DataFrames, with bridges to pandas/xarray/datasets and Parquet export.
    • Added a new sector benchmark case and expanded benchmark ladder coverage.
  • Documentation
    • Updated architecture, API examples, walkthrough, roadmap, and benchmark docs to match the frame-based workflow.
  • Bug Fixes
    • Improved label alignment, where/row masking, bounds propagation (including infinities), and correct aggregation when variables repeat in objectives.
    • Improved LP text determinism and clarified solve/result and dual behavior.
  • Chores
    • Streamlined benchmark harness/config and refreshed dependency metadata toward Polars.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR migrates relational execution from DuckDB/SQL tables to Polars lazy frames, rewrites model assembly and solver sinks for Polars and direct HiGHS execution, updates result readers and dependencies, adds a sector benchmark case, and revises documentation and tests.

Changes

Polars relational execution

Layer / File(s) Summary
Frame input boundary and public API
src/farkas/api.py, src/farkas/relational/frames.py, src/farkas/sources.py, pyproject.toml
Sources and results use Polars frames, while pandas, Arrow, and xarray remain optional bridges.
Lazy plan compiler
src/farkas/relational/compiler.py, tests/test_compiler.py
SQL string compilation is replaced by Polars lazy-frame fragments, joins, predicates, bounds, and shape transformations.
Model assembly and sinks
src/farkas/relational/executor.py, src/farkas/relational/sinks/*
The executor builds Polars model frames, LP output is streamed by sections, and HiGHS receives column and row batches directly.
Validation and regression coverage
tests/*
Tests migrate to Polars execution and cover result bridges, labels, aggregation, LP formatting, duals, lifecycle, and optional dependencies.

Benchmark and documentation updates

Layer / File(s) Summary
Benchmark harness and cases
bench/*, bench/models/sector.yaml, bench/results/latest.jsonl
Memory-budget sweeps are removed, benchmark records use case-size-arm keys, and a sector-coupled case is added.
Architecture and usage documentation
ARCHITECTURE.md, README.md, SPEC.md, ROADMAP.md, docs/benchmarks.md
Documentation describes the Polars relational lane, revised result APIs, memory measurements, and updated benchmark methodology.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • FBumann/farkas#54 — Earlier walkthrough and golden-test changes are extended by the Polars walkthrough migration.
  • FBumann/farkas#90 — The doc-example validation wiring is updated for PolarsExecutor.
  • FBumann/farkas#148 — The solve-status and direct-result plumbing overlaps with the Polars executor and sink changes.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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 accurately summarizes the main change: rebuilding the relational engine on Polars instead of DuckDB.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch polars-engine

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.

@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Correction: the headline was measured at the wrong point

Pushed 9a70dd6. The build-only tables stop at an LP file — one of two sinks, the one fewer callers use, and also linopy's worst path. Measured to where a caller actually stands (Highs.run stubbed, so the simplex workspace is out and HiGHS's own model is in):

build + handoff handoff time
dispatch, 10M
farkas 2.12 GB 3.41 GB 2.81 s
linopy direct 0.75 GB 3.38 GB 2.92 s
nodal, 3M @ 25%
farkas 1.03 GB 1.55 GB 0.61 s
linopy direct 1.04 GB 2.01 GB 1.26 s
transport, 9.8M
farkas 2.85 GB 4.18 GB 2.60 s
linopy direct 1.06 GB 4.04 GB 2.31 s

A 2.8x build-memory deficit on dispatch is a dead heat once the model reaches the solver. 2.7x on transport is 3%. And on nodal — sparse, the shape real multi-node models have — we win outright, 1.55 GB against 2.01 GB in half the time. HiGHS's own model is the larger term on both sides.

Also worth knowing: linopy's LP path is not its cost. The same model through io_api='lp' peaks at 6.92 GB and 55 s against 3.38 GB and 2.9 s direct.

And against the duckdb engine, on the handoff

dispatch 10M build + handoff handoff time
polars 2.12 GB 3.41 GB 2.81 s
duckdb 0.80 GB 2.62 GB 22.44 s

~8x faster handoff. duckdb still wins on peak, but its solver_direct path was doing per-chunk SQL with ORDER BY per batch, and that dominated.

Measured headroom still on the table

Frames total 0.74 GB at 10M variables; build peaks at 2.12 GB. Most of the difference is the terminal group_by('row','col').agg(sum). Removing it (unsafe as-is — it exists to collapse duplicates):

with without
dispatch build 2.12 GB / 1.82 s 1.68 GB / 0.59 s
transport build 2.85 GB / 2.41 s 2.16 GB / 0.85 s

~25% peak and ~3x build time, for an aggregate that is a no-op in the common case. It is safe to skip when there is exactly one term fragment and no parameter table has duplicate rows for a coordinate — and the second condition is cheap to check (parameter tables are thousands of rows against a 10⁷-row matrix), which also turns a silent data bug into a named error.

That is the same shape of argument as #179 does for the objective, and it deserves the same care. Say the word and I will land it here or as a follow-up.

@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Landed the optimization — b6e1772

TermFragment now carries keyed, and each operator states what it does to it, rather than the assembly guessing. The per-operator argument is short: a term stays keyed through Sum because the rows it merges came from distinct coordinates and keep distinct var_label; GroupSum and Translate join a dim table one-to-one. A constant part is the exception — dropping a dim from it genuinely merges rows — and a product inherits the weaker factor.

Then the assembly skips its group_by('row','col').agg(sum) when there is one keyed fragment, because no column can appear twice.

What it bought

l rung, LP path before after
dispatch peak 3.06 GB (1.40x linopy) 2.28 GB (1.06x)
dispatch build 2.12 GB / 1.82 s 1.44 GB / 0.59 s
transport peak 4.05 GB 3.92 GB
nodal peak 1.99 GB 1.88 GB

And at the handoff, which is the number that matters:

build + handoff farkas linopy direct
dispatch 10M 3.13 GB 3.38 GB
nodal 3M @ 25% 1.50 GB / 0.89 s 2.01 GB / 1.26 s
transport 9.8M 3.71 GB 4.04 GB

Ahead on all three now. transport still pays full price for the aggregate — three group_sum fragments land on every row, so _needs_aggregate is true and correctly so. That is the obvious next thing to look at.

The premise it rests on, which is worth having anyway

The skip is only sound if a parameter is keyed by its dims, so _check_one_row_per_coordinate now refuses a source carrying a coordinate twice. Two rows for one coordinate has no defined meaning, the eager lane refuses to lay such a source out at all, and the relational lane was silently resolving it into a sum — an actual divergence between two lanes that are supposed to accept the same thing. It costs one pass over the source, which is orders of magnitude smaller than the matrix it saves aggregating.

One thing to flag

My first draft had the condition inverted — it skipped the aggregate exactly when it was needed. The existing 338 tests did not catch it, because none of them writes a variable twice in one row. The two tests I added (x + 2 * x in a constraint, and in an objective) did. That gap is worth knowing about independently of this change.

@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

⚠️ Baseline correction: what these numbers were measured against

Every duckdb number in this PR and its comments was measured against v0.0.0-alpha.18 (398230b) — the branch base. Main is now v0.0.0-alpha.24 (5587b1f), and six commits landed in between, five of them performance work on the engine this PR replaces:

271b0d7 fix: the HiGHS sink's column ingest was an unbounded global sort (#181)
a31645b perf(relational): a label is a position, so compute it instead of counting it (#186)
b1df538 perf(lp): render doubles with ::VARCHAR instead of printf('%.17g') (#190)
787b1ce perf: skip the objective's GROUP BY where a column cannot repeat (#179)
ac64f26 perf(sink): hand Arrow to numpy directly, not through to_pydict (#193)
284df79 feat: expose duals on the solve path (sol.dual) (#156)

Claims that are now void

"~8x faster handoff than duckdb" (2.81 s vs 22.44 s) — treat as withdrawn. 271b0d7 names precisely the bug I attributed that gap to. Until re-measured at alpha.24 it should not be relied on.

a31645b attacks label assignment, which was the other build-side cost I measured; 787b1ce is the same idea my keyed mechanism generalises. Every polars-vs-duckdb comparison here needs re-running.

Claims that still stand

Everything measured against linopy 0.9.0, which is identical in both runs:

  • below ~1M variables, 2–3x faster and lower peak on all three cases;
  • at the handoff (Highs.run stubbed): 3.13 vs 3.38 GB on dispatch, 1.50 vs 2.01 GB on nodal, 3.71 vs 4.04 GB on transport — ahead on all three;
  • io_api='lp' costs linopy 6.92 GB / 55 s against 3.38 GB / 2.9 s direct.

Blocking work before this is mergeable

  1. Rebase onto v0.0.0-alpha.24. Not mechanical — the duckdb engine gained ~800 lines in executor.py / sinks/ that this branch deletes wholesale.
  2. sol.dual is missing. 284df79 shipped duals on the solve path; the polars engine has no equivalent, so merging as-is is a feature regression.
  3. Drop e544400 and 653ef88. e92ba90 fixed the bench parity gate upstream, so those two are now redundant and will conflict.
  4. Re-measure duckdb at alpha.24 and rewrite docs/benchmarks.md.

Until 1–4 are done this is a proposal with a stale baseline, not a merge candidate. Flagging rather than quietly rebasing, because (2) is a scope question and (4) may change the conclusion.

@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Re-measured against v0.0.0-alpha.24 — the conclusion changes

Same machine, same session, l rung of each case. duckdb built from the tag (5587b1f), polars from this branch head.

Build + LP file

duckdb @ alpha.24 polars (this branch) linopy
dispatch 10M 7.38 s / 0.88 GB 3.87 s / 2.28 GB 3.02 s / 2.15 GB
nodal 3M @ 25% 3.69 s / 0.59 GB 1.99 s / 1.88 GB 1.35 s / 1.57 GB
transport 9.8M 6.31 s / 1.16 GB 5.49 s / 3.92 GB 3.46 s / 1.89 GB

Build + COO handoff to HiGHS (run stubbed)

duckdb @ alpha.24 polars linopy direct
dispatch 10M 2.00 GB / 3.72 s 3.13 GB / 3.55 s 3.38 GB / 2.92 s
nodal 3M @ 25% 0.75 GB / 2.36 s 1.50 GB / 0.89 s 2.01 GB / 1.26 s
transport 9.8M 1.90 GB / 4.02 s 3.71 GB / 2.69 s 4.04 GB / 2.31 s

What this actually says

The "duckdb is slow" case is gone. 271b0d7 did fix the unbounded sort: the dispatch handoff went 22.44 s → 3.72 s, against polars' 3.55 s. That is level, not 8x. I withdraw that claim in full.

The trade at alpha.24 is: polars ~1.5–2x faster, duckdb ~2–3x lighter.

  • Speed, polars: 1.9x on dispatch LP, 1.9x on nodal LP, 1.15x on transport LP; at the handoff, level on dispatch, 2.6x on nodal, 1.5x on transport.
  • Memory, duckdb: 2.6x less on dispatch LP, 3.2x on nodal, 3.4x on transport; 1.6–2x less at the handoff.

And duckdb at alpha.24 now beats the eager lane on memory outright — 0.88 GB against linopy's 2.15 GB on dispatch — which is the claim docs/benchmarks.md on main was already making and which this branch would give up.

Where that leaves the swap

The case I opened this PR on was largely a duckdb-performance problem that main has since fixed. What survives on the merits:

  • the deletionssql.py, the identifier restriction on declared names, the chunking, the lifetime, two dependencies;
  • speed, consistently, most clearly on the sparse case;
  • a smaller install.

Against a 2–3x memory regression that is structural (COO triples vs dense floats), not a bug I can find and fix.

That is a real decision, not a formality, and it is yours. I would not merge this on the numbers alone — the honest reading is that duckdb wins the axis this project leads on, and polars wins the one it does not currently claim.

Continuing with sol.dual on the polars engine either way, so the branch is a like-for-like comparison rather than one missing a feature.

@FBumann

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Duals ported — 2c1c610

sol.dual(name) now works on the polars engine, so the branch is a like-for-like comparison rather than one missing a feature. The sink returns a (row, value) frame beside the primal when HiGHS reports dual_valid, and _dual is the adjoint of _solution_frame — the same label join against the constraint's row frame instead of a variable's column frame.

The two empty cases stay distinct, which is the part worth keeping: no values at all is NoSolutionError; a solve that did leave values but no duals raises LinopyYamlError and names the integer variable responsible, because integrality is decidable from the program while "HiGHS reported no dual solution" is not actionable. Neither returns zeros.

tests/test_duals.py comes across from alpha.24 (two frame-idiom assertions adapted). The differential against the eager lane passes — prices agree with linopy's in sign and magnitude.

One thing it caught: the file arrived with a bare import pandas as pd, which sorts above the first-party import that guards it — the exact ordering bug tests/oracle.py documents. It broke collection on the bare install rather than skipping. Fixed in bb0e241; worth knowing the same trap is live on main.

State: 349 passed / 1 xfailed with the dev env, 183 passed / 20 skipped with no dataframe library installed, ruff clean, pyrefly 0 errors.

Still outstanding before merge: the rebase onto v0.0.0-alpha.24 (~800 lines landed in executor.py/sinks/ that this branch deletes wholesale), and dropping e544400 + 653ef88 now that e92ba90 fixed the bench gate upstream. Neither changes the numbers; both are needed for this to be reviewable as a diff.

Replaces duckdb with polars as the engine the relational lane runs on, rebased
onto v0.0.0-alpha.24. The language does not move: plan.py, lowering.py, the
schema, both parsers, resolution, dimensions, piecewise and the whole linopy
lane are untouched, and the differential suite passes on all three benchmark
cases including transport, the group_sum shape.

BREAKING: `primal()` and `dual()` return a `polars.DataFrame`; the
`memory_limit`, `chunk_rows`, `threads` and `workdir` build knobs are gone.

What the swap deletes:

- `relational/sql.py` and every quoting rule in it. An identifier is a value
  now, not syntax.
- The `[A-Za-z_][A-Za-z0-9_]*` restriction the engine imposed on every declared
  name — a *language* restriction that existed only because names were spliced
  into query text. A dimension may be called `from` or `order`.
- All hand-managed chunking, and the label window it existed for.
- The lifetime apparatus: workdir, tempdir, weakref.finalize, `_release`.
- Two dependencies: duckdb and pyarrow. The bare-install job proves the suite
  runs with neither, nor pandas, nor linopy, nor xarray (210 tests).

What it costs, measured against the tag rather than the old base:

    build + LP file        duckdb a24        polars        linopy
    dispatch 10M       7.38s / 0.88 GB   3.87s / 2.28 GB   3.02s / 2.15 GB
    nodal 3M @ 25%     3.69s / 0.59 GB   1.99s / 1.88 GB   1.35s / 1.57 GB
    transport 9.8M     6.31s / 1.16 GB   5.49s / 3.92 GB   3.46s / 1.89 GB

    build + handoff        duckdb a24        polars        linopy direct
    dispatch 10M       2.00 GB / 3.72s   3.13 GB / 3.55s   3.38 GB / 2.92s
    nodal 3M @ 25%     0.75 GB / 2.36s   1.50 GB / 0.89s   2.01 GB / 1.26s
    transport 9.8M     1.90 GB / 4.02s   3.71 GB / 2.69s   4.04 GB / 2.31s

polars is 1.5-2x faster; duckdb is 2-3x lighter, and after #181/#186/#190/#179
it beats the eager lane on memory outright. The trade is real and the memory
side is structural — COO carries a (row, col, coeff) triple per nonzero where a
dense array carries one float — so hard rule 4 is rewritten to say what is true
now (peak tracks the model, not a configured number) and ROADMAP Track 5
becomes the memory axis, naming partition-wise execution as the way to get a
ceiling back.

Carried across from main and re-proved on this engine:

- `sol.dual` (#156), the same label join against the row frame, still refusing
  rather than zero-filling and still naming the integer variable responsible.
- The LP-text round-trip properties (#190). Porting them caught a real bug: my
  `_signed` prefixed `+` to whatever the cast produced, so `-0.0` became
  `+-0.0`, which no LP parser accepts.
- The objective-aggregate tests (#179). Porting *those* caught a worse one — a
  fragment's dims can equal its variable's while its rows do not, so a term now
  tracks which of its dims its label actually determines (`label_dims`) rather
  than assuming every reduction is safe.
- The regression suite and the build profiler, the latter rewritten to wrap
  `LazyFrame.collect` instead of `DuckDBPyConnection.execute`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann and others added 3 commits July 27, 2026 13:53
Every explanatory comment inside a function body is gone from the engine.
Where one was load-bearing it became a docstring on the code it described, and
where the code needed the comment to be followed it became a named helper:
`TermFragment.survives_dropping`, `_relabel`, `_series_to_frame`, `_status_of`,
`_constraint_blocks`, `_sorted_terms`. Only section banners remain.

Docstrings trimmed with them — 939 lines to 769 across the six engine modules,
and the module headers cut hardest, since they were restating the architecture
docs rather than saying what the module does.

Tidying the compiler broke it once, which is worth recording: collapsing

    condition = walk(pred)
    return carrier, condition

into `return carrier, walk(pred)` reads better and is wrong. Walking the
predicate is what joins the mask's parameters onto `carrier`, and a tuple
evaluates left to right, so the one-expression form returns the frame as it was
before the walk. 30 tests caught it. The two-line form is back and its docstring
now says why it is two lines.

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

Build memory alone is out of the headlines. It is a small fraction of what
solving costs, so shrinking it further changes nothing a caller feels. What
replaces it: end-to-end peak against the floor under the LP-file route, and
marginal cost per model in a loop. Both are checkable in a command, and neither
moves when either library ships a release.

Also ran the density sweep, which refuses the prediction written into the case:

    installed   live     wall farkas / linopy   peak farkas / linopy
    12/12       1.20M        0.58 s / 0.59 s       0.68 GB / 0.63 GB
    6/12        0.60M        0.38 s / 0.45 s       0.52 GB / 0.60 GB
    3/12        0.30M        0.35 s / 0.41 s       0.46 GB / 0.45 GB
    1/12        0.10M        0.19 s / 0.32 s       0.40 GB / 0.35 GB

linopy's peak falls with density too, and at the sparsest rung ends up below
ours. So "an absent pair is a row we skip and a NaN they pay for" is not what
the memory does. Wall time is the axis that behaves — our margin grows 1.0x to
1.7x as the model thins.

Kept and written down rather than dropped: the case is the shape real
multi-node models have, and it is a throughput measurement, not evidence about
representations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`nodal` is sparse everywhere it can be, `dispatch` dense everywhere, and
neither is what a sector-coupled model looks like. `sector` crosses them: 50
nodes x 12 technologies at 8% installed, 5 dense carriers, dense snapshots. The
shape that matters is `sum(p * produces, over=tech)` — a dense dim broadcast
onto a sparse frame, then the sparse one reduced away.

    variables   wall farkas / linopy   peak farkas / linopy
    4k               0.11 s / 0.32 s      0.17 GB / 0.21 GB
    10k              0.10 s / 0.31 s      0.20 GB / 0.24 GB
    100k             0.25 s / 0.44 s      0.42 GB / 0.53 GB
    1M               1.58 s / 1.75 s      1.27 GB / 2.79 GB

2.2x less memory at the top rung, which is the clearest result in the file, and
it rescues the density sweep committed one change ago. That sweep holds the
coordinate product at 1.2M, where a dense array over it is ~10 MB and the
interpreter dominates — so it could not have shown the effect whatever the
density. `sector` runs the same 8% at a 12M product and it is unmistakable. The
claim needs both halves: low density *and* a product large enough for it to
cost anything. Both files now say so.

Two pre-existing divergences turned up while writing the model, neither caused
by nor fixed here, both worth their own issue:

1. `x * a + y * b` with disjoint dims in an objective. The relational lane sums
   the fragments (32); the eager lane broadcasts them together (66). duckdb at
   alpha.24 also answers 32, so this is lane-level, not engine-level — a hard
   rule 3 violation.
2. A second `equations:` entry under one objective is silently dropped by both
   lanes. It should sum or be refused, not ignored.

The case avoids both: one objective equation, one dim set, and `where: demand`
on the balance so an unreachable (node, carrier) pair is no row rather than a
vacuous one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is not a solve

Two defects, one visible symptom. Reported in review of #189, reproduced, and
both fixed.

**The wrong model.** `_group_fragment` passed `keyed` through unchanged.
Grouping merges labels of `over` into one `into`, and whether that merges rows
carrying the *same* `var_label` depends on where `over` came from. When the
variable carries it the merged rows have distinct labels and the key survives;
when it arrived by broadcast they do not. `group_sum(x * w, over=generator,
by=bus)` with `x` indexed by snapshot alone is the reachable case: two
generators on one bus put one column on a row twice, and `_needs_aggregate`
had already decided not to collapse them.

    matrix, before        matrix, after
    (0, 0, 1.0)           (0, 0, 3.0)
    (0, 0, 2.0)           (1, 0, 5.0)
    (1, 0, 5.0)

**The silent answer.** HiGHS rejects a row list carrying a column twice — by
return value, then carrying on with an empty model. `addCols`/`addRows` went
unchecked, so on an LP that surfaced as an unrelated ShapeError when the dual
read-back found no duals, and on a MIP as `ok / optimal / 20.0` for a model
whose answer is 6.0: a confident solution to an unconstrained problem. The
status is checked now and a refused batch raises. Unchecked on main too, but
this branch made it load-bearing.

Neither is covered by the differential suite. `transport` has three term
fragments per balance, so `len(terms) != 1` forces the aggregate regardless and
the elision never runs on the shape that breaks it. Three tests added: the
broadcast case end to end, its `foreach` counterpart, and the flag itself at
the compiler. All three fail with the fix reverted.

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

@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: 12

🧹 Nitpick comments (2)
src/farkas/relational/sinks/highs.py (1)

99-108: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Per-chunk filter rescans both frames once per batch.

ordered_rows and ordered_matrix are already sorted and eager, and row is dense 0..n-1, so each chunk's slice boundaries are known without a predicate pass. As written a model with row_count / batch_rows chunks pays a full scan of the matrix per chunk — the cost grows with the square of the model on the hot handoff path the PR is measuring.

♻️ Slice instead of filter
     ordered_rows = model.rows.sort('row')
     ordered_matrix = model.matrix.sort('row')
+    matrix_row = ordered_matrix['row'].to_numpy()
     for lo, hi in model.row_chunks(batch_rows):
-        rows = ordered_rows.filter(pl.col('row').is_between(lo, hi, closed='left'))
-        a = ordered_matrix.filter(pl.col('row').is_between(lo, hi, closed='left'))
+        rows = ordered_rows.slice(lo, hi - lo)  # `row` is dense 0..n-1
+        start, stop = np.searchsorted(matrix_row, [lo, hi])
+        a = ordered_matrix.slice(int(start), int(stop - start))
🤖 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/farkas/relational/sinks/highs.py` around lines 99 - 108, Replace the
per-chunk predicate filters in the row-processing loop with positional slices
derived from each chunk’s dense row bounds, applying the same slice boundaries
to ordered_rows and ordered_matrix. Preserve the existing rhs, sense, bound, and
starts calculations while ensuring each chunk only scans its corresponding rows.
tests/test_lp_text.py (1)

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

Missing type annotation on render parameter.

_rendered's render argument is untyped, unlike other newly-added helpers in this PR (e.g. columns/query/joins in tests/test_compiler.py). As per coding guidelines, "Keep Pyrefly strict type checking at zero errors; fix types rather than widening them."

♻️ Suggested typing fix
-def _rendered(render, values: list[float]) -> list[str]:
+def _rendered(render: Callable[[pl.Expr], pl.Expr], values: list[float]) -> list[str]:
🤖 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_lp_text.py` around lines 42 - 45, Add an explicit callable type
annotation to the render parameter of _rendered, matching the expression type
accepted by frame.select and preserving strict Pyrefly checking without using an
untyped or overly broad type.

Source: Coding guidelines

🤖 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 `@bench/cases.py`:
- Around line 485-499: Update the explanatory comment in the sector Case
definition to remove the nonexistent `shed` reference and describe only the
model variables actually defined in sector.yaml, while preserving the stated
density characteristics for `p` and `balance`.

In `@bench/README.md`:
- Around line 58-60: Update the nearby “after the clock” row and “Memory is not
the whole cost” paragraph in bench/README.md to remove stale scratch-database,
SQL count, scratch-directory, and workdir_bytes disk-tracking references. Align
the documentation with bench/_run_case.py reporting workdir_bytes as 0 and the
current Polars engine, without changing unrelated benchmark guidance.

In `@docs/benchmarks.md`:
- Around line 14-21: The benchmark documentation and committed fixture must
describe the same complete run. In docs/benchmarks.md lines 14-21, include
sector in the reproduction command and update the parity-gate summary to match
all cases and the regenerated results; in bench/results/latest.jsonl lines 1-18,
regenerate and commit a full benchmark run containing gate and timing records
for every case shown in the documentation, including dispatch, nodal, transport,
and sector.
- Around line 45-58: Update the first variables label in the sector benchmark
table from “4k” to “1k”; leave its measured wall-time and peak-RSS values
unchanged, since they correspond to the 1000-column benchmark record. Preserve
the existing “10k”, “100k”, and “1M” labels.

In `@src/farkas/relational/compiler.py`:
- Around line 377-383: Add compile-time validation in the group_sum flow before
building the mapping or join: reject cases where g.into already exists in
p.dims. Raise a clear LanguageError naming the GroupSum construct and
conflicting target dimension, alongside the existing shape validation, so the
duplicate-column path is never reached.

In `@src/farkas/relational/executor.py`:
- Around line 421-426: Update _check_coordinates_single_valued so its per-column
distinct-value count excludes null coordinates before applying the n > 1
disagreement check. Preserve the existing validation for multiple distinct
non-null values while allowing labels with real values mixed with null rows,
consistent with _check_coordinate_containment.
- Around line 600-606: Update the objective aggregation decision in the shown
executor flow to account for the dimensions dropped before returning the
collected frame: use each TermFragment’s survives_dropping behavior on the
implicit-sum dimensions rather than relying only on
_needs_aggregate(comp.terms). Ensure objectives such as x over i multiplied by w
over (i, j) aggregate duplicate var_label rows before solve_direct joins them,
and add the corresponding regression test beside
test_an_objective_naming_a_variable_twice_sums_its_coefficients.
- Around line 365-366: Update the derived-dimension construction in the executor
around stacked, unique, and row indexing to filter out null values from the
selected dimension column before deduplication, sorting, and ordinal assignment,
matching _check_coordinate_containment’s absent-value behavior. Also validate
contributing parameter dimension columns at load time for compatible integer
widths and raise an actionable error naming both the dimension and conflicting
parameter names instead of allowing pl.concat to emit a generic schema error.

In `@src/farkas/relational/sinks/highs.py`:
- Around line 89-90: The assembly validation must reject null and NaN values in
cols, rows, and matrix, naming the relevant declaration; update _build_variable
and the corresponding assembly path so coeff and rhs are validated as well as
lb/ub. At src/farkas/relational/sinks/highs.py lines 89-90, preserve NaN by
passing nan=np.nan to np.nan_to_num so only infinities are substituted. At
src/farkas/relational/sinks/lp_file.py lines 144-163, make no direct change
because assembly validation makes the existing null/inf/NaN rendering paths
unreachable.
- Around line 93-97: Capture the return status from changeColsIntegrality in the
noncontinuous-column branch and combine it with the existing _loaded state, so a
rejected integrality update marks the model as not successfully loaded. Preserve
the current column selection and integrality values, and ensure subsequent
solve/reporting follows the existing _loaded failure path.
- Around line 163-167: Update the SolveStatus construction after
h.getModelStatus() so _CONDITION_OF_HIGHS_STATUS is keyed using the canonical
value returned by h.modelStatusToString(model_status), reusing that value for
solver_wording instead of parsing str(model_status). Preserve the existing
unknown fallback and primal-solution handling.

In `@src/farkas/relational/sinks/lp_file.py`:
- Around line 98-107: Update the row aggregation in the model-writing flow to
call group_by('row', maintain_order=True), preserving the `_sorted_terms` column
order when constructing the `body` string while retaining the existing final row
sort.

---

Nitpick comments:
In `@src/farkas/relational/sinks/highs.py`:
- Around line 99-108: Replace the per-chunk predicate filters in the
row-processing loop with positional slices derived from each chunk’s dense row
bounds, applying the same slice boundaries to ordered_rows and ordered_matrix.
Preserve the existing rhs, sense, bound, and starts calculations while ensuring
each chunk only scans its corresponding rows.

In `@tests/test_lp_text.py`:
- Around line 42-45: Add an explicit callable type annotation to the render
parameter of _rendered, matching the expression type accepted by frame.select
and preserving strict Pyrefly checking without using an untyped or overly broad
type.
🪄 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: acafe344-b1cb-4e28-b2e0-2536615e84de

📥 Commits

Reviewing files that changed from the base of the PR and between 2da06dd and d273c87.

⛔ Files ignored due to path filters (1)
  • examples/walkthrough.out is excluded by !**/*.out
📒 Files selected for processing (49)
  • ARCHITECTURE.md
  • README.md
  • ROADMAP.md
  • SPEC.md
  • bench/README.md
  • bench/_run_case.py
  • bench/cases.py
  • bench/models/sector.yaml
  • bench/profile_build.py
  • bench/regressions/test_build.py
  • bench/report.py
  • bench/results/latest.jsonl
  • bench/run.py
  • docs/benchmarks.md
  • examples/walkthrough.py
  • pyproject.toml
  • src/farkas/__init__.py
  • src/farkas/api.py
  • src/farkas/piecewise.py
  • src/farkas/relational/__init__.py
  • src/farkas/relational/arrow.py
  • src/farkas/relational/compiler.py
  • src/farkas/relational/executor.py
  • src/farkas/relational/frames.py
  • src/farkas/relational/sinks/highs.py
  • src/farkas/relational/sinks/lp_file.py
  • src/farkas/relational/sinks/tables.py
  • src/farkas/relational/sql.py
  • src/farkas/sources.py
  • tests/conftest.py
  • tests/differential.py
  • tests/oracle.py
  • tests/test_api.py
  • tests/test_compiler.py
  • tests/test_doc_examples.py
  • tests/test_duals.py
  • tests/test_expansion.py
  • tests/test_group_sum.py
  • tests/test_linopy_lane.py
  • tests/test_lowering.py
  • tests/test_lp_text.py
  • tests/test_milp.py
  • tests/test_piecewise.py
  • tests/test_relational.py
  • tests/test_resolution_parity.py
  • tests/test_roll.py
  • tests/test_solve_status.py
  • tests/test_validation.py
  • tests/test_walkthrough.py
💤 Files with no reviewable changes (2)
  • src/farkas/relational/sql.py
  • src/farkas/relational/arrow.py

Comment thread bench/cases.py
Comment thread bench/README.md
Comment thread docs/benchmarks.md Outdated
Comment thread docs/benchmarks.md Outdated
Comment on lines 377 to +383
if g.over not in p.dims:
raise LanguageError(f"in {context}: GroupSum over '{g.over}' but the expression has dims {list(p.dims)}")
keep = tuple(x for x in p.dims if x != g.over)
valcols = 't.var_label, t.coeff' if p.is_term else 't.cval'
keepcols = ', '.join([*(f't.{x}' for x in keep), f'g."{g.coordinate}" AS {g.into}', valcols])
sql = f'SELECT {keepcols} FROM ({p.sql}) t JOIN dim_{g.over} g ON g.val = t.{g.over}'
return TermFragment((*keep, g.into), sql, p.is_term)
mapping = self.dimensions[g.over].select(pl.col('val').alias(g.over), pl.col(g.coordinate).alias(g.into))
frame = p.frame.join(mapping, on=g.over, how='inner').select(*keep, g.into, *p.carried)
keyed = p.keyed and g.over in p.label_dims
return TermFragment((*keep, g.into), frame, p.is_term, keyed, _relabel(p.label_dims, g.over, g.into))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

group_sum breaks when the fragment already carries the target dim.

If g.into is already in p.dims (e.g. group_sum(f, over=line, by=bus) on a fragment indexed by (line, bus)), keep retains bus, the joined mapping also contributes a bus (suffixed bus_right by the join), and select(*keep, g.into, ...) then names bus twice — polars raises a duplicate-column error with no reference to the model. Either reject this at compile time with a message naming the construct, or decide and implement the merge semantics explicitly.

As per coding guidelines, "Perform all validation at load time and provide clear, actionable error messages."

🛡️ Reject it where the other shape errors are raised
         if g.over not in p.dims:
             raise LanguageError(f"in {context}: GroupSum over '{g.over}' but the expression has dims {list(p.dims)}")
+        if g.into in p.dims:
+            raise LanguageError(
+                f"in {context}: GroupSum over '{g.over}' targets '{g.into}', which the expression "
+                f'already carries {list(p.dims)} — sum over one of them first'
+            )
         keep = tuple(x for x in p.dims if x != g.over)
📝 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
if g.over not in p.dims:
raise LanguageError(f"in {context}: GroupSum over '{g.over}' but the expression has dims {list(p.dims)}")
keep = tuple(x for x in p.dims if x != g.over)
valcols = 't.var_label, t.coeff' if p.is_term else 't.cval'
keepcols = ', '.join([*(f't.{x}' for x in keep), f'g."{g.coordinate}" AS {g.into}', valcols])
sql = f'SELECT {keepcols} FROM ({p.sql}) t JOIN dim_{g.over} g ON g.val = t.{g.over}'
return TermFragment((*keep, g.into), sql, p.is_term)
mapping = self.dimensions[g.over].select(pl.col('val').alias(g.over), pl.col(g.coordinate).alias(g.into))
frame = p.frame.join(mapping, on=g.over, how='inner').select(*keep, g.into, *p.carried)
keyed = p.keyed and g.over in p.label_dims
return TermFragment((*keep, g.into), frame, p.is_term, keyed, _relabel(p.label_dims, g.over, g.into))
if g.over not in p.dims:
raise LanguageError(f"in {context}: GroupSum over '{g.over}' but the expression has dims {list(p.dims)}")
if g.into in p.dims:
raise LanguageError(
f"in {context}: GroupSum over '{g.over}' targets '{g.into}', which the expression "
f"already carries {list(p.dims)} — sum over one of them first"
)
keep = tuple(x for x in p.dims if x != g.over)
mapping = self.dimensions[g.over].select(pl.col('val').alias(g.over), pl.col(g.coordinate).alias(g.into))
frame = p.frame.join(mapping, on=g.over, how='inner').select(*keep, g.into, *p.carried)
keyed = p.keyed and g.over in p.label_dims
return TermFragment((*keep, g.into), frame, p.is_term, keyed, _relabel(p.label_dims, g.over, g.into))
🤖 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/farkas/relational/compiler.py` around lines 377 - 383, Add compile-time
validation in the group_sum flow before building the mapping or join: reject
cases where g.into already exists in p.dims. Raise a clear LanguageError naming
the GroupSum construct and conflicting target dimension, alongside the existing
shape validation, so the duplicate-column path is never reached.

Source: Coding guidelines

Comment thread src/farkas/relational/executor.py
Comment thread src/farkas/relational/sinks/highs.py Outdated
Comment thread src/farkas/relational/sinks/highs.py Outdated
Comment on lines +93 to +97
noncontinuous = np.flatnonzero(batch['vtype'].to_numpy() != 'continuous')
if len(noncontinuous):
cols_idx = batch['col'].to_numpy().astype(np.int32)[noncontinuous]
integrality = np.full(len(noncontinuous), int(highspy.HighsVarType.kInteger), dtype=np.uint8)
h.changeColsIntegrality(len(noncontinuous), cols_idx, integrality)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

changeColsIntegrality is the one load whose status is dropped.

_loaded exists precisely because HiGHS reports a rejected batch by return value; if this call fails the model quietly solves as its LP relaxation and reports optimal, which is the failure mode the helper's own docstring describes.

🛡️ Route it through `_loaded` too
-            h.changeColsIntegrality(len(noncontinuous), cols_idx, integrality)
+            _loaded(
+                h,
+                h.changeColsIntegrality(len(noncontinuous), cols_idx, integrality),
+                'column integrality',
+            )
📝 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
noncontinuous = np.flatnonzero(batch['vtype'].to_numpy() != 'continuous')
if len(noncontinuous):
cols_idx = batch['col'].to_numpy().astype(np.int32)[noncontinuous]
integrality = np.full(len(noncontinuous), int(highspy.HighsVarType.kInteger), dtype=np.uint8)
h.changeColsIntegrality(len(noncontinuous), cols_idx, integrality)
noncontinuous = np.flatnonzero(batch['vtype'].to_numpy() != 'continuous')
if len(noncontinuous):
cols_idx = batch['col'].to_numpy().astype(np.int32)[noncontinuous]
integrality = np.full(len(noncontinuous), int(highspy.HighsVarType.kInteger), dtype=np.uint8)
_loaded(
h,
h.changeColsIntegrality(len(noncontinuous), cols_idx, integrality),
'column integrality',
)
🤖 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/farkas/relational/sinks/highs.py` around lines 93 - 97, Capture the
return status from changeColsIntegrality in the noncontinuous-column branch and
combine it with the existing _loaded state, so a rejected integrality update
marks the model as not successfully loaded. Preserve the current column
selection and integrality values, and ensure subsequent solve/reporting follows
the existing _loaded failure path.

Comment on lines +163 to +167
model_status = h.getModelStatus()
return SolveStatus(
termination_condition=_CONDITION_OF_HIGHS_STATUS.get(str(model_status).rsplit('.', 1)[-1], 'unknown'),
solver_wording=h.modelStatusToString(model_status),
has_primal=h.getInfo().primal_solution_status == int(highspy.SolutionStatus.kSolutionStatusFeasible),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

highspy HighsModelStatus enum .name attribute str representation

💡 Result:

In the highspy library, HighsModelStatus is an enumeration [1][2]. Because it is implemented as a C++ enum exposed to Python, it does not behave exactly like a standard Python 3.4+ enum.Member; consequently, accessing the.name attribute directly on an instance of HighsModelStatus will generally fail as it is not a native Python Enum type [3]. To obtain a string representation of a HighsModelStatus value, you should use the designated utility function provided by the highspy library rather than relying on.name or direct string conversion [4]. Specifically: 1. Use the method highspy.Highs.modelStatusToString(status_value) to retrieve the human-readable string representation of a model status [4]. Example usage: import highspy h = highspy.Highs #... solve model... model_status = h.getModelStatus status_str = h.modelStatusToString(model_status) print(status_str) Attempting to access.name or use str directly on the enum member may not provide the expected string name associated with the HiGHS status constants (e.g., 'kOptimal', 'kInfeasible') [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant file snippet =="
sed -n '120,185p' src/farkas/relational/sinks/highs.py

echo
echo "== highspy dependency declarations =="
rg -n "highspy|highs" pyproject.toml setup.cfg setup.py requirements* .github workflow 2>/dev/null || true

echo
echo "== package availability and enum attrs =="
python3 - <<'PY'
try:
    import highspy
    print("highspy_version=", getattr(highspy, "__version__", "unknown"))
    statuses = [
        highspy.HighsModelStatus.kOptimal,
        highspy.HighsModelStatus.kInfeasible,
    ]
    for s in statuses:
        print(type(s), repr(s), str(s), int(s))
        for a in ["name", "value", "name()"]:
            try:
                print(f"  attr={a}: {getattr(s, a)() if callable(getattr(s, a)) else getattr(s, a)}")
            except Exception as e:
                print(f"  attr={a}: {e.__class__.__name__}: {e}")
except Exception as e:
    print(f"FAILED: {e.__class__.__name__}: {e}")
PY

Repository: FBumann/farkas

Length of output: 2946


🌐 Web query:

highspy Python HighspySource.cpp HighsModelStatus enum __str__ pybind11

💡 Result:

In the highspy Python package, the HighsModelStatus enum does not automatically provide a human-readable string representation via a built-in str method on the enum itself [1][2]. Instead, the package provides explicit helper functions to convert status codes into descriptive strings [3][4]. To get a human-readable string for a HighsModelStatus instance, you should use the corresponding conversion method provided by the Highs object [3][4]. Example usage: import highspy h = highspy.Highs #... after running the solver... model_status = h.getModelStatus print(h.modelStatusToString(model_status)) While pybind11 supports defining custom str methods for C++ enums exposed to Python, highspy follows a pattern of using explicit conversion functions like modelStatusToString (and similar methods for other statuses like solutionStatusToString or basisValidityToString) rather than relying on the default enum string representation [3][4][5]. If you were to implement this in a custom pybind11 binding, you would typically use.def("str",...) on the py::enum_ object, provided the pybind11 version supports the desired override behavior [6]. In the context of the official highspy package, the conversion utility is the intended approach [3][4].

Citations:


🌐 Web query:

PyPI highspy 1.10 HighspySource.cpp HighsModelStatus.py

💡 Result:

Highspy is the Python interface for the HiGHS optimization solver [1][2]. Version 1.10.0 of highspy was released on March 20, 2025 [2]. The terms you mentioned relate to the internal structure and usage of the library: 1. HighspySource.cpp: This is likely a reference to the C++ source files within the HiGHS project that highspy wraps. Highspy uses pybind11 to create a thin Python wrapper around the native HiGHS C++ codebase [1][3][4]. 2. HighsModelStatus.py: This refers to the Python-exposed enumeration used to check the status of an optimization model after it has been solved [5][6]. In practice, you typically access this status via the highspy package (e.g., highspy.HighsModelStatus) to determine if a model is optimal, infeasible, unbounded, etc. [5][6]. For example, when using highspy, you can check the model status as follows [6]: import highspy h = highspy.Highs #... (define and solve model) model_status = h.getModelStatus if model_status == highspy.HighsModelStatus.kOptimal: print("Model solved to optimality.")

Citations:


🌐 Web query:

"highspy" "class HighsModelStatus" "__repr__"

💡 Result:

In the highspy library, HighsModelStatus is an enumeration that represents the status of a model after the solver has been run [1][2]. While Python enums typically have a repr method that returns a descriptive string representation of the enum member, the highspy documentation does not explicitly document a custom repr implementation for HighsModelStatus [1]. In practice, users generally interact with HighsModelStatus by converting the status to a human-readable string using the modelStatusToString method provided by the Highs class [3][4]. For example: import highspy h = highspy.Highs #... (model configuration and run) model_status = h.getModelStatus print(h.modelStatusToString(model_status)) This approach is the standard and recommended way to output or log the status of a model, as it returns a clear, descriptive name (e.g., kOptimal, kInfeasible) corresponding to the enum value [1][3].

Citations:


🌐 Web query:

"highspy" "enum class HighsModelStatus" "__str__"

💡 Result:

In the highspy Python interface, the HighsModelStatus enum class does not provide a direct str method to convert its members to human-readable strings [1][2]. Instead, to convert a HighsModelStatus value to a string, you should use the modelStatusToString method provided by the Highs class instance [3][4][5]. Example usage: import highspy h = highspy.Highs #... solve the model... model_status = h.getModelStatus print(h.modelStatusToString(model_status)) Using h.modelStatusToString is the standard approach for obtaining descriptive, string-based representations of model status enum values within the highspy library [3][4].

Citations:


🌐 Web query:

repository: ERGO-Code/HiGHS HighspySource.cpp py::enum_ HighsModelStatus modelStatusToString

💡 Result:

In the HiGHS optimization software project, HighspySource.cpp is a C++ source file that facilitates the Python interface (highspy) using pybind11 [1][2][3]. The code snippet py::enum_(m, "HighsModelStatus") is used to bind the C++ HighsModelStatus enumeration to Python, allowing users to access model status constants directly within their Python code [4][5]. The function modelStatusToString is a utility exposed through this interface that converts a HighsModelStatus enum value into its corresponding human-readable string representation (e.g., kOptimal becomes 'Optimal') [6]. Users can call this function from Python—typically as h.modelStatusToString(status)—to interpret the numerical or enum-based status returned by the solver [6]. Key points: - Purpose: Provides a bridge between C++ internal model status codes and Python-readable strings for debugging and result interpretation [6]. - Binding: The py::enum_ construct is part of pybind11, which creates a Python-compatible wrapper for the C++ HighsModelStatus type [5][3]. - Usage: It is commonly used in conjunction with h.getModelStatus to report the solver's outcome [6]. Technical documentation for the HiGHS project and its enums is available on the official HiGHS documentation site [4][2].

Citations:


🌐 Web query:

github highspy 1.10.0 source Highspysources.pyi enum.__str__

💡 Result:

In the highspy Python package, including version 1.10.0, the codebase interacts with HiGHS enums by wrapping C++ structures [1][2][3]. The specific file Highspysources.pyi (often generated or located within the package's type stub files) provides type information for these interfaces [4]. Regarding enum.str, this refers to the standard Python Enum behavior or a specific implementation override [5][6]. Python's enum types define str to provide a human-readable string representation of the member (e.g., EnumName.MemberName) [5][6]. If you are encountering issues related to enum.str in highspy or are looking to modify how these enums are represented, note that: 1. Standard Behavior: By default, calling str on a Python enum member returns the Enum name and member name [5][6]. 2. Overrides: If a specific version or implementation of highspy requires different behavior, developers sometimes explicitly reassign the str method to the base Enum.str (or similar) to change output, often to avoid breaking changes or to align with expected serialization [5][7]. 3. Context: HiGHS enums in highspy are mappings of the underlying C++ structures [8][3]. If you are seeing references to enum.str in type stubs (.pyi) or code, it typically reflects how the Python wrappers maintain compatibility with the standard library's Enum type system [5][6]. For precise implementation details, you can inspect the highspy source code on the official ERGO-Code/HiGHS GitHub repository [2]. If you are troubleshooting a specific error, ensure your Python environment version is compatible, as Python 3.11+ introduced changes to how enum string representations are handled [7].

Citations:


🌐 Web query:

site:github.com/ERGO-Code/HiGHS "def(str" "py::enum" enum

💡 Result:

In pybind11, the py::enum_ class is used to bind C++ enumerations to Python. The syntax for defining an enum and its values involves creating a py::enum_ object and chaining the.value method to register each enumeration member. The standard pattern for this definition is: py::enum_(m, "EnumType").value("NAME1", EnumType::NAME1).value("NAME2", EnumType::NAME2).export_values; In this structure: 1. py::enum_(m, "EnumType"): This initializes the binding for the C++ enumeration TypeName, exposing it in the Python module m as "EnumType". 2..value("NAME1", EnumType::NAME1): This registers a specific enum member, providing its Python-side string name ("NAME1") and the corresponding C++ value (EnumType::NAME1). 3..export_values: This optional method exports the enum members directly into the parent scope (e.g., the module level), allowing them to be accessed as EnumType.NAME1 rather than requiring the full qualified path if desired. While the query mentioned def(str py::enum enum, pybind11 bindings typically use the py::enum_ constructor rather than a function named def for the enum object itself. The py::enum_ object is usually instantiated within a module or class binding block.

Citations:


Use modelStatusToString() instead of parsing str(model_status).

str(model_status).rsplit('.', 1)[-1] depends on an undocumented str() format. Since h.modelStatusToString(model_status) returns the stable solver status text and the code already calls it for solver_wording, map _CONDITION_OF_HIGHS_STATUS by that canonical value instead so future enum repr changes don’t silently fall back to unknown.

🤖 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/farkas/relational/sinks/highs.py` around lines 163 - 167, Update the
SolveStatus construction after h.getModelStatus() so _CONDITION_OF_HIGHS_STATUS
is keyed using the canonical value returned by
h.modelStatusToString(model_status), reusing that value for solver_wording
instead of parsing str(model_status). Preserve the existing unknown fallback and
primal-solution handling.

Comment thread src/farkas/relational/sinks/lp_file.py Outdated
Ported from main (#186), which this branch had dropped on the floor when the
executor was rewritten. An unmasked coordinate product needs no sort: every
coordinate exists, so a row's label is its row-major position, which is a dot
product against the dim ordinals the frame already carries.

    l rung          build before   build after
    transport            2.57 s        0.87 s
    dispatch             0.97 s        0.49 s

transport gains most because neither of its variables carries a mask; dispatch
keeps the sort for `p` and gains on its constraint rows. Total wall at the l
rung: transport 8.8 s -> 4.2 s.

Both paths now return `(dims…, label)` in that column order. They did not
before — `with_row_index` puts the index first — and the ported upstream test
`test_a_mask_that_removes_nothing_labels_exactly_like_no_mask` caught it, which
is exactly the property it exists to pin: a mask that removes nothing has to be
indistinguishable from no mask, down to the schema.

Benchmarks re-run across all four cases and the tables updated. What is left at
scale is the LP writer — 3.6 s of dispatch's 4.1 s at 10M — which is now the
obvious next target and is also engine-independent.

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

FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Re-measured: supersedes every polars-vs-duckdb number earlier in this thread

Three changes since the last comparison, two of which move the numbers: the group_sum broadcast fix (correctness — it forces the aggregate in more cases), and porting main's positional-label path, which this branch had dropped. duckdb side unchanged, built from v0.0.0-alpha.24. Same machine, back to back, l rung, two repeats.

Build + LP file

polars duckdb a24
dispatch 10M 2.79 s / 2.38 GB 6.57 s / 0.88 GB 2.4x faster, 2.7x heavier
nodal 3M 1.46 s / 1.91 GB 3.11 s / 0.59 GB 2.1x faster, 3.2x heavier
transport 9.8M 4.64 s / 3.66 GB 6.09 s / 1.16 GB 1.3x faster, 3.2x heavier

Build + COO handoff to HiGHS (run stubbed)

polars duckdb a24
dispatch 10M 0.79 s build, 2.99 GB total 3.48 s build, 2.46 GB total
nodal 3M 0.46 s build, 1.50 GB 2.48 s build, 0.80 GB
transport 9.8M 0.94 s build, 3.68 GB 3.07 s build, 1.95 GB

What changed, and what did not

The speed gap widened from ~1.9x to ~4x on the build phase alone — 0.79 s against 3.48 s on dispatch. Both engines now compute unmasked labels arithmetically rather than sorting for them, so what is left is everything else: parameter binding, dim frames, matrix assembly. That is a straight engine comparison with the biggest algorithmic difference removed from both sides.

The memory gap did not move. duckdb is 2.7-3.2x lighter on the write path and 1.6-1.9x lighter at the handoff, on every case. It was structural before and it is structural now: COO carries a triple per nonzero where a dense array carries a float, and there is no budget to set.

Nothing here contradicts the earlier reading, it sharpens it. polars is faster, duckdb is lighter, and the crossover is still wherever a model stops fitting.

Corrections to earlier comments in this thread

  • "~8x faster handoff than duckdb" — withdrawn already, and still withdrawn. It was an artifact of fix: the HiGHS sink's column ingest was an unbounded global sort #181, fixed on main.
  • "ahead on all three at the handoff" — that was against linopy io_api='direct', not against duckdb. duckdb was lighter than this branch at the handoff then and is now. The comparison stands but the sentence read as if it were against duckdb.
  • sector has no duckdb column anywhere, because the case only exists on this branch. Its 0.42x peak ratio is against linopy, not against main.

…t out of the rules

Two changes, both about what this PR should be carrying.

**The constraint-matrix elision comes out.** It was new here, it is worth ~30%
of build peak, and it had two bugs during this PR that the differential suite
structurally cannot catch. The objective elision stays, because main already
ships it (#179) and dropping it would be a regression rather than a split. The
mechanism (`keyed`, `label_dims`) stays with it and the tests stay in full;
extending it to the matrix is #161's, on its own evidence.

Measured cost of holding it back, l rung: dispatch build 0.49s -> ~2.1s,
transport 0.87s -> ~1.8s. Tables re-run and updated. That is the size of the
prize #161 is now carrying.

**Hard rule 4 is no longer a rule.** "Peak memory tracks the model" is a
property of the engine, not of the language: it was phrased around a
`memory_limit` only one engine had, and promoting it made an implementation
choice load-bearing in the rulebook — which is the only reason changing the
implementation reads as a decision about what the package is. Rules 0-3 (layer
order, engine isolation, one language two lanes, and the closure) survive any
engine and decide what enters SPEC.md; cost is settled by measurement and lives
in docs/benchmarks.md, which now says so at the top. Rules renumbered, and every
reference to "hard rule 4" in ARCHITECTURE, bench/README and the sinks README
rewritten to say what it actually meant.

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

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ARCHITECTURE.md (1)

88-100: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Narrow the “engine knows nothing about xarray” rule.

src/farkas/relational/frames.py intentionally detects and unwraps xarray.DataArray, so stating that src/farkas/relational/ never sees xarray contradicts the implemented boundary adapter. Limit this rule to the compiler/solver core or explicitly document frames.py as the permitted input-boundary exception.

🤖 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 `@ARCHITECTURE.md` around lines 88 - 100, Revise the architecture rule around
“The engine knows nothing about linopy, xarray or YAML” to acknowledge the
intentional xarray handling in frames.py. Limit the prohibition to the
compiler/solver core, or explicitly identify frames.py as the permitted
input-boundary adapter exception while preserving the existing
dependency-boundary guidance.
🤖 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 `@src/farkas/relational/sinks/README.md`:
- Around line 33-34: Update the streaming guidance in the sinks README to remove
the obsolete DuckDB aggregation instruction and state that sinks should consume
the already-built Polars frames directly, without materializing the model a
second time.

---

Outside diff comments:
In `@ARCHITECTURE.md`:
- Around line 88-100: Revise the architecture rule around “The engine knows
nothing about linopy, xarray or YAML” to acknowledge the intentional xarray
handling in frames.py. Limit the prohibition to the compiler/solver core, or
explicitly identify frames.py as the permitted input-boundary adapter exception
while preserving the existing dependency-boundary guidance.
🪄 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: 7568bdaa-0c55-49a5-8995-29d0d1ba37e4

📥 Commits

Reviewing files that changed from the base of the PR and between d273c87 and 95498ce.

⛔ Files ignored due to path filters (1)
  • examples/walkthrough.out is excluded by !**/*.out
📒 Files selected for processing (6)
  • ARCHITECTURE.md
  • bench/README.md
  • bench/results/latest.jsonl
  • docs/benchmarks.md
  • src/farkas/relational/executor.py
  • src/farkas/relational/sinks/README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • bench/README.md
  • docs/benchmarks.md
  • src/farkas/relational/executor.py

Comment thread src/farkas/relational/sinks/README.md Outdated
FBumann and others added 5 commits July 27, 2026 16:23
Restores what the previous commit held back, for a measured reason rather than
a procedural one. Ported to the duckdb engine on its own branch, it moved
nothing: dispatch build 4.17s against 4.16s, transport 3.80s against 3.64s,
peak unchanged — inside the noise on a back-to-back run. On this engine the
same change is worth 2-4x of build time.

So the reasoning is engine-independent and the value is not. duckdb's
`GROUP BY row, col` is a spilling hash aggregate that costs little beside
everything else it does; polars was materialising it. That is now on #161,
which is closed against the duckdb path.

What made the split worth doing anyway: the port produced the tests. Both
guards are verified load-bearing by reinstating each bug and watching the right
test fail — the broadcast `group_sum` case and the constant-part reduction.
Those stay.

The premise the elision rests on — every parameter keyed by its dims — went to
main separately as #201, where it stands on its own as a lane-parity fix rather
than as this optimisation's precondition.

Benchmark tables re-run. The machine was busy, so the note about reading ratios
rather than seconds is now in the doc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_constraint_blocks` joined the matrix onto the rows, grouped, and pasted each
row's terms together with `str.join`. The join is where reproducibility went:
it hands back a group in whatever order it finished it, and ordering the *rows*
afterwards cannot repair the order *within* one. Two writes of the same model
gave different bytes — which #109 asks for and the branch already claimed.

So build the lines independently and interleave them by sorting. Header, terms
and footer each carry `(row, ord, line)`; concat, `.sort('row','ord')`, sink.
Nothing gathers a row's terms into a string, and the order is a total order on
two integers rather than a side effect of a join. `_sorted_terms` folds into
the terms arm.

The anti-join is the one thing the old shape got free: a row with no terms
still needs a line a solver can parse, and a group-by emits one per group
whether or not the join matched.

Emit at the `l` rung, best of three, the two arms back to back on a quiet
machine with the build phase matched as a control:

    sector     0.52s -> 0.22s   -57%
    nodal      0.80s -> 0.48s   -40%
    transport  2.72s -> 2.05s   -25%
    dispatch   1.89s -> 2.15s   +13%

**`dispatch` pays for this and the others are paid.** It is the one case with
few rows and many terms each, so the old group-by had little to group and the
new sort has everything to sort; the other three invert that. Peak falls on all
four either way — 3.16 -> 2.46 GB on `dispatch`, 1.91 -> 1.35 on `nodal`, 1.26
-> 1.00 on `sector`, 3.89 -> 3.40 on `transport` — because nothing builds a
row's text before writing it. Reproducible bytes are the point; the emit time
is a trade that goes our way on three cases of four, and the memory goes our
way on all of them.

Two shapes that look like cleanups are not. Dropping the `.sort('row','col')`
on the matrix is slower on every case even though the order does not survive
into output that is sorted again anyway. Sorting *after* the join is the other
way to fix the bytes; it works and costs 30-50%, because it orders rendered
strings instead of the two integers behind them.

The test is new because there was none: it writes one model three times and
compares digests. The old sink fails it on every attempt — at 60 variables, not
just at scale, so the size is for realism rather than to provoke it.

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

The sink opened by joining `cols` to `obj`, sorting the result by `col` and
collecting the whole frame, then sliced it batch by batch. Every part of that
is work the labels already did: `col` is dense `0..n-1` (`ModelTables`), so it
*is* the position each value belongs at. The objective's gaps are a fill, not a
join, and lining the bounds up is a write, not a sort — and neither needs an
n-row frame to exist before the first batch can be handed over.

The column half alone, `dispatch/l` (10M columns), both shapes on the same
tables with HiGHS taken out so what is left is the sink:

    join + sort + collect   2.22s   +1.16 GB
    scatter                 0.37s   +0.49 GB

End to end with `Highs.run` stubbed — so HiGHS's own copy of the model is in
the number and the simplex workspace is not — best of two at the `l` rung, the
build peak matched as a control:

    dispatch   1.02s -> 0.55s
    transport  1.18s -> 0.72s
    nodal      0.34s -> 0.16s

**Peak barely moves there, and that is the honest reading**: 3.51 -> 2.59 GB on
`dispatch`, 4.04 -> 3.97 on `transport`, 1.50 -> 1.49 on `nodal`. HiGHS's own
copy dominates once the model is loaded, so saving 0.67 GB on our side of the
handoff is most of a gigabyte that the process never gets back to show. The
time is what a caller feels.

Both shapes hand the solver the same numbers — the two arms agree to the bit on
a checksum over every bound, cost and integrality flag. Solving dispatch,
transport, sector and nodal at `s` gives the same objective, the same primal
digest and the same row and column counts as before the patch.

Nothing textual crosses into numpy any more. `vtype` and `sense` were compared
in numpy, which means a polars `String` column converts by boxing 10M Python
strings: 0.95s where the same test in polars, handing back a bool, costs 0.04s.
Both comparisons move to the frame side.

`matrix.sort('row')` stays. `row` is not dense *per matrix entry*, so unlike
the columns that is a real sort, and it is what `searchsorted` reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Left behind by the rebuild: `COPY`, `printf`, `DuckdbExecutor`, "aggregate
inside duckdb", and a connection where there are now frames.

The known-issue section goes with it. #109 is closed, so what is worth writing
down is not the bug but why stable output is easy to lose again — which is the
one thing a future sink has to know before it reaches for a group-by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The committed numbers were taken under load — visible in the fact that the
*linopy* arm, whose code nobody touched, ran 30-50% faster on the re-run. A
number measured against a busy machine is not wrong so much as unattributable,
and two changes landed on the sinks since.

Re-run with nothing else above 40% CPU. What actually moved, rather than what
the machine was doing:

- Emit: -57% on `sector`, -40% on `nodal`, -25% on `transport`, +13% on
  `dispatch`, from the one-sorted-stream LP sink. Recorded as a trade rather
  than a win, since one case pays.
- Handoff: 2-5x faster on all three cases in the `solver_direct` table, from
  scattering the columns instead of joining and sorting them.
- `sector` peak reads 0.33x rather than 0.51x, and the prose that quoted
  1.27 GB against 2.79 GB now quotes what the table says.

Also corrected two things that were stale rather than re-measured: the run
command omitted `sector`, which has been a case for a while, and the parity
gate covers four cases, not three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann FBumann added area:engine Lowering, IR, relational executor, sinks perf Wall time, memory, or I/O cost — not a wrong answer labels Jul 27, 2026

@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 (2)
src/farkas/relational/sinks/lp_file.py (2)

121-129: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant pre-sort of terms before the final global sort.

The concatenated frame is re-sorted by ('row', 'ord') at line 135, which already fully determines term order (term ord equals col). The .sort('row', 'col') at line 123 does no useful work for correctness since the subsequent sort supersedes it, but it does add an extra sort pass over matrix — likely the largest table in the model and the one dominating the emit phase at scale.

♻️ Proposed fix
     terms = (
         model.matrix.lazy()
-        .sort('row', 'col')
         .select(
             'row',
             pl.col('col').cast(pl.Int64).alias('ord'),
             (_signed(pl.col('coeff')) + pl.lit(' x') + _digits(pl.col('col'))).alias('line'),
         )
     )
🤖 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/farkas/relational/sinks/lp_file.py` around lines 121 - 129, Remove the
redundant .sort('row', 'col') call from the terms construction chain in the
model emission flow. Keep the existing row, ord, and line selections unchanged,
relying on the later global sort by ('row', 'ord') to determine term order.

65-72: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Binary/integer existence check filters cols twice.

chosen.select(pl.len()).collect() materializes the filtered-and-sorted plan just to check emptiness, then _sink(chosen.select(...), part) re-derives the same filter from scratch. For models with large cols tables this doubles the scan/filter cost per variable-type check.

♻️ Proposed fix
         integrality = []
         for variable_type, keyword in (('binary', 'binary'), ('integer', 'general')):
-            chosen = model.cols.lazy().filter(pl.col('vtype') == variable_type).sort('col')
-            if chosen.select(pl.len()).collect().item() == 0:
-                continue
-            part = parts / keyword
-            integrality.append((keyword, part))
-            _sink(chosen.select(pl.lit('x') + _digits(pl.col('col'))), part)
+            lines = (
+                model.cols.lazy()
+                .filter(pl.col('vtype') == variable_type)
+                .sort('col')
+                .select(pl.lit('x') + _digits(pl.col('col')))
+                .collect()
+            )
+            if lines.is_empty():
+                continue
+            part = parts / keyword
+            integrality.append((keyword, part))
+            _sink(lines.lazy(), part)
🤖 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/farkas/relational/sinks/lp_file.py` around lines 65 - 72, Update the
integrality loop to avoid materializing and scanning each filtered LazyFrame
twice: reuse a single collected result from chosen for both the emptiness check
and the _sink output. Preserve the existing binary/integer filtering, sorting,
part naming, and integrality entries while ensuring each variable type scans
cols only once.
🤖 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/farkas/relational/sinks/lp_file.py`:
- Around line 121-129: Remove the redundant .sort('row', 'col') call from the
terms construction chain in the model emission flow. Keep the existing row, ord,
and line selections unchanged, relying on the later global sort by ('row',
'ord') to determine term order.
- Around line 65-72: Update the integrality loop to avoid materializing and
scanning each filtered LazyFrame twice: reuse a single collected result from
chosen for both the emptiness check and the _sink output. Preserve the existing
binary/integer filtering, sorting, part naming, and integrality entries while
ensuring each variable type scans cols only once.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4437cf02-fe5c-48ce-8ac3-9e53f679c2fd

📥 Commits

Reviewing files that changed from the base of the PR and between 95498ce and 43050f7.

📒 Files selected for processing (7)
  • bench/results/latest.jsonl
  • docs/benchmarks.md
  • src/farkas/relational/executor.py
  • src/farkas/relational/sinks/README.md
  • src/farkas/relational/sinks/highs.py
  • src/farkas/relational/sinks/lp_file.py
  • tests/test_lp_text.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/farkas/relational/sinks/highs.py
  • docs/benchmarks.md
  • src/farkas/relational/executor.py

Conflicts everywhere the two lines touched the same idea from opposite sides:
main improved the duckdb sinks (#195) and this branch deletes duckdb. The SQL
does not survive; the rule it encodes does, and is worth having.

`chunking.py` comes over intact — it is a budget divided by the width of one
unit, which has nothing to do with which engine spends it. `ModelTables` keeps
`row_chunks_by_nonzeros` and `col_chunks`, reimplemented over frames:
`matrix.height` is the nonzero count that `SELECT count(*) FROM A` was there to
get, and `scalar()` goes with the connection it queried. The hand-off spends
the budget through both.

Resolved to this branch's side, with reasons:

- `executor.py` — `_chunk_starts` sized label assignment over a leading dim.
  That pass no longer exists: labels are positional arithmetic, so there is no
  window to chunk.
- `lp_file.py` — main chunked the constraint text by nonzeros because each
  chunk was a `COPY` of a `string_agg`. Here it is one lazy frame handed to
  `sink_csv`, which streams it, so the chunking has no counterpart.
- `docs/benchmarks.md` — the operational findings were duckdb's (`memory_limit`,
  the sort that would not stay inside it). #188's methodology lesson is the
  part that generalises, and it lives in `bench/README.md`, which merged clean.

**The budget is 1e5 elements here, not the 2e6 main settled on**, and that is a
measurement rather than an oversight. On duckdb a wider chunk was worth a third
of the hand-off because every chunk re-ran an ordered query. On this lane the
columns are numpy slices and the rows a filter over an already-sorted frame, so
more chunks cost almost nothing and only residency scales with the budget. At
the `l` rung, 2e6 against 1e5 is 0.50s against 0.59s on `dispatch` and 0.71
against 0.75 on `transport` — for 0.6 GB and 0.2 GB more peak. A tenth of a
second in front of a minute of simplex does not buy half a gigabyte of hard
rule 4.

Both of #195's tests come over, one ported to frames. Solving dispatch,
nodal, sector and transport at `s` gives the same objective, the same primal
digest and the same counts as before the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann and others added 3 commits July 28, 2026 13:37
Every number in this file predated #233, #234 and #238, and was measured
against linopy 0.9.0 rather than the v1-semantics build this branch now pins.
Re-taken in one run: five cases, two sinks, three arms, best of three, plus
the marginal-cost pass. 408 timings and 72 loop records, no failures.

**Ahead on both axes on every case through the hand-off**, which is the sink
most callers use: wall 0.35x, 0.32x, 0.25x, 0.45x, 0.86x and peak 0.95x,
0.84x, 0.32x, 0.76x, 0.88x. `profiled` was the one exception until #238
stopped the duplicate-coordinate check grouping 12M rows to answer a yes/no
question; it is a win now like the rest.

The LP file is the weaker route and stays that way — 0.71x to 1.35x on wall,
and `transport` 1.61x on peak — because most of an LP write is float-to-text,
work neither lane avoids.

Against duckdb: 2.2-5.2x faster on every case and both sinks, and duckdb
1.2-2.9x lighter. duckdb is slower than the eager lane everywhere here.

The parity gate agrees across all three arms at 0.0e+00 on every shared case,
which is also what proves this branch's v1 semantics (#234) and `main`'s
(#239) build the same model rather than merely claiming to.

Provenance now names a commit per arm rather than a version, since two of the
three arms have no meaningful version string — the duckdb arm is a checkout
and the polars arm is an editable install that reports its sync point.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the LP-path half of #244. A memory timeline through `write_lp_file` —
the same treatment that found #206 inside `_build_constraint` — puts the
writer's whole spike in one section:

  section        wall   peak in  peak out   d_peak
  objective     0.20s     1.91G     1.91G   +0.00G
  constraints   0.65s     1.91G     2.79G   +0.88G
  bounds        0.41s     2.79G     2.79G   +0.00G

The objective and bounds each sort too and cost nothing, because they stream.
The constraint section cannot: it concatenates a header, a placeholder, the
terms and a footer, then sorts the lot to interleave them — and that stream
carries *rendered text*, so sorting the whole model at once is what the peak
is.

So it is emitted one row range at a time, through the `row_chunks_by_nonzeros`
budget the HiGHS sink already spends. Ranges ascend and each is internally
sorted, so the bytes are the same ones #109 pins — verified by hash against
the unchunked writer, not merely by the reproducibility test.

Peak at the `l` rung, LP sink:

  transport   3.08-3.11 GB -> 2.20-2.28 GB    1.69x -> 1.20x of linopy
  profiled    3.39-3.41 GB -> 2.72-2.75 GB
  dispatch    2.22-2.27 GB -> 2.02-2.06 GB    1.01x -> 0.91x
  nodal       1.28 GB      -> 1.18-1.21 GB
  sector      0.93-0.94 GB -> 0.92 GB

Wall time is unchanged everywhere. `transport` is no longer the outlier it was
filed as, and `dispatch` crosses from behind to ahead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Full three-arm ladder on `polars-engine@3fb95d6`, which now carries #238, #243
and #245. Five cases, two sinks, three arms, best of three, plus the
marginal-cost pass. 408 timings and 72 loop records, no failures, parity gate
exact across all three engines.

Two numbers moved, both for a stated reason:

  transport LP peak   1.69x -> 1.19x   (#245, chunked constraint emission)
  dispatch  LP peak   1.01x -> 0.95x   (same)

To a loaded solver we are ahead on both axes on all five cases: wall 0.35x,
0.32x, 0.25x, 0.44x, 0.81x and peak 0.95x, 0.84x, 0.33x, 0.77x, 0.88x.

On the LP route only two numbers are still against us — `profiled` 1.34x on
wall and `transport` 1.19x on peak — and the section now says why that route
is structurally weaker: most of an LP write is float-to-text, which neither
lane avoids, so the ratio compresses toward 1.00 however fast the build gets.

Against duckdb: 2.2-5.3x faster on every case and both sinks, duckdb 1.2-2.8x
lighter.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann and others added 2 commits July 28, 2026 16:25
…adder (#247)

* test(bench): a many-declaration case, and the duckdb arm on our own ladder

Two gaps, both about shapes the ladder could not reach.

**`fleet` — many declarations rather than one large one.** Every other case is
a single variable over a large coordinate product, which is the shape a
benchmark reaches for because it scales easily and is *not* the shape a real
model has. `fleet` spends a comparable total across 12 variables and 7
constraints over `(snapshot, unit)`, so what it varies against the rest of the
ladder is the number of declarations and nothing else. The eager lane pays per
declaration too, so it is not a shape chosen to suit us.

Its objective prices every variable the balance can substitute into: a
degenerate optimum would make the parity gate compare 0 == 0 and pass whatever
the engines did.

**The duckdb arm now runs this harness under its interpreter, not its own
`bench/`.** It was limited to whatever ladder that checkout shipped with — no
`sector`, no `fleet`, and no rung above `l`, so the one comparison that
matters at scale could not be made at all. Running our code with their
`farkas` means both arms time the same thing and only the engine differs,
which is what the loop pass already did. `_carries` and its skips go with it.

The nonzero count is read defensively for the same reason: `ModelTables` is
this engine's frame and an older one exposes its own shape, so only the two
totals are common. Counts are harness bookkeeping read after the clock stops.

Also records the scaling run the ladder cannot: at 120M variables on a 25 GB
machine every engine finishes, polars fastest at 28.3 s against 62.0 and 81.7
— and duckdb holds 2.01 GB against our 7.83, which is where an engine that
spills starts to.

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

* docs(bench): the scaling curve, measured rather than sampled

The absurd-size section was two single runs. This is six rungs spanning
12,000x — 10k to 120M variables — on all three engines, best of three, parity
gated, kept in `bench/results/scaling.jsonl` beside the ladder so the question
does not get re-run by hand every time it is asked.

  variables    polars        linopy        duckdb
      10k    0.01s/0.17G   0.20s/0.21G   0.05s/0.16G
      100k   0.03s/0.21G   0.28s/0.26G   0.14s/0.18G
      1M     0.15s/0.47G   0.39s/0.56G   0.40s/0.32G
      10M    1.69s/2.01G   1.73s/2.09G   3.09s/0.73G
      40M    5.07s/5.78G   7.43s/7.93G   19.5s/1.38G
      120M   20.6s/9.44G   38.5s/9.81G   64.3s/1.98G

Nothing falls over at any size, and polars is fastest at every rung. The
finding is the *shape*: over that range duckdb's peak grows 12x and polars'
grows 56x, because one spills and the other keeps the model resident. The
earlier single runs had the ordering right and the crossover wrong.

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

* test(bench): every case reaches xl and 2xl, not just dispatch

The scaling question — does an engine that keeps the model resident hold up
where one that spills would — could only be asked of `dispatch`, because it
was the only case whose ladder went past `l`. One case measures scale; it says
nothing about whether shape changes the answer, and shape is what the other
five cases exist to vary.

Every ladder now grows by the same two factors dispatch already used: `xl` is
4x the `l` rung and `2xl` is 12x. Same factors everywhere, so the top rungs
are comparable with each other rather than each case inventing its own idea of
"large".

  dispatch  10M -> 40M  -> 120M
  fleet     12M -> 48M  -> 144M
  nodal     12M -> 48M  -> 144M
  profiled  12M -> 48M  -> 144M
  sector    17M -> 68M  -> 204M   (8.3% live, so ~17M actual variables)
  transport 10M -> 39M  -> 118M

`xs`..`l` stays the published ladder — the range the cross-case tables compare
over. The two top rungs answer a different question and are run separately,
which is why the comment saying so lives next to the labels.

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

* test(bench): run duckdb at its own unlimited as well as a budget

Every duckdb number this branch has published was measured at `memory_limit`
1GB — that engine's default, and the harness never said so. It is not a
property of the engine: it is a ceiling the engine was told to hold, which it
does by spilling to disk and paying wall time for it. Comparing that against a
lane with no such knob measures the budget, not the engine.

The arm now runs at both, reported separately: `duckdb` passes duckdb's own
unlimited (`-1`) and is the engine-to-engine comparison; `duckdb@1GB` is the
engine plus a promise, which is what that architecture was for.

Passing `-1` explicitly rather than omitting the flag matters — omitting it
falls through to the checkout's 1GB default, so an arm labelled unbounded
would silently be another budgeted one. That bug was in the first version of
this change and `fleet/l` caught it by reporting two identical rows.

On `fleet/xl` the two arms are not close: unbounded builds in 25.8s at
2.42 GB, and 1GB fails outright.

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

* docs(bench): duckdb at its own unlimited, and what the budget was hiding

Every duckdb number this file published was measured at `memory_limit` 1GB —
that engine's default — and the file never said so. Re-run with the arm at
both settings, and the correction goes in both directions.

**Against my earlier framing:** unbounded duckdb holds 5.28 GB at 120M
variables against the budgeted run's 2.11 and polars' 8.30. So the
"duckdb is 2-3x lighter" reading was substantially the budget doing its job,
not the engine needing less. Engine to engine the memory gap at the top is
~1.6x.

**Against my worry that the budget was a handicap:** on `dispatch` it costs
almost no wall time — 0.99x, 0.89x, 1.08x at the top three rungs — while
saving up to 2.5x of peak. On this shape the ceiling is close to free, which
is an argument *for* that architecture.

**Where it is not free:** `fleet/xl`, twelve declarations rather than one,
fails outright at 1GB and builds in 26.4s inside 2.40 GB at 8GB. A budget
below what the shape needs is not something spilling can fix.

polars is still fastest at every rung, 2.1-3.8x against duckdb unbounded.

Also records that `2xl` is noisy between runs — polars 20.6s and 35.4s,
linopy 38.5s and 106.2s — because 8-10 GB of peak on a 25 GB machine is where
other things start to matter. The ordering holds; the seconds should not be
quoted.

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

* feat(bench): give each duckdb budget its own column

`--duckdb-limits none 1GB` produces two arms, `duckdb` and `duckdb@1GB`, and
the report knew about neither: `ARMS` listed three names, so the budgeted rows
were read into `best()` and then never looked up. The published `duckdb`
column was the unbounded arm and the budgeted one was silently dropped — a
table that looks complete while half the run is missing from it.

The columns are now read off the results rather than listed, so a run with
`--duckdb-limits 1GB 8GB` gets both without touching this file. Collapsing
them was never an option: the two settings differ by 2.5x of peak at 40M, and
one column holding whichever was faster describes neither.

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

* docs(bench): six models, four arms, and a chart page to read them in

Re-runs the whole ladder with `fleet` in it and duckdb at both settings, and
replaces the ladder tables and the summary above them from that one file.

The summary was stale in a way worth naming: it claimed `profiled` had become
"a win like the rest". It has not. At `l` through `highs` it is 1.26x — 3.63 s
against linopy's 2.88 — and it is the only case in the set where linopy is
ahead at any rung of any sink. It has the most distinct variables and the most
per-variable arithmetic of the six, which is the shape that suits a dense
array best and a frame least. Named rather than averaged away.

`docs/benchmarks-scaling.html` plots the same run three ways, because one view
answers one question:

- a **band** per engine — min-max across all six models at each rung, against
  linopy so the size trend divides out and what is left is spread across model
  *shape*. On wall time the polars band clears duckdb's outright through `m`
  and touches only at `l`, where `profiled` is the extreme of both. On peak
  they overlap at every rung, which is the honest answer: against linopy
  neither engine is decisively lighter, and the gap only resolves engine to
  engine (duckdb 1.28-1.63x lighter at `l`, on all six).
- the **ladder past `l`**, to 120M variables, with duckdb drawn at both
  budgets so "the budget is not what made it slow" is visible rather than
  asserted.
- **six panels**, one per model, for the detail a band flattens.

Self-contained: no CDN, no fetch, the numbers inline. `.gitignore` excluded it
under the rendered-output rule, which is wrong for a file `benchmarks.md`
links to — negated with the reason.

The loop pass and the density sweep still come from an earlier run, since
`--out` truncates and neither survives the same invocation as the ladder. Both
now say so, and the density prose is corrected to the table under it.

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

* docs(bench): re-measure the six-case ladder on an idle machine

The previous version of this ladder was taken while the same laptop was doing
other work, and I published a conclusion from it: that `profiled` is "the one
case we lose", 3.63 s against linopy's 2.88. A quiet best-of-three gives 2.84 s
against 2.61 — level, not lost. Every case moved in the same direction under
load, so the ordering survived, but the one claim that turned on a margin did
not. `docs/benchmarks.md` now says to measure on an idle machine, and why.

Six cases, four arms (duckdb at `-1` and at its 1 GB default), both sinks,
best of three, plus the marginal-cost pass. 655 timings, no failures, parity
gate exact across all three engines on all six.

What the fresh numbers say, at `l` through `highs`: ahead of linopy on peak on
all six and on wall on five, `profiled` level at 1.09x. Through the LP sink,
where there is no HiGHS copy to dilute it, `profiled` is an unambiguous 1.46x
and `fleet` 1.64x on peak — COO's `(row, col, coeff)` triple per nonzero
against a dense array's single float. Against duckdb: 2.6-5.2x faster through
`highs`, and duckdb 1.29-1.49x lighter.

The mask sweep moves to its own results file. `--out` replaces rather than
appends, so a run narrower than the tables it publishes leaves them with no
provenance while still looking complete; the report already accepted several
files, so the ladder and the sweep are now taken separately and both passed in.

`docs/benchmarks-scaling.html` is regenerated from the same run. The band
section changes conclusion with it: the wall bands now do not overlap at *any*
rung — polars' worst model is faster than duckdb's best one at every one of
the four — where under load they touched at `l`. The peak bands still overlap
against linopy throughout, and that gap still only resolves engine to engine.

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

* style(bench): quote style on the --duckdb-limits help text

Left over from the commit that added the flag; `ruff format --check` is gated
in CI and this was the one file failing it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.release-please-manifest.json
#	CHANGELOG.md
#	bench/README.md
#	bench/_run_case.py
#	bench/cases.py
#	bench/report.py
#	bench/run.py
#	examples/walkthrough.out
#	examples/walkthrough.py
#	pyproject.toml
#	src/farkas/relational/compiler.py
#	src/farkas/relational/executor.py
#	src/farkas/relational/sinks/highs.py
#	tests/test_group_sum.py
#	tests/test_milp.py
#	tests/test_objective.py
#	tests/test_relational.py
@FBumann
FBumann merged commit c11a0dd into main Jul 28, 2026
2 checks passed
FBumann added a commit that referenced this pull request Jul 28, 2026
#253)

duckdb stopped being a dependency when #189 landed. The arm that measured it
was there to answer one question — was replacing it worth doing — and that
question is closed, so what is left is a column nobody can re-run without
keeping a foreign checkout synced indefinitely. The comparison itself survives
in #189 and in git, which is where a decided decision belongs.

**Harness.** `FOREIGN_ARMS`, `--duckdb-root`, `--duckdb-limits`, the
foreign-interpreter plumbing in `_child`, and `--memory-limit` in `_run_case`
all go. `_child` is now four lines of `subprocess` with no notion of running
another checkout's code under another checkout's python. The report drops to
two arms, which takes the ladder tables from fourteen columns to ten.

**docs/benchmarks.md: 649 -> 419 lines.** It had become the largest file in the
repo — larger than SPEC.md — and most of the excess was argument for the
engine choice rather than measurement of the engine we have. The tables are the
same measurements with the duckdb columns dropped, not a re-run.

What that argument was hiding is worth keeping, and is now stated plainly: at
120M variables the peak ratio is **1.07x**. Past a certain size both lanes hold
the same model and the representation stops deciding anything, so the memory
headroom this lane has is a small-and-sparse-model property rather than a
scaling one. The old section reached the opposite impression by reading duckdb's
budgeted column as an engine measurement.

**The chart page** loses its third and fourth series with the arm. The band
section changes question with it — from "is the ordering between three engines
robust to model shape" to "does the advantage over linopy hold whatever the
model looks like" — which the two-arm data answers directly: clear of 1.0 at
every rung but `profiled/l` on time, straddling it on peak with a threefold
spread between `sector` and `dispatch`. Palette re-validated at two slots
(worst CVD dE 24.7 light / 26.8 dark, no contrast warning).

**SPEC.md** still justified refusing a stray dim with "under a memory budget
the first quietly builds a bigger model" — there is no budget now, and the
clause is stronger without it.

423 passed / 1 xfailed, ruff and pyrefly clean.
This was referenced Jul 28, 2026
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, sinks decision A question to be answered, not work to be done; closes by resolution engine:polars Specific to the polars rewrite (#189) — invalidated if duckdb stays 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.

2 participants