Architecture¶
How the package is put together, for anyone who changes its structure or adds a sink or an operator. A PR that changes the structure described here updates this page.
The walkthrough runs the pipeline stage by stage. Its output,
walkthrough.out,
is asserted line for line by tests/test_walkthrough.py.
The pipeline¶
A spec is a closed AST known before any data is touched, so it can be
built two ways that mean the same thing. The package's build is the
relational lane (relational/), a plan streamed to a sink. The test suite's
is the oracle (tests/linopy_lane/), a linopy.Model.
flowchart TB
Y[YAML file] --> SPEC
DATA[("your data<br/>parquet · polars · any Arrow table")] --> SRC
subgraph MS["mathspec — another package, pinned in pyproject.toml: read · expand · resolve · judge · lower"]
SPEC["<b>Spec</b> — what the file says<br/>fully resolved: names typed, dims checked, degree judged"]
SPEC --> TS["typesetting/<br/>latex · typst · markdown<br/><i>a consumer, not a stage</i>"]
SPEC -->|".program"| PLAN["<b>Program</b> — what it means, the narrow waist<br/>the plan both lanes build from<br/>closed from both sides"]
end
SPEC -->|"the declarations to attach against"| SRC
SRC["<b>sources.py</b> — flat<br/>data → the tidy tables, by name<br/><i>the one door both lanes enter</i>"]
PLAN -->|"outside the plan:<br/>LanguageError naming the construct"| ERR["load error<br/>(no fallback)"]
PLAN -->|"the plan"| COMP
SRC --> ATTACH
PLAN -->|"the plan, the same one"| BUILD
SRC --> LOAD
subgraph REL["relational/ — the streaming lane"]
direction TB
subgraph ENG["engine/ — what the contract around it is written against"]
direction TB
COMP["compiler.py<br/>plan → lazy queries · reads nothing"] --> ENGINE
ATTACH["attaching.py<br/>→ AttachedSources, frozen"] --> ENGINE["assembly.py + labels.py<br/>assemble the model tables"]
end
ENG --> TABLES["sinks/handoff.py<br/>cols · obj · rows · A · sos"]
TABLES --> LPS["sinks/writers/<br/>a file, chosen by suffix<br/>lp_file · mps_file"]
TABLES --> DIRECT["sinks/solvers/<br/>the matrix in one call → the solver, chosen by name<br/>highs (ships) · gurobi · xpress (extras)"]
DIRECT --> SOL["result.py<br/>label join, never dense"]
end
SOL --> ANS["<b>Result</b> — the lane runs to the answer<br/>objective · primal · dual · activity · evaluate<br/>polars tables you can join"]
subgraph LIN["tests/linopy_lane/ — the test oracle, not shipped"]
direction TB
LOAD["loader.py<br/>the tidy tables → xr.Dataset"] --> BUILD["builder.py<br/>evaluate the plan"]
end
BUILD --> MODEL["<b>a linopy.Model</b> — the oracle stops here<br/>solved by the tests, and compared with the Result"]
classDef laneL stroke:#b7791f,stroke-width:2px
classDef laneR stroke:#3a7d44,stroke-width:2px
classDef laneE stroke:#4a5fc1,stroke-width:2px
classDef laneT stroke:#8b3a7d,stroke-width:2px
classDef waist stroke:#4a5fc1,stroke-width:3px
classDef flat stroke:#8a8578,stroke-width:2px,stroke-dasharray:4 3
classDef data stroke:#b7791f,stroke-width:1.5px
classDef out stroke:#3a7d44,stroke-width:2px
class MS laneL
class REL laneR
class LIN laneE
class TS laneT
class PLAN waist
class SRC flat
class DATA data
class ANS,MODEL out
- The language is another package.
mathspecreads a file and lowers it to aProgram. It cannot import anything here. - Data enters through one door, and both lanes use it.
sources.tidy_sourcesreads every shape the data contract accepts into tidy polars tables, andassumptions.pychecks a spec's assumptions there. - One gate decides what either lane builds.
inputs.loweredreturns aProgramor raisesLanguageError.DataErroris what a source failed to supply. A spec the language accepts and this lane cannot build raisesSpecsolveError, naming the rewrite.
The Python surface¶
Three modules hold the surface. specsolve holds what a caller calls,
specsolve.types every type a call returns, and specsolve.errors what a call
raises or warns, the language's errors included. Every other module is
internal. tests/test_architecture.py pins each __all__, and
the Python API renders the docstrings.
Nothing on the surface constructs math or reaches the plan. The YAML file is the model, and Python runs it.
Hard rules¶
tests/test_architecture.py encodes these, and CI's bare-install job proves
the dependency claims.
- The layers are ordered, and imports prove it. Every module imports only
downward, at module level. An undeclared in-function import fails the build
(
DELIBERATE_LAZY_IMPORTS). - Core AST is the whole language, and the language is upstream. The AST
that crosses is fully resolved: macros and named expressions without
cases:are substituted, andSpec.expandexpandspiecewise:first. Everymathspecimport names the package, never a module inside it. - The engine knows nothing about linopy, xarray or YAML. It imports
nothing from the package but
errors.pyandmessages.py(ENGINE_MAY_IMPORT), and no contract module names a module inside it. - One language, two builds, and linopy is only the oracle. Both pass
inputs.lowered, and nothing undersrc/imports linopy. The three files that build on one side only are in Verification. - A YAML file is self-contained. No Python-side state may change what a file means.
- The public interface is a declared spec, not a Python API. There is no API to construct a spec, hand in a plan, or populate a registry (the Python surface).
The plan, node for node¶
The plan is the vocabulary both lanes speak. The table gives what the file
writes and what the relational query does with it. The oracle's call is in
what a construct becomes in the oracle.
tests/test_docs_site.py holds the table to mathspec.program.Expression's
subclasses.
Fan-in says how an output row's slots relate to the input's
(pieces.fan_in). Anything but one-to-one mixes several slots into one row, so
absence is pushed into the operand first
(#1142).
| plan node | the file writes | fan-in | the relational query |
|---|---|---|---|
Constant |
a number | one-to-one | a one-row const piece |
Parameter |
a declared name | one-to-one | its table as (dims…, cval) |
Variable |
a declared name | one-to-one | (dims…, var_label, coeff=1), plus where it exists; at a read, its primal as a const piece with the same presence, a zero at every absent slot under absence: zero |
Dual |
dual(c) |
one-to-one | at a read only: the constraint's rows beside its share of the dual vector, a const piece present exactly where a row stands |
Negate |
-x |
one-to-one | the value column negated |
Add |
x + y, x - y |
one-to-one | the two piece lists concatenated |
Multiply |
x * y |
one-to-one | a join on the shared dims; two variable factors pair into a quadratic piece |
Divide |
x / p |
one-to-one | a left join, so a divisor parameter with no row leaves a null to report; where the divisor is absent, so is the quotient; a divisor with a reduction under it is added up to one value per coordinate first; at a read, a divisor that is zero makes the quotient absent |
Power |
p ** q |
one-to-one | an inner join and pow, each side with a reduction under it added up to one value per coordinate first |
Sum |
sum(x), sum(x, over=d) |
many-to-one | the summed dims projected away — no aggregate |
GroupSum |
sum(x, by=r, over=c, into=d) |
many-to-one | one inner join with the relation's table on the columns the direction consumes and joins on, the consumed dims traded for the produced ones; a bare relation fans a member out to every target |
Pullback |
at(x, by=r, over=d, into=c) |
one-to-one | the same table joined the other way, fanning out |
Translate |
shift(x, along=d, offset=n) |
one-to-one | a remap through the dimension's ord, modulo its size under wrap |
WindowSum |
sum_back(x, along=d, window=w) |
one-to-many | a row lands at every position whose window reaches it — no aggregate |
Cases |
a named expression's cases: block |
one-to-one | each region's value cut to its own mask and the piece lists concatenated |
Named |
the name of an expressions: entry |
one-to-one | its body, compiled where the name stands |
The engine only builds. Every refusal about the shape of a file is made
upstream. The engine raises DataError about the data, and SpecsolveError
for the one construct the language accepts and this lane cannot build (#1137).
Neither reduction aggregates. A Sum drops columns, a GroupSum swaps
them and a WindowSum replicates rows. Every duplicate collapses once, in the
terminal SUM(coeff) GROUP BY row, col at assembly.
The relational lane¶
The spine is one module per box: attaching.py, compiler.py,
assembly.py, sinks/, and engine.py, which runs the lifecycle. scope.py
sits under them all.
AttachedSources and BuiltModel are frozen, so a build that raises
leaves no model.
Tables are tidy. Parameters are (dims…, value). A variable table is
(dims…, var_label). A linear expression is (dims…, var_label, coeff) plus a
constant part. A mask is a missing row, and broadcasting is a join.
Labels are dense 0..n-1, row-major over the masked coordinate product.
So var_label is the solver column, row is the solver row, and a build is
the same from run to run. update puts new bounds, costs and right-hand sides
onto a loaded solver by position.
The plan is affine. No node adds variables or constraints. Binary and
integer are a column type, sos: is a fourth stream, and Spec.expand writes
a formulation out.
A frame is the boundary in both directions. frames.py reads a caller's
table through the Arrow PyCapsule protocol, and a Result hands back polars.
Sinks take four streams: cols, rows, A in CSR, and sos. A sink
declares what it can ingest (sinks/capabilities.py). A solver is chosen by
name from SOLVERS, and a writer by suffix from WRITERS.
Module map¶
| Module | Role |
|---|---|
mathspec (a dependency) |
the whole language |
api.py |
the functions, and load_result / scan_result |
archive_layout.py |
what an archive holds |
archive.py |
reads an archive back: load_archive, scan_archive and load_inputs |
inputs.py |
Buildable and Source, what every function takes |
relational/collect.py |
how polars materialises a frame |
relational/parquet.py |
every parquet file, through polars, or pyarrow where polars has none |
sources.py |
the door: caller data into tidy tables |
assumptions.py |
a spec's assumptions: and a curve's conditions, checked against the data |
frames.py |
caller tables in, via the Arrow PyCapsule protocol |
types.py |
public: every type a call returns |
errors.py |
public: the error tree |
messages.py |
a wording two modules raise |
strategy.py |
a sweep's driver: one plan per slice, folded |
axes.py |
how a sweep cuts its sources, and the stitch back |
sweep.py |
Sweep, its spill on disk, and load_sweep / scan_sweep |
relational/engine/scope.py |
the program, its attached data and the variable frames built so far |
relational/engine/compiler.py |
plan → lazy queries |
relational/engine/relations.py |
a relation's table as a walk reads it |
relational/engine/shifts.py |
shift and sum_back |
relational/engine/predicates.py |
a where: mask as a boolean query |
relational/engine/pieces.py |
the additive pieces an expression compiles to |
relational/status.py |
solve outcome on two axes |
relational/engine/labels.py |
which coordinate gets which solver index |
relational/engine/attaching.py |
the door's tables → AttachedSources |
relational/engine/assembly.py |
one build: every declaration into the model tables |
relational/engine/coverage.py |
is the data there where a declaration reads it |
relational/engine/readback.py |
a row, a solve and a named expression, read back by label |
relational/engine/engine.py |
the lifecycle: build, hand to a sink, read back |
relational/result.py |
what a solve returned |
relational/answer_layout.py |
an answer on disk, and the ANSWER_LAYOUT stamp |
relational/names.py |
the columns specsolve writes beside a spec's own |
relational/sinks/handoff.py |
what every sink reads |
relational/sinks/capabilities.py |
what a sink can ingest |
relational/sinks/ |
how a built model leaves: solvers/ by name, writers/ by suffix (README) |
relational/ imports nothing else from the package but errors.py and
messages.py. No module imports linopy, and none imports xarray at module
level.
Internal names¶
The names the code uses for its own parts. The names a caller meets are in the glossary.
- Lane
- One way to build a spec: the relational lane or the oracle.
- The door
sources.tidy_sources, the one function both lanes read data through.- Lowering
- Turning a spec into its
Program, throughinputs.lowered. - Attached sources
- The tidy tables as every query reads them: a dimension as
(val, ord), a label column as anEnumin declared order. - Piece, presence and region
- One additive part of a compiled expression, over its dims; where the
variable under it exists; the part of a
cases:block it covers (pieces.py). - Label
- The solver's index of one column or row (
labels.py). - Collect engine
- Which polars engine materialises a frame. A build within
IN_MEMORY_REACHcolumns and rows uses the in-memory engine (collect.py). - Parquet backend
- Which library reads and writes a parquet file: polars, or pyarrow where
polars has none, as in the browser (
parquet.py). - Structure digest
- A digest of everything a re-solve may not change. A loaded solver keeps its
model while the digest holds and every coefficient is within 1e-12 relative
of the one it holds (
handoff.py). - Kind
- One frame family of an answer:
primal,dual,expression, and each outputoutputs=asked for (OUTPUT_KINDS). - Run stamp
- The
specsolve_runcolumn an archive adds to every table. Names that start withspecsolve_are reserved. - Slice, stitch and spill
- One model of a sweep; windowed frames put back over the dimension they cut;
a sweep's answers on disk (
axes.py,sweep.py). - Carry and the wire
- One slice's answer copied into the next slice's data; how a slice's sources
cross to a worker (
strategy.py).
Extension checklists¶
Add a macro or named expression: edit YAML.
Add a sink: a module in relational/sinks/solvers/ with one line in
SOLVERS, or one in writers/ keyed by suffix in WRITERS. Its dependency
sits behind an extra, imported inside the function. The
README
is the full list.
Add a consumer of the AST: a package of its own that depends on
mathspec, not on this one.
Add an operator: first in mathspec, landed and tagged. Then here: the oracle's linopy call, the compiler case, the engine, and a differential test through a solver and the LP writer. The nightly canary checks the two halves have not drifted.