Skip to content

feat: cases on a named expression, read in order with a fallback - #70

Closed
FBumann wants to merge 8 commits into
mainfrom
feat/expression-cases
Closed

FBumann wants to merge 8 commits into
mainfrom
feat/expression-cases

Conversation

@FBumann

@FBumann FBumann commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Implements the feature from #2. Supersedes #36 and #33, which took the same feature through a proved static partition; this takes it through the shape of the block instead.

Note

The following content was generated by AI.

expressions: takes cases: — an ordered set of arms over a declared foreach:, the last of them the fallback. The value at a coordinate is the first arm whose when holds there; the last arm carries no when. So the quantity has exactly one value at every coordinate, and a value at every coordinate — both by the shape of the block rather than by anything a checker establishes, which is why #33's 945-line decision procedure is not needed at all.

expressions:
  previous_status:
    description: the commitment state a unit carries into a snapshot
    foreach: [snapshot, generator]
    cases:
      always_on: { when: "not committable", expression: 1 }
      boundary: { when: "position(snapshot) == 0", expression: status_initial }
      interior: { expression: shift(status, over=snapshot, offset=1) }
constraints:
  no_restart:
    foreach: [snapshot, generator]
    expression: status - previous_status <= 1

Three regimes, one quantity, and the inequality using it written once instead of three times that nothing checks agree.

Three commits, each green on its own:

  1. feat(language): a named expression may write a constant as a number — the bug writing the example found, separable from cases:.
  2. feat(language): a named expression may give a value per region — the construct and its printing. The two halves do not separate: CasesNode joins ArithmeticNode, and test_the_golden_model_carries_every_node_kind_the_walk_renders holds the walk to get_args(ArithmeticNode).
  3. docs: unit commitment, the model cases exists for — the example, its gallery page, the nav entry. Cherry-picks cleanly if it should be its own PR.

Checks. pixi run ci clean on 9a215da: lint (ruff, pyrefly 0 errors, reuse, typos, prettier), 745 passed, mkdocs build --strict, and 27 TeX documents compiled — the new example among them. Every equation quoted below is the renderer's own output.

The rule that makes it a quantity

The arms are read in order, and the last one carries no when:.

The value at a coordinate is the first arm whose when holds there; the last arm has no condition and covers everything the arms above it leave. So the arms cannot disagree — only the first to match is read — and the quantity has a value everywhere, because the fallback has no condition to fail. Nothing has to decide what a predicate could be true of, and there is no case set that loads and later turns out to have a hole in it.

Totality is not a nicety. A gap would leave the quantity undefined there, and absence spreads — every constraint referencing it would lose rows it never masked, which is what diagnostics().omissions exists to catch. A cased expression being total is what keeps a constraint's row set readable at the constraint. It is not free, and the example shows the price: with no mask to narrow the frame, the fallback has to say what an absent parameter or an unnamed label gets.

The near-miss is named in each direction: a fallback that is not last, no fallback at all, one case, both forms, neither form, a foreach: without cases, and — the typo this design invites — where: inside a case:

expressions.x.cases.a: unknown key 'where' in an expression case. Did you mean 'when'?

when:, not where:. A case selects which value a coordinate takes; it creates no absence and deletes no row, which is what where means on every other block (rule 6). A cased expression has no where of its own, so the word would be free to mislead.

foreach: required with cases, refused without. An uncased expression's dims fall out of its body; a cased one's cannot, since a case may be a scalar where its when is not — always_on above is exactly that. Each when is held to the frame by _check_where_dims, the same check in the same words that holds a variable's or a constraint's mask to its; each case's value must sit inside the frame too. The dims of a reference are the declared foreach, not the union of the arms: an arm narrower than the frame broadcasts, exactly as a parameter with fewer dims does.

Why this is not #36

#36 required the cases to be a proved partition — pairwise disjoint and jointly exhaustive, decided before any data binds by #33's 945-line decision procedure, with the overlap or the gap named and a witness for it. The error messages were genuinely good.

Ordered arms with a mandatory fallback give the same two guarantees by making the bad states unrepresentable rather than diagnosed. What that buys:

  • feat: a static partition check for expression cases #33 is not needed at all. 945 lines of cell enumeration over where-atom subjects, and the coupling that came with it: every where atom added later would have had to teach partition.py about its cells, or the procedure would silently get more conservative.
  • The wiring here shrinks to two validators. _check_cases, _resolved_when, the witness-bearing message and the "a when did not resolve, so the partition would misreport" bail-out are all gone.
  • The math gets shorter. With disjointness enforced rather than proved, the arms no longer restate each other's negations: boundary and interior above drop the committable and … that feat: cases on a named expression, and referencing one #36's version needed, and the printed block loses a conjunction per row.
  • It prints the way papers write it — \text{otherwise} on the last row, which feat: cases on a named expression, and referencing one #36's format.cases docstring specifically defended not having.

