The parameters are an artifact too¶
Status: Draft / decision record, written before the work.
Companion: lpspec-direction.md, whose step 6 this
finishes and sharpens, and validation-layers.md,
whose layer 3 it half closes.
fluxopt's math is a file. Its numbers are not — they are computed at every solve and thrown away. This page argues they should have the same status as the math, says what that changes, and says plainly where the symmetry stops.
Where we are¶
The tidy-frames re-cut already happened at the data layer. contract.py went
from 117 lines to 54; NaN-as-absent is gone in favour of row absence;
BoundType is gone; the ModelData containers are polars frames; and
ModelData.save / load is a directory of parquet, one file per frame.
What did not happen is the half this page is about. The artifact we store is
fluxopt's own schema — flows.sizes, flows.envelope, sizing.bounds,
effects — while the thing lpspec binds is the program's 93 parameter
tables. math/sources.py stands between them, 1089 lines, running at every
solve and producing something no caller can see or keep.
So the two halves of a solve have unequal standing:
| math | parameters | |
|---|---|---|
| artifact | math/program.yaml, shipped package data |
none |
| readable | system.math() → lpspec.Model |
nothing |
| persisted | in the repo | only indirectly, as the ModelData parquet tree |
| handed back | optimize(math=...) |
optimize(parameters=...) — but you cannot read what fluxopt bound |
That last row is the tell. A caller can read, edit, diff and typeset the
equations, and then supply data for a constraint of their own through a
channel whose existing contents are invisible to them. solve checks exactly
one thing about the supply: that it does not collide with a name the program
already binds.
The decision¶
system.parameters(profiles=...) returns the bound parameter set, it
round-trips to a parquet directory, and optimize takes it back.
(program.yaml, parameters/) is then the whole problem: hashable, diffable,
solvable without fluxopt.
The larger consequence, and the reason this is worth doing rather than merely nice, is that it forces a question the current shape lets us avoid: do we keep two schemas, or is the stored middle layer the program's parameter set?
today
elements ──▶ ModelData ──▶ build_sources ──▶ {93 param tables} ──▶ lpspec
parquet dir 1089 lines ephemeral
fluxopt schema at solve time
proposed
elements ──▶ build ──▶ parameters/ ──▶ lpspec
parquet dir, program schema, stored
│
labels/ ──▶ results · stats · plot
The middle layer stops being a schema of our own that gets translated, and becomes the binding itself, persisted.
What it changes¶
One schema instead of two¶
contract.py's vocabulary, the container classes and program.yaml's 93
declarations state overlapping facts, and sources.py is the translation
between them. The renaming half of that collapses.
Only the renaming half. A large part of sources.py is computation that
has to happen somewhere whatever the container is: the Leontief fold,
_size_upper's big-M, composing an absolute envelope from size × relative,
mapping time labels to the ordinals the program indexes by, and cross-joining
PERIOD_PARAMS onto every period. Under this shape that code is the build
rather than a second pass after one — which is the saving. It does not
disappear, and a plan that counts it as deleted is counting wrong.
Validation moves to the layer that can answer¶
validation-layers.md keeps layer 3 partly alive for
reload: a parquet tree that was hand-edited, or written by an older version,
never passed through layers 1 and 2, so each container re-checks what its
element already refused. That page already anticipates the reload half closing
if a reloaded file were rebuilt through ModelData.build.
Storing the parameters gives a stronger version of the same result. A reloaded
parameter set is checked by lpspec against the program it will be bound
to — layer 4, which that page names as the best checker and the one fluxopt
must not reimplement. Unknown labels, a float column where the program says
bool, a constant side the parameters do not cover: all of it, with the
program in hand.
The ProfileRef half of layer 3 does not move and should not. A rule about
values that arrive later has to be checked later.
Results need a labels table, and the split is clean¶
The program already carries topology, as lookups: — carrier_of,
converter_of, charge_storage, discharge_storage, status_of,
pw_status_of. That is what Result.topology() wants.
What it does not carry, and never will, is carrier.unit, carrier.color
and carrier.description. The math has no use for them, so they are not
parameters, and no amount of tidying will make them into some. So the division
is honest rather than a fudge:
the program carries what the math needs; a labels table carries what presentation needs.
results.py, stats.py, contributions.py and math/results.py read the
labels table plus a handful of quantities — dt, flow_hour_weight, the
period weights — that are already bound parameters and can be read as such.
The parameter set is lossy, in the one direction that matters¶
The fold and the pre-scaling dissolve declared coefficients into solver coefficients. A stored parameter set therefore cannot answer "what did the user write for this effect?", only "what is the solver adding up".
This is the same wall the effects work hit, and it has the same answer: name the contribution as an expression and let the ledger sum that very expression, rather than storing raw coefficients beside the folded ones. Attribution is a question for the math, not for the parameter file — and a plan that tries to answer it by keeping a second, unfolded copy of the coefficients is undoing the fold that took the effects build from 18.5 s to 4.2 s.
It is not a YAML file¶
"Store it like the math" means the same status, not the same format. Three reasons, and each has already cost us something:
- Size. 93 parameters over a horizon; the
stressbenchmark is ~2M variables. YAML has no random access and no column types. - Types.
_INT_DIMS,_BOOL_PARAMSand_stamp_empty_dtypesexist because an empty column has no dtype and pandas guessesfloat64, which the engine reads as a numeric label space and refuses. Parquet carries dtypes. YAML would reintroduce that whole class of bug at the file boundary. - Diff. YAML earns its keep by line-diffing. A million-row table does not line-diff usefully; its meaningful diff is a digest.
The status that is shared with the math is the part worth having: a named
artifact, validated at load against a declaration, round-trippable, and
passable back to optimize().
And the YAML config file already exists — FlowSystem.to_yaml(): structure,
scalars and ProfileRefs, with bulk numbers referenced rather than inlined,
per that decision in
config-and-pydantic-direction.md. Four
artifacts, four jobs:
| artifact | format | authored by | checked by |
|---|---|---|---|
FlowSystem — structure, scalars, refs |
YAML | the user | layers 1–2 |
| profiles — bulk series | parquet / netCDF, referenced | the user's data pipeline | layer 3, on resolve |
program.yaml — the math |
YAML | fluxopt, editable via math= |
lpspec |
parameters/ — the bound numbers |
parquet directory | derived, never authored | lpspec, against the program |
Decisions taken, so they are not re-argued¶
Derived, never authored. The parameter set is persistable, not a writing
surface. Editing effects_per_flow_hour means editing numbers whose relation
to the declared inputs the fold has already dissolved — an edit nobody can
review, which is the objection that retired customize, moved to the data
side. The authoring surfaces stay the element layer for data and
program.yaml for math.
Parquet, not YAML. Above.
A labels table is a real thing, not a leftover. Presentation metadata has no parameter and needs none. Naming it up front stops it being smuggled into the program as parameters nothing reads.
Attribution is answered by named expressions. Not by a second copy of the coefficients.
What this costs¶
Result.data is public and its layout ships. Changing what the stored
artifact is breaks saved results. Step 2 below is where that break lands and
it should land once.
Two representations during the migration. Between steps 1 and 3 the
parameter set is derived twice — once for the stored artifact, once inside
sources.py. That is the cost of not doing it as one commit, and it is
cheaper than the alternative.
Nothing is saved on the first step. Step 1 is pure addition. The line count only moves at step 3.
Order of work¶
system.parameters(profiles=...), pluswrite_parquet/read_parquetover whatbuild_sourcesalready returns. No layer change. It makes themath=/parameters=pair symmetric, which is the smallest statement of the whole idea.Result.savepersistsparameters/and a digest instead of the fluxopt-schemamodel/tree. Provenance lands here: a result that says which numbers produced it.- Collapse the schemas. The build emits program-keyed tables directly;
ModelDatashrinks to the labels table; layer 3's reload half closes. Breaking, and gated bytests/mathandtests/math_porton the shipping lane.
Step 1 is small and useful on its own, so it is worth doing even if step 3 is never reached.
A side effect worth recording¶
This closes the seam that
bindspec wants and that fluxopt cannot
currently offer it. bindspec binds lpspec parameter names against an lpspec
model, while a ProfileRef names an element field — different key spaces, so
it has nothing to attach to today. Once the parameter set is an artifact keyed
by parameter name, its expect contracts and its deterministic manifest apply
to ours directly, as an optional check on the way out rather than a layer on
the way in.
That is a reason to prefer this direction over adopting a binding layer at the ingest end, not a commitment to take the dependency.