Roadmap¶
Why this project exists, and what it is becoming. No work items live here. The work is issues, grouped under three parents:
An index maintained by hand beside an issue tracker is a second copy that drifts, so there is not one here: the issues are the list, and this page is the argument for what the list is for.
Why¶
An optimisation model is math, and math is worth reading. It usually arrives as Python that builds math — the equations entangled with the loops, the frames and the library that assembled them, so a diff shows scaffolding rather than constraints and nothing can read the model except the program that wrote it. Reviewing such a model means reviewing a program, which is a different and much harder job.
lpspec makes the math the artifact. A YAML file says what the variables, constraints and objective are; the file is validated at load time, built at runtime, and can be reviewed by someone who understands the math without understanding the builder. That is the whole thesis, and every rule below is downstream of it.
Where it is going¶
One language, more than one place to run it. The same file builds natively
on the relational engine or onto a linopy.Model that already exists in memory.
Not a fallback and not a dialect — one language, so a differential test between
the two lanes is an oracle rather than a comparison.
A build that streams, with a ceiling you can declare. The model is frames and the build is relational, so nothing dense is ever materialised and peak tracks the model rather than a number someone guessed. What is missing is the declaration: there is no way to say "build this within N gigabytes or fail". The honest version is partition-wise execution, which the locality closure already guarantees is safe.
Answers, not just solutions. A solved model should tell you why it is infeasible, what a row costs, and what changed since the last solve — without opening a file no editor can hold. Most of that is a query over frames that already exist.
Component libraries, composed rather than generated. A fixed set of parametrised templates agreeing on a port/flow convention, merged into one program before a single build pass. Topology is data — wiring a system is rows in a connectivity table, never generated YAML — so structure stays bounded by the number of component types while cardinality lives entirely in data.
What it will not become¶
Two durable losses, and they are the price of the closed AST. Structure that needs the solver's answer to decide the next row, inside one plan; and imperative modeling at all. What that price buys is load-time validation, two lanes on one language, and a build that streams. Everything else is scheduling.
Everything a model needs after "it builds". Mostly queries over frames we already materialise.
- Reading results. Duals have shipped; reduced costs and slacks ride the same join and have not. Derived results (LCOE, curtailment, emissions by group) are SQL over the solution tables.
- Infeasibility. HiGHS has no IIS — Gurobi does, but an answer only the opt-in sink can give is not the answer — so it is elastic relaxation (#80) — slacks with penalty costs, then a query of nonzero slacks grouped by block. Needs no solver feature and works on every sink. Taxed like a primitive, since new variables mean a schema-level expansion pass.
- The REPL gap is verbs, not structure. A built model is four polars frames,
which answers "what is in row 12" with a filter where a labelled
Datasetneeds a scatter —build()already returns the live executor, it just has nothing readable on it. Render a bound row, preview which rows survive awhere, evaluate an expression against bound data, coefficient ranges, model statistics. All read-only: inspecting a built model, never declaring one, which is the line that keeps this cheap and rule 5 intact. - Lifecycle.
var_labelis the solver column index with no remapping, so value-only re-solve is a label query pluschangeColsBounds, integrality is the same contract (changeColsIntegrality, already called at build), and appending rows moves no label at all. The session that holds the handle is #204; warm starts are #382, and they are what make rebuild-instead-of-edit cheap rather than merely correct. - Decomposition. Benders and successive substitution, and the shape
favours us — worth saying because the opposite is the natural assumption.
Decomposition wants sparse triplets plus label tables; that is the model
here, so the master/sub split is a
GROUP BYoverA(#39), cut coefficients areduals ⋈ subproblem rows, and a cut lands with zero translation. The open question is not feasibility but who writes the cut (#381). - AST consumers. The first has shipped —
to_latex/to_typst/to_markdown, one tree walk, no data, no solver. Remaining: CLI (#35), observability (#34).
Track 3 — capabilities, and the degree line¶
The ceiling and sink capability are two axes. A declared capability set per
sink (not per solver — lp_file is not a solver but has capabilities),
modelled on linopy's Solver.features with two divergences: entries are
three-valued (native / reformulated / absent) so satisfying one by
reformulation is additive later, and the model expresses conjunction
exclusions, because linopy declares HiGHS with INTEGER_VARIABLES and
QUADRATIC_OBJECTIVE in one flat frozenset while HiGHS refuses the pair.
check(model, sink=...) takes the sink optionally. Design:
#89.
That unblocks three things, in order of effort:
| Blocked on | Note | |
|---|---|---|
| Semi-continuous | nothing | HiGHS has kSemiContinuous natively and linopy has the oracle — #383 |
| SOS / indicator | the capability model | lp_file carries SOS as a text section, Gurobi natively, HiGHS not at all — #23 |
| Quadratic | the capability model — the second solver ships | below |
Quadratic is planned, not refused. The cost side is settled and small, and
the oracle is free — linopy's QuadraticExpression builds the comparison, which
is normally the expensive half of a primitive. Performance is not the question.
The blocker is where it can land: HiGHS returns kError for
Hessian + integrality, and binary:, integer: and nonconvex piecewise: all
ship today, so on the default path quadratic conflicts with features already in
the language. That is a capability finding, not a reason to refuse the math — so
it needs the table above and a solver without the exclusion. The second
solver is no longer the blocking half: the gurobi solver ships
(#106), and Gurobi takes a
Hessian alongside integrality. What is left is the capability table, without
which landing the primitive would ship math the default solver refuses.
Until then piecewise: {convex: true} and the epigraph pattern are the answer
for convex 1-D curves — and they keep the LP duals, warm starts and MILP
compatibility a quadratic objective gives up, so they stay the preferred
spelling even after quadratic lands. The scope, the lowering, and whether
coordinate-aligned is the right restriction at all, are
#261 and
#84.
Track 4 — the memory axis¶
Both engines hold the model they build, so peak tracks the model rather than a number the caller sets. That is the right default and it is what makes the lifetime disappear from the API, but there is no declared ceiling — no way to say "build this within N gigabytes or fail".
Choosing an engine is not the answer, and that is worth knowing before this track is picked up. The figure this section used to quote — 2.1–4.2x less memory on the write path — was measured on the engine the in-tree duckdb one was ported from, and the port has never reproduced it: peak lands between 0.90x and 1.84x of the polars engine's, lighter on two rungs of fourteen and heavier on eleven (bench/duckdb-spike.md). It was briefly ahead on speed at the top of the ladder; the polars perf series has since taken that back too. duckdb is the default regardless — being behind on the ladder is what makes it the engine worth having under CI's instruments unasked — but the default is not what closes this gap and was never meant to be.
But it locates the ceiling, which is worth more than a ratio. duckdb does
have the knob this section asks for — SET memory_limit, and it spills past it.
Setting it shows the knob is aimed at the wrong half: dispatch/xl, 40M
columns, engine capped at 500 MB, builds unchanged and peaks at 4.49 GB —
the same figure, to the last measured digit, as the uncapped build. Its own
working set was already under the cap, so there is nothing for the knob to bind
on, and no engine-side limit reaches peak.
What a bound would have to be declared over has moved, though. The frames
are 1.65 GB of that 4.49 — CSR halved the matrix on both engines — so peak
is no longer mostly the output the way this section used to say it was. The
build transient is now the larger term, on polars (4.02 GB against the same
1.65) as much as on duckdb. A ceiling declared over ModelTables would miss
most of what a build costs.
What remains is a declared bound, and the honest version is partition-wise
execution, which the locality closure already guarantees is safe. Measured on
polars it takes 23–36% off peak and then floors, because the matrix stops being
the binding term while the label frames, cols, rows, obj and the
parameters stay resident. That floor and the transient above are the same
finding from two directions, and between them they are what this track has to
answer. Worth most for the write path either way: the solver is the larger term
by roughly an order of magnitude at 10⁷ variables (benchmarks).
What we will not build¶
The specific refusals — data prep, arbitrary array ops, domain helpers, normalisation, in-plan conditionals, a Python modeling API — are in the ceiling, with the reason and the rewrite for each. Read them before proposing a feature: parity with another tool is not by itself a reason to add anything.
Honest snapshot¶
Cheaper here, because the model is tables: model statistics and coefficient-range diagnostics; IIS read-back (a join, not a scatter); serialization to parquet; elastic relaxation; dualization, since transposing a COO matrix is swapping two column names.
Ahead of comparable declarative layers: sparse-by-construction build with no
dense intermediate, and a hand-off straight to the solver rather than through a
file; parameterised macros: (Calliope's sub-expressions
take no arguments);
binary and integer variables; piecewise as N links with per-link signs, convex
mode and active gating; load-time validation of every expression, where
string and uncalled macro template.
Behind linopy, and none of it a ceiling question: the post-solve object
(labelled DataArrays vs tidy tables — to_dataarray bridges), debugging (IIS
via Gurobi, print() of a row), lifecycle (mutate, re-solve, warm start,
relax/fix), solver breadth (ten backends and four handoffs vs HiGHS-direct
plus LP files), and the variable types and capabilities behind the capability
model.
The ranking this implies: indexed access blocks whole model classes today;
the operational verbs block using the engine at 3am; solver breadth blocks
arrival from linopy at all; semi-continuous and cumsum-over-data are cheap,
unblocked and unscheduled.