What it costs, stated plainly: a fully shadowed arm is now silently dead rather than a load error, and the arms must be read top-down instead of as an unordered set. \begin{cases} rows are read top-down anyway.

What a reference expands to

A name reaching a cased expression expands to CasesNode, a core arithmetic node carrying the name and every arm in file order — and not the frame, which is on the declaration that every consumer needing it already holds. Because it joins BranchNode (#52's named groups), degree, _adds, is_quadratic, _degree and boundedness' variable walk all reach the arms through children() — only expansion, resolution, the dim algebra and the typeset walk needed an arm of their own, and pyrefly's assert_never is what enforced that. Arms are substituted through, so a case body may name another expression, and a macro template may name a cased expression.

Verified by probe, and unchanged by this PR because the existing walks already cover it: a self- or mutually-referential cased expression is caught by the existing cycle check; _degree takes a max over arms rather than a sum, so p * p * x is still refused at degree 3; a piecewise: link may name a cased expression; a bound still refuses any named expression.

How it prints

A cased expression is the one named expression that does not inline. Rendered as an atom, the block printed where the name stood, and a three-arm block is three rows tall, so whatever follows it in the equation sits beside the middle arm — and a quantity written once in the file is written once per use on the page, which is the opposite of what naming it was for.

So a use prints the symbol:

$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathrm{ramp_limit}_{g} \cdot \mathit{previous_status}_{t,g} + \mathrm{start_up_limit}_{g} \cdot \left( 1 - \mathit{previous_status}_{t,g} \right)$$

and the block prints once, under a new Definitions section between Subject to and Variable domains — where a paper states a quantity defined by region:

$$\mathit{previous_status}_{t,g} = \begin{cases} 1 &amp; \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} &amp; \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} &amp; \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

The section is named Definitions rather than the conventional where, which in this repo is a keyword and would read as the wrong thing.

Every declared cased expression prints, used or not, in declaration order — the rule a variable's domain already follows. definitions() therefore iterates the declarations and resolves each one the way constraints() resolves a constraint, rather than collecting what the other sections happened to reach: no reached-set on Walk, no fixpoint over arms that name further cased expressions, and no ordering dependency between the sections of the document. printed_expressions returns a tuple rather than a set precisely because the section's row order is the file's; test_the_definitions_print_in_declaration_order declares six of them so a shuffle cannot pass by luck.

Which arm is the fallback is a fact about the math, so the walk chooses between if and otherwise and a Format only stacks the rows — the split typesetting/README.md states. Format.cases takes (value, condition) pairs both already rendered.

Symbols. Cased expressions join the symbol pool, deriving a symbol like any other name, and --symbols can rename one. Uncased expressions stay out: they print nothing under their own name, so a table entry for one would never apply — the silent-typo failure the table is strict about.

Given or chosen. A cased expression is on whichever side its arms put it: one whose every arm is data prints upright, however many regions it is cut into; a when naming a variable does not move it, since that asks whether the variable exists, which the model settles when it is built. The chain is followed all the way — chosen_expressions resolves each arm and asks degree.carries_variable, which walks another cased expression's arms too, so a quantity whose only route to a variable runs through a second cased expression still prints italic.

The asymmetry is deliberate and is the one thing worth arguing with. test_macros_and_named_expressions_are_expanded_away still holds for every other named expression; a cased one is the exception. The defence: it is the only kind that cannot inline legibly, and the only kind whose declaration a reader has to see to check the regions.

The format seam gains cases() — \begin{cases} in LaTeX, cases() in Typst. The row separator is a cases_row class attribute, the shape dash and operators already use: Markdown overrides it with \cr rather than \\, whose escape pass eats one of the two backslashes before MathJax sees them. The golden model carries a cased expression and a constraint naming it, so the three .out files, the Typst compile and the walk's node-coverage guard all cover the new arm.

The example, and the bug writing it found

examples/commitment.yaml is the formulation #2 factors: ramp_up holds a running unit to ramp_limit and a starting one to start_up_limit in one inequality instead of three. tools/render_tex.py picks it up like every other model, so the LaTeX gate compiles it, and it gets a page in the example gallery.

Writing it turned up a bug in the documented example: expression: 1 is how a constant case is spelled, YAML reads it as an int, and the annotation took only a string — so the block in expressions.md did not load. Nothing caught it because no test in this repo loads the page's snippets. A number is now accepted where a named expression or one of its cases declares its body, and the checked-in JSON schema says so, since a mode='before' rewrite is otherwise invisible to it. A constraint, an objective and a piecewise: link are untouched: none of them is a bare number. Booleans still fail: true is not arithmetic, and an error naming the type reads better than one naming 'True'.

That fix is the first commit, and stands on its own.

Scope, and what is deliberately not here

Cases in a macro template are a follow-up. The fallback would have to cover a frame the macro does not have until it is called.

