Conversation
Documentation build overview
47 files changed ·
|
0ef6884 to
2bd3a0c
Compare
2bd3a0c to
15e0358
Compare
…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
…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>
…, 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>
…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>
15e0358 to
f224a5f
Compare
|
Rebased onto current |
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>
713c1f6 to
9a215da
Compare
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>
|
@FBumann I very much like the implementation. The docstring tried to explain to hard why using 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 |
|
Agreement with @FabianHofmann Land on a middle ground between #70 and #36, enforcing mutual exclusivity between |
…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>
51dd7b7 to
88e8dab
Compare
88e8dab to
51dd7b7
Compare
|
Superseeded by #168 |
|
@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 |
|
Yes it would. 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 <= 1With the default as a mandatory case name? |
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:takescases:— an ordered set of arms over a declaredforeach:, the last of them the fallback. The value at a coordinate is the first arm whosewhenholds there; the last arm carries nowhen. 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.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:
feat(language): a named expression may write a constant as a number— the bug writing the example found, separable fromcases:.feat(language): a named expression may give a value per region— the construct and its printing. The two halves do not separate:CasesNodejoinsArithmeticNode, andtest_the_golden_model_carries_every_node_kind_the_walk_rendersholds the walk toget_args(ArithmeticNode).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 ciclean on9a215da:lint(ruff,pyrefly0 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
whenholds 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().omissionsexists 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:when:, notwhere:. A case selects which value a coordinate takes; it creates no absence and deletes no row, which is whatwheremeans on every other block (rule 6). A cased expression has nowhereof 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 itswhenis not —always_onabove is exactly that. Eachwhenis 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 declaredforeach, 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:
whereatom added later would have had to teachpartition.pyabout its cells, or the procedure would silently get more conservative._check_cases,_resolved_when, the witness-bearing message and the "awhendid not resolve, so the partition would misreport" bail-out are all gone.boundaryandinteriorabove drop thecommittable and …that feat: cases on a named expression, and referencing one #36's version needed, and the printed block loses a conjunction per row.\text{otherwise}on the last row, which feat: cases on a named expression, and referencing one #36'sformat.casesdocstring 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 joinsBranchNode(#52's named groups), degree,_adds,is_quadratic,_degreeand boundedness' variable walk all reach the arms throughchildren()— only expansion, resolution, the dim algebra and the typeset walk needed an arm of their own, andpyrefly'sassert_neveris 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;
_degreetakes a max over arms rather than a sum, sop * p * xis still refused at degree 3; apiecewise: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:
and the block prints once, under a new Definitions section between
Subject toandVariable domains— where a paper states a quantity defined by region: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 wayconstraints()resolves a constraint, rather than collecting what the other sections happened to reach: no reached-set onWalk, no fixpoint over arms that name further cased expressions, and no ordering dependency between the sections of the document.printed_expressionsreturns a tuple rather than a set precisely because the section's row order is the file's;test_the_definitions_print_in_declaration_orderdeclares 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
ifandotherwiseand aFormatonly stacks the rows — the splittypesetting/README.mdstates.Format.casestakes(value, condition)pairs both already rendered.Symbols. Cased expressions join the symbol pool, deriving a symbol like any other name, and
--symbolscan 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
whennaming 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_expressionsresolves each arm and asksdegree.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_awaystill 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 acases_rowclass attribute, the shapedashandoperatorsalready use: Markdown overrides it with\crrather 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.outfiles, 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.yamlis the formulation #2 factors:ramp_upholds a running unit toramp_limitand a starting one tostart_up_limitin one inequality instead of three.tools/render_tex.pypicks 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: 1is how a constant case is spelled, YAML reads it as an int, and the annotation took only a string — so the block inexpressions.mddid 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 amode='before'rewrite is otherwise invisible to it. A constraint, an objective and apiecewise:link are untouched: none of them is a bare number. Booleans still fail:trueis 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,ExpressionBlockgainingforeach/cases, the two form validators, the round-trip serialiser and the number coercion),expression_parser.py(CasesNode/CaseArm, joiningBranchNodeandchildren()),expansion.py(CasesNodewhere the name stood),resolution.pyanddimensions.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.yamland 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.pyis what catches one going stale.