model.py (ExpressionCase, ExpressionBlock gaining foreach/cases, the two form validators, the round-trip serialiser and the number coercion), expression_parser.py (CasesNode/CaseArm, joining BranchNode and children()), expansion.py (CasesNode where the name stood), resolution.py and dimensions.py (the dispatch arms and the frame checks, beside the ones for variables and constraints), validation.py (resolve each case), typesetting/ (the format seam, the symbol at the use site, definitions(), the section, and the given/chosen cut), the regenerated JSON schema, tools/notation.py, tools/gallery.py, expressions.md, notation.md, examples/commitment.yaml and its gallery page, the golden fixture and its three outputs, and tests.

The generated pages are regenerated rather than hand-edited, and tests/test_docs.py is what catches one going stale.

@FBumann
FBumann marked this pull request as draft August 25, 2026 06:43
@FBumann
FBumann force-pushed the feat/expression-cases branch from 0ef6884 to 2bd3a0c Compare August 25, 2026 07:10
@FBumann
FBumann force-pushed the feat/expression-cases branch from 2bd3a0c to 15e0358 Compare August 25, 2026 07:28
FBumann pushed a commit that referenced this pull request Aug 26, 2026
…model is block-for-block PyPSA's

The optimum, the counts and the prices already match, so the split was
invisible to the gates and the rows read done; the target is now the model
itself — linopy against linopy — and under it seventeen PyPSA names stated
as several blocks are the known inventory of same-optimum-but-not-same-model.
The index marks them split, with #70's cases as the fuser where value cases
fuse them and sense-as-data named where they cannot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs
FBumann added a commit that referenced this pull request Aug 26, 2026
…t pass — and two wrong formulas they caught (#125)

* test: the spine's snapshot weightings are generic, so a missing hour factor cannot pass the gates

Every weighting was 1.0 — the multiplicative identity — so a model missing
or misplacing an hours factor built the identical matrix and passed every
gate. The spine now carries non-unit, pairwise-distinct weightings in all
three columns, all ten rungs re-record and re-solve to parity, and the one
divergence this exposed was in the gate itself: PyPSA publishes
marginal_price as the row dual over the objective weighting, so the dual
comparison now states that normalization instead of assuming weighting 1.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: an operational_limit row counts what its storage draws down, and every global-constraint class solves to parity

The file's operational_limit expression charged storage dispatch against
the row; PyPSA counts the drawdown — the level lost between the initial
charge and the horizon's end — and the initial charge is folded into the
row's constant, as primary_energy already documented but prep never did.
prep now computes every weight table from PyPSA's own memberships (the
emissions, the lengths, the capital costs, the carrier-and-bus sets), the
tech row gains its Line term, and rungs 3, 5 and 6 carry all five
global-constraint types in all three senses — nineteen rows, with storage
in the co2 accounting — to full parity: one objective, one structure, one
set of bus prices.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: the plain non-negativity row of a committed build is masked per unit, as PyPSA masks it

The flag was one answer for the whole network; PyPSA asks each unit
whether any of its own minimums is negative and adds the row unit by
unit. The flag is now a per-generator column, and rung 8 carries a
committable extendable unit with a negative minimum to hold the
difference on both lanes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* test: the corpus proves its own coverage — every block built, every mask split, every parameter fed

parity stamps what lpspec built per file block, each dimension's size and
the tables bound non-empty; the suite asserts from the stamps that every
declared block is built by some rung (the GlobalConstraint fourteen
included), that every where: is left partially true somewhere — full or
empty proves only all-or-nothing — and that every parameter reaches some
solve non-empty. Rung 3 mixes fixed beside extendable storage and puts
ramps on an extendable generator and link, and rung 7 gains a cold-start
committable so the first-snapshot ramp rows of a unit that starts off
exist somewhere.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a row split across several where: blocks is not done until the model is block-for-block PyPSA's

The optimum, the counts and the prices already match, so the split was
invisible to the gates and the rows read done; the target is now the model
itself — linopy against linopy — and under it seventeen PyPSA names stated
as several blocks are the known inventory of same-optimum-but-not-same-model.
The index marks them split, with #70's cases as the fuser where value cases
fuse them and sense-as-data named where they cannot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a split row states what it already guarantees — PyPSA's feasible region and optimum, short of one block

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: four status words, each a claim — done, split, open, out

The six words mixed how close a row is with why it is not closer; the
status now answers only the first, on one ladder — the one block PyPSA
builds, the same feasible region under a different statement, not stated
yet, never stated deliberately — and the note carries the cause. The
done/split boundary is the coming linopy-against-linopy gate's to decide;
open against out stays the maintainer's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 26, 2026
…, and says how deep that is (#126)

* test: the spine's snapshot weightings are generic, so a missing hour factor cannot pass the gates

Every weighting was 1.0 — the multiplicative identity — so a model missing
or misplacing an hours factor built the identical matrix and passed every
gate. The spine now carries non-unit, pairwise-distinct weightings in all
three columns, all ten rungs re-record and re-solve to parity, and the one
divergence this exposed was in the gate itself: PyPSA publishes
marginal_price as the row dual over the objective weighting, so the dual
comparison now states that normalization instead of assuming weighting 1.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: an operational_limit row counts what its storage draws down, and every global-constraint class solves to parity

The file's operational_limit expression charged storage dispatch against
the row; PyPSA counts the drawdown — the level lost between the initial
charge and the horizon's end — and the initial charge is folded into the
row's constant, as primary_energy already documented but prep never did.
prep now computes every weight table from PyPSA's own memberships (the
emissions, the lengths, the capital costs, the carrier-and-bus sets), the
tech row gains its Line term, and rungs 3, 5 and 6 carry all five
global-constraint types in all three senses — nineteen rows, with storage
in the co2 accounting — to full parity: one objective, one structure, one
set of bus prices.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: the plain non-negativity row of a committed build is masked per unit, as PyPSA masks it

The flag was one answer for the whole network; PyPSA asks each unit
whether any of its own minimums is negative and adds the row unit by
unit. The flag is now a per-generator column, and rung 8 carries a
committable extendable unit with a negative minimum to hold the
difference on both lanes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* test: the corpus proves its own coverage — every block built, every mask split, every parameter fed

parity stamps what lpspec built per file block, each dimension's size and
the tables bound non-empty; the suite asserts from the stamps that every
declared block is built by some rung (the GlobalConstraint fourteen
included), that every where: is left partially true somewhere — full or
empty proves only all-or-nothing — and that every parameter reaches some
solve non-empty. Rung 3 mixes fixed beside extendable storage and puts
ramps on an extendable generator and link, and rung 7 gains a cold-start
committable so the first-snapshot ramp rows of a unit that starts off
exist somewhere.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a row split across several where: blocks is not done until the model is block-for-block PyPSA's

The optimum, the counts and the prices already match, so the split was
invisible to the gates and the rows read done; the target is now the model
itself — linopy against linopy — and under it seventeen PyPSA names stated
as several blocks are the known inventory of same-optimum-but-not-same-model.
The index marks them split, with #70's cases as the fuser where value cases
fuse them and sense-as-data named where they cannot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a split row states what it already guarantees — PyPSA's feasible region and optimum, short of one block

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: four status words, each a claim — done, split, open, out

The six words mixed how close a row is with why it is not closer; the
status now answers only the first, on one ladder — the one block PyPSA
builds, the same feasible region under a different statement, not stated
yet, never stated deliberately — and the note carries the cause. The
done/split boundary is the coming linopy-against-linopy gate's to decide;
open against out stays the maintainer's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: a structural gate compares the two linopy models label for label, and one rung already builds one model

structural.py builds PyPSA's n.optimize.create_model() and
lpspec.linopy.build from the same network and compares every label's
coefficients, sense, right-hand side, bounds and integrality — no solver,
so no degeneracy, no MIP-dual gap. Its verdicts speak the table's words:
equal is done, region is split, mismatch fails the run. The quadratic rung
builds one model on both lanes, label for label, objective included; the
nine others stamp the exact lpspec.linopy blocker they wait on — an empty
sum_back window, and a NaN constant where the relational lane drops the
row. The count and dual gates stay until the structural gate covers what
they cover.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: why the structural gate reads linopy's flat export rather than calling linopy.testing

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* ci: an advisory parity job runs the differential harness with this tree's language swapped in

The division of labour: this repository gets the YAML right and renders
it; the engines interpret and solve it. The job installs the pinned
engines, overrides lpspec's pinned math-spec with the working tree, and
runs both runners — so a red tells one of two stories, parity broke or
the language moved ahead of the pinned lpspec, and only the first is this
side's to fix. Deliberately not a required check; lpspec's own CI is the
required side of the same contract.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: one runner, one proof ladder — the count and dual gates retire into the model comparison

parity.py absorbs the structural differ and structural.py goes; per rung
it now runs the three comparisons that define parity — model against
model where lpspec.linopy builds, one solved objective across the fence,
and the coverage stamps — and stamps how deep the proof reaches. The
separate count and dual comparisons are deleted as strict subsets of the
model comparison; where that comparison is still blocked upstream the
banner and the legend say plainly that the proof stops at the objective.
The index legend now states what is proven per rung instead of implying
one proof for all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* ci: this repository runs no engine — the parity runner belongs to lpspec's CI and to out-of-band certification

The advisory job swapped this tree's math_spec under the pinned lpspec
and died on first contact: the pinned engine's code speaks the language
of the math-spec it pins, not this tree's. That is not a job to repair —
building and solving are the engines' business, so the workflow goes, this
repository's CI stays engine-free, and parity.py states its two homes:
out of band here to refresh the stamps when the corpus changes, and in
lpspec's CI, its own tree swapped in, where engine regressions go red.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: the corpus keeps no engine code — binding, building and solving move to lpspec's differential suite

prep.py and the parity runner leave for lpspec's differential/pypsa/,
which reads this checkout as its corpus: the models, the data with its
loader, and references.json — the PyPSA record the rung scripts write,
plus the certification stamps lpspec's runner writes here when the corpus
changes. The suite asserts over the committed files alone, and a new check
holds the stamps to this record's own objective, so a re-recorded fixture
with unrefreshed stamps fails by arithmetic rather than by trust.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 26, 2026
…nd solved to the same objective on both lanes (#122)

* feat: PyPSA in one file, rung 1 — transport, a declaration at a time (#96)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: the PyPSA table on the examples page, by rung (#103)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: PyPSA in one file, rungs 2 to 9 — storage through multi-link, a declaration at a time (#110)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs(pypsa): each rung's reference network sits under its table, solved and recorded (#113)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: the file solves — every rung lands on PyPSA's objective through lpspec (#116)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: PyPSA in one file, rung 10 — the quadratic class, a file of its own (#117)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: every rung's instance is the shared spine plus its own folder of additions (#119)

Rebasing the stack onto alpha.20 regenerates the declared pages under the
quoted-label typesetter and retires the pre-rename script names a squash
merge cannot see as renames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* chore: the ten reference scripts share one recorder, and each fact in the corpus has one home

The byte-identical record()/main() copies move into instances.py as
record/stamp; parity builds networks straight from instances and cuts
prep.sources to what each model file declares, retiring the hand-kept
table list; the gallery loads model and records once and reuses
tools.notation's equation reader; prose that restated a neighbouring
docstring, description or generated block is cut.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: parity asserts the objective, every PyPSA name's row and column count, and bus prices on both lanes

parity.py now compares, per rung: the objective (unchanged); the row count
under every PyPSA name, lpspec's built labels against the recorded
masked-label counts, split where: blocks summing to their one row and
GlobalConstraint rows summing by type; the column count per variable the
same way; and the bus-balance duals against PyPSA's recorded marginal_price
wherever both lanes price — lpspec refuses duals on a mixed-integer model,
stamped mip. Primals are deliberately not compared: an optimum need not be
unique. All ten rungs pass; the suite asserts the stamps.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: the rung index legend states the current-state rule and the gate that now runs

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: the three bookkeeping deviations from PyPSA's rows say what is untested about their duals

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* test: generic spine data and coverage gates, so a silent regime cannot pass — and two wrong formulas they caught (#125)

* test: the spine's snapshot weightings are generic, so a missing hour factor cannot pass the gates

Every weighting was 1.0 — the multiplicative identity — so a model missing
or misplacing an hours factor built the identical matrix and passed every
gate. The spine now carries non-unit, pairwise-distinct weightings in all
three columns, all ten rungs re-record and re-solve to parity, and the one
divergence this exposed was in the gate itself: PyPSA publishes
marginal_price as the row dual over the objective weighting, so the dual
comparison now states that normalization instead of assuming weighting 1.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: an operational_limit row counts what its storage draws down, and every global-constraint class solves to parity

The file's operational_limit expression charged storage dispatch against
the row; PyPSA counts the drawdown — the level lost between the initial
charge and the horizon's end — and the initial charge is folded into the
row's constant, as primary_energy already documented but prep never did.
prep now computes every weight table from PyPSA's own memberships (the
emissions, the lengths, the capital costs, the carrier-and-bus sets), the
tech row gains its Line term, and rungs 3, 5 and 6 carry all five
global-constraint types in all three senses — nineteen rows, with storage
in the co2 accounting — to full parity: one objective, one structure, one
set of bus prices.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: the plain non-negativity row of a committed build is masked per unit, as PyPSA masks it

The flag was one answer for the whole network; PyPSA asks each unit
whether any of its own minimums is negative and adds the row unit by
unit. The flag is now a per-generator column, and rung 8 carries a
committable extendable unit with a negative minimum to hold the
difference on both lanes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* test: the corpus proves its own coverage — every block built, every mask split, every parameter fed

parity stamps what lpspec built per file block, each dimension's size and
the tables bound non-empty; the suite asserts from the stamps that every
declared block is built by some rung (the GlobalConstraint fourteen
included), that every where: is left partially true somewhere — full or
empty proves only all-or-nothing — and that every parameter reaches some
solve non-empty. Rung 3 mixes fixed beside extendable storage and puts
ramps on an extendable generator and link, and rung 7 gains a cold-start
committable so the first-snapshot ramp rows of a unit that starts off
exist somewhere.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a row split across several where: blocks is not done until the model is block-for-block PyPSA's

The optimum, the counts and the prices already match, so the split was
invisible to the gates and the rows read done; the target is now the model
itself — linopy against linopy — and under it seventeen PyPSA names stated
as several blocks are the known inventory of same-optimum-but-not-same-model.
The index marks them split, with #70's cases as the fuser where value cases
fuse them and sense-as-data named where they cannot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a split row states what it already guarantees — PyPSA's feasible region and optimum, short of one block

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: four status words, each a claim — done, split, open, out

The six words mixed how close a row is with why it is not closer; the
status now answers only the first, on one ladder — the one block PyPSA
builds, the same feasible region under a different statement, not stated
yet, never stated deliberately — and the note carries the cause. The
done/split boundary is the coming linopy-against-linopy gate's to decide;
open against out stays the maintainer's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat: one parity runner proves each rung as deep as the engines allow, and says how deep that is (#126)

* test: the spine's snapshot weightings are generic, so a missing hour factor cannot pass the gates

Every weighting was 1.0 — the multiplicative identity — so a model missing
or misplacing an hours factor built the identical matrix and passed every
gate. The spine now carries non-unit, pairwise-distinct weightings in all
three columns, all ten rungs re-record and re-solve to parity, and the one
divergence this exposed was in the gate itself: PyPSA publishes
marginal_price as the row dual over the objective weighting, so the dual
comparison now states that normalization instead of assuming weighting 1.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: an operational_limit row counts what its storage draws down, and every global-constraint class solves to parity

The file's operational_limit expression charged storage dispatch against
the row; PyPSA counts the drawdown — the level lost between the initial
charge and the horizon's end — and the initial charge is folded into the
row's constant, as primary_energy already documented but prep never did.
prep now computes every weight table from PyPSA's own memberships (the
emissions, the lengths, the capital costs, the carrier-and-bus sets), the
tech row gains its Line term, and rungs 3, 5 and 6 carry all five
global-constraint types in all three senses — nineteen rows, with storage
in the co2 accounting — to full parity: one objective, one structure, one
set of bus prices.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* fix: the plain non-negativity row of a committed build is masked per unit, as PyPSA masks it

The flag was one answer for the whole network; PyPSA asks each unit
whether any of its own minimums is negative and adds the row unit by
unit. The flag is now a per-generator column, and rung 8 carries a
committable extendable unit with a negative minimum to hold the
difference on both lanes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* test: the corpus proves its own coverage — every block built, every mask split, every parameter fed

parity stamps what lpspec built per file block, each dimension's size and
the tables bound non-empty; the suite asserts from the stamps that every
declared block is built by some rung (the GlobalConstraint fourteen
included), that every where: is left partially true somewhere — full or
empty proves only all-or-nothing — and that every parameter reaches some
solve non-empty. Rung 3 mixes fixed beside extendable storage and puts
ramps on an extendable generator and link, and rung 7 gains a cold-start
committable so the first-snapshot ramp rows of a unit that starts off
exist somewhere.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a row split across several where: blocks is not done until the model is block-for-block PyPSA's

The optimum, the counts and the prices already match, so the split was
invisible to the gates and the rows read done; the target is now the model
itself — linopy against linopy — and under it seventeen PyPSA names stated
as several blocks are the known inventory of same-optimum-but-not-same-model.
The index marks them split, with #70's cases as the fuser where value cases
fuse them and sense-as-data named where they cannot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: a split row states what it already guarantees — PyPSA's feasible region and optimum, short of one block

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: four status words, each a claim — done, split, open, out

The six words mixed how close a row is with why it is not closer; the
status now answers only the first, on one ladder — the one block PyPSA
builds, the same feasible region under a different statement, not stated
yet, never stated deliberately — and the note carries the cause. The
done/split boundary is the coming linopy-against-linopy gate's to decide;
open against out stays the maintainer's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: a structural gate compares the two linopy models label for label, and one rung already builds one model

structural.py builds PyPSA's n.optimize.create_model() and
lpspec.linopy.build from the same network and compares every label's
coefficients, sense, right-hand side, bounds and integrality — no solver,
so no degeneracy, no MIP-dual gap. Its verdicts speak the table's words:
equal is done, region is split, mismatch fails the run. The quadratic rung
builds one model on both lanes, label for label, objective included; the
nine others stamp the exact lpspec.linopy blocker they wait on — an empty
sum_back window, and a NaN constant where the relational lane drops the
row. The count and dual gates stay until the structural gate covers what
they cover.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* docs: why the structural gate reads linopy's flat export rather than calling linopy.testing

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* ci: an advisory parity job runs the differential harness with this tree's language swapped in

The division of labour: this repository gets the YAML right and renders
it; the engines interpret and solve it. The job installs the pinned
engines, overrides lpspec's pinned math-spec with the working tree, and
runs both runners — so a red tells one of two stories, parity broke or
the language moved ahead of the pinned lpspec, and only the first is this
side's to fix. Deliberately not a required check; lpspec's own CI is the
required side of the same contract.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: one runner, one proof ladder — the count and dual gates retire into the model comparison

parity.py absorbs the structural differ and structural.py goes; per rung
it now runs the three comparisons that define parity — model against
model where lpspec.linopy builds, one solved objective across the fence,
and the coverage stamps — and stamps how deep the proof reaches. The
separate count and dual comparisons are deleted as strict subsets of the
model comparison; where that comparison is still blocked upstream the
banner and the legend say plainly that the proof stops at the objective.
The index legend now states what is proven per rung instead of implying
one proof for all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* ci: this repository runs no engine — the parity runner belongs to lpspec's CI and to out-of-band certification

The advisory job swapped this tree's math_spec under the pinned lpspec
and died on first contact: the pinned engine's code speaks the language
of the math-spec it pins, not this tree's. That is not a job to repair —
building and solving are the engines' business, so the workflow goes, this
repository's CI stays engine-free, and parity.py states its two homes:
out of band here to refresh the stamps when the corpus changes, and in
lpspec's CI, its own tree swapped in, where engine regressions go red.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: the corpus keeps no engine code — binding, building and solving move to lpspec's differential suite

prep.py and the parity runner leave for lpspec's differential/pypsa/,
which reads this checkout as its corpus: the models, the data with its
loader, and references.json — the PyPSA record the rung scripts write,
plus the certification stamps lpspec's runner writes here when the corpus
changes. The suite asserts over the committed files alone, and a new check
holds the stamps to this record's own objective, so a re-recorded fixture
with unrefreshed stamps fails by arithmetic rather than by trust.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

---------

Co-authored-by: Claude <noreply@anthropic.com>

* ci: an advisory job runs the pinned lpspec's parity runner against the corpus

The job installs lpspec exactly as pinned — with lpspec's own pinned
math-spec, never this tree — and runs differential/pypsa/parity.py over
the checkout, so a corpus change that breaks parity shows on the PR
without gating it. The certificate is refreshed under that same pin:
the sum_back blocker is gone upstream and the stamps now name the
empty-dim reshape, with every objective and count unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: one reference script, and the rung folders are the rungs (#129)

* refactor: one reference script; the rung folders are the rungs, and a story is the folder's README

Ten scripts differed in a name and a docstring; the work was in
instances.py and the data. `reference.py` runs every folder beside the
spine, or the ones named on the command line, and pins PyPSA once. Each
rung's story moves to `data/<rung>/README.md`, where the tables it
describes are.

The per-rung banner no longer repeats the model-for-model caveat ten
times; the spine block says it once, and a banner speaks only where its
record has something to say.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: the tables are the story — no README per rung, and the spine says it in two sentences

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: instances.rungs() is the rung list, for reference.py and lpspec's runner alike

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: a structural stamp is held to the lpspec the workflow pins (#131)

The gate accepted any blocker string, so a stamp could name a blocker
the pinned runner no longer hits. Now every rung's stamp must carry the
commit the workflow pins; bumping either without the other is red. The
advisory job fails when a re-certification would rewrite the record.

Re-certified at fluxopt/specsolve#1313, which runs the rung folders.

Closes #130.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* ci: the stamp certification ignores the unreproducible part of the version string

setuptools-scm's dev counter varies with checkout depth, so the advisory
job went red on dev1-versus-dev2 with the parity gate itself green. Only
the +g<sha> suffix is reproducible, and the suite already holds that sha
to the workflow's pin, so the diff step skips the version line.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* feat: every priced rung holds its nodal prices to PyPSA's, and the banner says on how many rows (#134)

lpspec's runner now compares Bus_nodal_balance duals over the objective
weighting to marginal_price per (snapshot, bus) and stamps the result;
the suite requires agreement wherever the lane prices, and a named
integer variable where it does not. Eight LP rungs agree to 4e-15.

Refs #132.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* ci: the required gate can be run by hand on a head no event reached

A push made with the GitHub App's token creates no pull_request run, so
a PR head can sit with the required check unreported and the PR blocked
while the tree is green. The workflow gains a dispatch trigger; the gate
it runs is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* ci: the stamp certification compares what an environment can reproduce

The dual gap is machine-epsilon noise that varies by build, so the
advisory job went red on 8.9e-16 against 0.0 with every verdict green.
It joins the version string on the certification diff's ignore list;
the verdicts and objectives are what must reproduce.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* ci: the stamp certification compares meaning, not bits

Three advisory reds in a row were reproducibility noise, not drift: the
scm dev counter, the epsilon-sized dual gap, then the solved objective's
last ulp. A byte diff cannot certify a file of solver floats, so the
step compares the committed record semantically — floats to 1e-9,
versions by their +g<sha> commit, verdicts, counts and keys exactly —
and prints the path of anything that truly moved.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: the corpus keeps PyPSA's record and no engine's, so no stamp here can go stale (#142)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: a rung is a PyPSA script with its data inline, and the binding is on the page beside the file (#143)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: every file of the rung corpus carries its license, on the page without boilerplate

The reuse gate was red on the rebuilt corpus: the scripts the docs pages
print verbatim are covered by a REUSE.toml annotation, so no license
boilerplate lands on a rung's page; reference.py and the new workflow,
which no page shows, carry the header in-file.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoRPgfnFb5gjokfeN82yCs

* refactor: the corpus states the file and the networks, and binding one to the other is the engine's (#148)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the feat/expression-cases branch from 15e0358 to f224a5f Compare August 27, 2026 07:51
@FBumann

FBumann commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (alpha.24). The typesetter tests it added moved into main's split (tests/typesetting/test_cases.py, plus the chosen line in the page guard in test_walk.py); the walk's ceiling went, since degree is decided at load now; the validation tests sit in TestExpressionCases at the end of test_validation.py. Gate: pytest, strict docs, compile-tex.

FBumann and others added 3 commits August 27, 2026 11:28
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann force-pushed the feat/expression-cases branch from 713c1f6 to 9a215da Compare August 27, 2026 09:37
FabianHofmann and others added 3 commits August 27, 2026 12:36
One spelling of a cased expression's frame via referenced_dims,
one names tuple in Symbols.__init__, and the arm builders as
plain loops matching the resolution idiom.
A vacuous snippet constraint becomes the real ramp inequality, the
macro note says not-supported instead of follow-up, and the garbled
sentences read straight.
… than against `where`

The contrast explained a value-selecting keyword with row-deleting
vocabulary, and repeated it at four sites. The closed schema's own
difflib suggestion already names `when` at the moment of the typo.
Schema regenerated, the docstring being the block's description.

Co-Authored-By: Claude <noreply@anthropic.com>
@FabianHofmann
FabianHofmann marked this pull request as ready for review August 27, 2026 11:02
@FabianHofmann

Copy link
Copy Markdown
Contributor

@FBumann I very much like the implementation. The docstring tried to explain to hard why using when instead of where. I removed that, so it stays clean and does not cause confusion. I would say this is ready to go.

There is one undocumented pitfall here which I would mention in the docs. The fallback is not catching cases where the absence is created by the body of a cased expression itself, eg. using the shift function. This is a more general topic related to expressions themselves. pushing something for this

@FBumann

FBumann commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Id like to chat about wether this simpler implementation is better than the one in #36 / #33, which is much mroe strict about the case definition before merging this.

Do you have time for it?

@FBumann

FBumann commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Agreement with @FabianHofmann

Land on a middle ground between #70 and #36, enforcing mutual exclusivity between when conditions (no reliance on ordering), but keeping a otherwise as the so to say default, which covers the rest. The remaining when does not need to be stated explicitly, like it was in #36.

@FBumann
FBumann marked this pull request as draft August 27, 2026 14:26
FBumann added a commit that referenced this pull request Aug 27, 2026
…rather than the first written winning

The middle ground between #70 and #36, decided in #70. Each case's `when`
is proved disjoint from every other's before any data binds, so the arms
carry no order: each says where it applies on its own terms, and a
reader checks one without the ones above it in mind. The fallback stays
what #70 made it — the last case, carrying no `when` — so a value
*everywhere* is still the block's shape rather than a second proof, and
the complement no longer has to be written out as #36 required.

`exclusivity.py` is #33's decision procedure with the exhaustiveness and
dead-case halves cut, and the frame built per pair rather than over the
whole case set: `when_i AND when_j` unsatisfiable, decided by cutting
each subject into cells every atom over it is constant on. Independence
between subjects over-approximates, so a spurious world can manufacture
a witness but never hide one. A pair it cannot decide is refused the way
a proven overlap is, and the message names the rewrite.

The unit commitment example and the golden model pay the price the rule
asks for: `boundary` says `committable and position(snapshot) == 0`, and
the golden model's `winter` arm says `position(snapshot) > 0 and`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann force-pushed the feat/expression-cases branch from 51dd7b7 to 88e8dab Compare August 27, 2026 14:42
@FBumann FBumann changed the title feat: cases on a named expression, read in order with a fallback feat: a named expression's cases are proved apart at load, rather than read in order Aug 27, 2026
@FBumann FBumann changed the title feat: a named expression's cases are proved apart at load, rather than read in order feat: cases on a named expression, read in order with a fallback Aug 27, 2026
@FBumann
FBumann force-pushed the feat/expression-cases branch from 88e8dab to 51dd7b7 Compare August 27, 2026 14:50
@FBumann

FBumann commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Superseeded by #168

@FBumann FBumann closed this Aug 27, 2026
@FabianHofmann

FabianHofmann commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

@FBumann still unsure about the last expression being the fallback. while this sort of aligned with the math nomenclature, I don't like the assymetric layout. wouldn't a default on the same level as cases be better/clearer/stricter?

@FBumann

FBumann commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Yes it would.
Id argue for it! Ill add it.

EDIT: Not sure about the level. What about

expressions:
  previous_status:
    description: the commitment state a unit carries into a snapshot
    foreach: [snapshot, generator]
    cases:
      always_on: { when: "not committable", expression: 1 }
      boundary: { when: "committable and position(snapshot) == 0", expression: status_initial }
      default: shift(status, over=snapshot, offset=1)
constraints:
  no_restart:
    foreach: [snapshot, generator]
    expression: status - previous_status <= 1

With the default as a mandatory case name?
@FabianHofmann
Would match the rendering well. And order still doesnt matter.

@FBumann
FBumann deleted the feat/expression-cases branch September 9, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants