Skip to content

feat: a static partition check for expression cases - #33

Closed
FBumann wants to merge 3 commits into
mainfrom
claude/constraint-cases
Closed

FBumann wants to merge 3 commits into
mainfrom
claude/constraint-cases

Conversation

@FBumann

@FBumann FBumann commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Prototype for #2, stacked on #31 — base is claude/new-session-divgg4, so the diff here is one module and its tests. Retarget to main once #31 lands.

Follows @FabianHofmann's proposal: the cases go on a named expression, not on the constraint. Not wired into the schema — expressions: accepts neither foreach nor cases yet, and that is the next change rather than this one. This is the decision procedure the #2 argument turns on, so it can be judged on what it proves and what it refuses.

Why expressions

Three independent axes — horizon boundary, is_modular, is_committable — multiply at the constraint level and add at the expression level: eight constraint cases against seven expression cases. The count is the smaller half of it. The eight write the ramp inequality eight times, so a change to it is eight edits that nothing checks agree; the seven write it once:

expression: p - previous_p <= ramp_limit_up * ramp_capacity * previous_status

It also answers the objection that opened #2 better than a constraint cases: did: the constraint keeps one name and one expression, so Generator_ramp_limit_up.dual is unambiguous by construction rather than by proof.

And the obligation factors. If each expression's cases partition independently, the product is automatically a partition of the constraint's rows — 2+2+3 small proofs rather than one eight-way one. The three quantities #2 factors that ramp limit into are a test here.

What it decides

check_partition(cases, schema) answers whether a set of when masks claims each coordinate of the frame exactly once. Three obligations, all the same unsatisfiability question:

disjoint case_i AND case_j unsatisfiable, for each pair
exhaustive NOT (case_1 OR … OR case_n) unsatisfiable
no dead case case_i alone satisfiable

Three outcomes: PARTITION, VIOLATED, UNDECIDED. The third is refused by a caller exactly as the second is; a checker that guesses where it cannot decide buys nothing over no checker.

Atoms are grouped by the subject they talk about and each subject is cut into cells, so equality against distinct labels comes out exclusive. A purely propositional reading invents a region where one storage is both a battery and hydrogen, and refuses the category split that motivates the feature.

case set verdict
the three ramp quantities from #2 partition
the storage split from #2 partition
a written complement, x / NOT (x) partition
an ordering on a position, == 0 / > 0 partition — what #31 unblocks
the same counted from the back, == -1 / < -1 partition
a category split, with a case for the labels it does not name partition
numeric bands, with a case for an absent capacity partition
first/last/middle on a dimension declaring values: partition
two cases that can both claim a coordinate violated, naming both and a witness
a coordinate no case claims violated
a case whose when can never hold violated
bare soc_initial vs soc_initial == 0 violated — defined is not non-zero
first/last on a data-bound axis undecided — 0 and -1 are one row on a one-member axis
first/last within a by= group undecided — no declaration sizes a group

Decisions this encodes

  • cases:, not branches:. It renders to \begin{cases}, and "branch" is a control-flow word for a language whose ceiling forbids control flow.
  • when:, not where:. A case selects which value a coordinate takes — it creates no absence and deletes no row, which is what where means everywhere else (rule 6). Under this design an expression has no where at all, so a where: here would be the only one on the block and would read as extent.
  • No where on expressions; cases are total over the frame. Two masked expressions would intersect in the constraint using them — absence spreads and takes the row — so the constraint's row set would stop being readable at the constraint. That is what diagnostics().omissions exists to catch: "rows lost to a mask the constraint never mentions." The price is visible in the tests: with nothing to narrow the frame, the cases have to say what an absent capacity or an unnamed label gets.
  • Constraints get no cases:. Everything varying in the value factors into a named quantity; what cannot factor is the relation, and a differing relation is a differing rule that wants its own name and its own dual. piecewise.py already settles this by precedent — facing >= on the first breakpoint and <= on the last, it emits two named constraints.
  • Macros are a follow-up. Cases there are safe only under a stronger obligation — provable with the names uninterpreted, since a macro has no frame until it is called — which is worth doing separately.

Soundness

The check rests on its cells covering every value a subject can take, so "no witness among the cells" means "no witness". TestSoundness walks a value grid finer than the cells — two points per band, both infinities, absence, labels no mask names — and asserts anything proved a partition survives it. Wider runs off-branch, with ordering comparators in both frames: 40,000 random case sets per frame, 202 and 167 proved partitions, 0 unsound; and 3,000 sets built to be partitions gave 2,187 proved, every one of the 813 refusals a dead case rather than a spurious overlap or gap.

Independence between subjects is an over-approximation, so a spurious world can only manufacture a witness, never hide one — every outcome is conservative in the safe direction.

Still open

The dim rule. A named expression has no foreach today — dims fall out of the body — but no single case's body gives the quantity's shape once cases exist, and one case's body may be a scalar while its when is not. foreach required alongside cases is the proposal; that is the wiring change, not this one.

Checks

Gates reproduced with a 3.13 venv on the versions pixi.toml pins plus prettier@3.9.3: ruff clean, pyrefly 0 errors, reuse lint compliant, 379 passed / 4 skipped.

@read-the-docs-community

read-the-docs-community Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

@FBumann
FBumann force-pushed the claude/constraint-cases branch from 29a8e33 to 94b8a31 Compare August 22, 2026 10:12
@FBumann
FBumann force-pushed the claude/constraint-cases branch from 94b8a31 to 157d920 Compare August 22, 2026 14:48
@FBumann FBumann changed the title feat: a static partition check for constraint cases feat: a static partition check for expression cases Aug 22, 2026
@FBumann
FBumann force-pushed the claude/constraint-cases branch from d5fafb6 to 36ec005 Compare August 22, 2026 21:13
@FBumann
FBumann force-pushed the claude/constraint-cases branch 2 times, most recently from 87fb522 to f95759c Compare August 23, 2026 07:03
@FBumann
FBumann force-pushed the claude/constraint-cases branch from f95759c to 42d69dc Compare August 23, 2026 08:07
@FBumann
FBumann force-pushed the claude/constraint-cases branch from 42d69dc to bd2dcdb Compare August 23, 2026 08:26
@FBumann
FBumann force-pushed the claude/constraint-cases branch from bd2dcdb to faca235 Compare August 23, 2026 12:34
@FBumann
FBumann force-pushed the claude/constraint-cases branch from faca235 to 417f239 Compare August 23, 2026 12:42
Base automatically changed from claude/new-session-divgg4 to main August 23, 2026 18:36
@FBumann
FBumann force-pushed the claude/constraint-cases branch from 417f239 to 643dba9 Compare August 23, 2026 18:36
claude added 3 commits August 23, 2026 18:36
A constraint with `cases:` is one rule whose expression varies by
region, and it is *one* constraint — one name, one row per coordinate,
one dual — only if the cases claim each row exactly once. This decides
that claim before any data binds, which is rule 2 in a new position.

Both obligations are conditioned on the constraint's own `where`, which
keeps that key meaning exactly what it means today (which rows exist)
while `cases` says only which expression each existing row gets:

  disjoint      where AND case_i AND case_j          unsatisfiable
  exhaustive    where AND NOT (case_1 OR ... case_n) unsatisfiable
  no dead case  where AND case_i                     satisfiable

Conditioning cuts both ways: cases overlapping somewhere the `where`
already excludes are not an ambiguity and an unconditional check would
refuse them, while a `where` wider than the cases cover is a gap that a
"the rows are whatever the cases claim" reading could not express.

Atoms are grouped by the subject they talk about and each subject is cut
into cells, so equality against distinct labels comes out exclusive — a
purely propositional reading invents a region where a storage is both a
battery and hydrogen, and refuses the category split that motivates the
feature. Three outcomes, and the third is refused rather than assumed:
two positions counted from opposite ends of a dimension whose extent
only data knows are the same row on a one-member axis.

Every case carries its own mask. An open "everything else" case would
have made exhaustiveness true by construction, but `not (x)` says the
same thing and a mask edited without its restated negation lands here as
a gap or an overlap rather than a silent change of model.

Not wired into the schema: `cases:` is not a key the model accepts, and
the spelling is still open (#2). This is the decision procedure the
argument turns on, so it can be judged on what it proves and refuses.

Tests pin one atom's reading at a time, plus a seeded fuzz asserting that
anything proved a partition survives a value grid finer than the cells.
Two of them cover a split counted from the back, where the rank cells
had to mirror: the open end is before the earliest position named, since
nothing follows -1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
`_dtype_of` re-derived what `Namespace.dtypes` already builds, and got a
worse answer: it handled parameters and dimensions and returned None for
every lookup, so an ordering on one — `period_of < 2040`, with `period`
declared `dtype: int` — was refused as "has dtype None". Namespace covers
lookups, including the non-obvious part that a targeted lookup takes its
target dimension's dtype, so a natural multi-period split is now decided
rather than refused. The dtype tuple also listed 'date', which is not a
declarable dtype.

`_Observed` is gone. `bare` was never read, `ordered` never outlived the
node that set it, and `literals`/`positions` were never both populated for
one subject — the tag was already `Subject.kind`, so a set per subject
says it.

`_Frame` replaces threading `domains` and `extents` separately, and
memoises each atom's subject. `_subject_of` is a pure function of the node
that allocates a fresh `Subject`, and `_atom` was calling it once per atom
per cell: 972k calls in the fuzz. The nodes are `@dataclass(eq=True)` and
so unhashable, hence the `id(node)` key.

Witnesses are now built only while there is room for them — the verdict
carries four, and a wide `where` over narrow cases would otherwise render
one per uncovered cell and discard all but those.

An unresolved node reaching `_subject_of` is now an AssertionError, as in
`dimensions.py` and the typesetter. It is a caller that skipped
`resolve_where`, not a model to refuse, and routing it to UNDECIDED put a
message with no rewrite in front of a model author.

Smaller: the two `except Undecidable` arms are one, `Gap` carried nothing
a witness did not, the rank-cell frames share their head, and the prose
loses what it said twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
#2 moved: the cases go on a named expression rather than on the
constraint that uses it. Three independent axes multiply into eight
constraint cases and add into seven expression cases, and the inequality
is written once instead of eight times — a change to it is then one edit
rather than eight that nothing checks agree.

It also answers the objection that opened #2 better than a constraint
`cases:` did. The constraint keeps one name and one expression, so its
dual is unambiguous by construction rather than by proof.

`where` goes with it. An expression carries no mask: it is **total** over
its frame or it is refused. Two expressions each masked would intersect
in the constraint that used them — absence spreads and takes the row —
so a constraint's row set would stop being readable at the constraint,
which is what `diagnostics().omissions` exists to catch ("rows lost to a
mask the constraint never mentions"). Nothing is conditioned here now,
and the price is visible in the tests: with no mask to narrow the frame,
the cases have to say what an absent capacity or an unnamed label gets.

The per-case key is `when`, not `where`. A case selects which value a
coordinate takes; it creates no absence and deletes no row, which is what
`where` means everywhere else (rule 6). Under this design an expression
has no `where` at all, so a `where:` here would be the only one on the
block and would read as extent — precisely the wrong reading.

Adds the three quantities #2 factors a PyPSA ramp limit into as a test,
since they are the case the design is for.

Not wired into the schema — `expressions:` accepts neither `foreach` nor
`cases` yet, and that is the next change rather than this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
@FBumann

FBumann commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #70, along with #36 — the PR this decision procedure existed for.

The difference. This module answers "do these cases partition the frame?" without data: pairwise disjointness, joint exhaustiveness and no dead case, all as one unsatisfiability question over cells of each where-atom's subject. It is careful work and the conservatism argument is right.

#70 makes the question not arise. The arms of a cases: block are ordered, first match wins, and the last one carries no when::

cases:
  always_on: { when: "not committable", expression: 1 }
  boundary:  { when: "position(snapshot) == 0", expression: status_initial }
  interior:  { expression: shift(status, over=snapshot, offset=1) }

Disjointness is then unrepresentable rather than proved — two arms cannot disagree at a coordinate, because only the first to match is read. Totality is structural — the fallback has no condition to fail. Both properties belong to the shape of the block, so 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.

The reason to close this rather than keep it for a stricter mode later is the coupling: every atom the where grammar gains would have to teach partition.py about its cells, or the procedure gets quietly more conservative — refusing case sets that are fine, with no test that would notice. That is a standing maintenance obligation on a language that is still growing, in exchange for diagnostics the ordered form does not need.

What is genuinely lost: a fully shadowed arm is now silently dead rather than a load error naming a witness. If that turns out to matter, it is a much smaller check than this one — and it can be written against the arms that are actually there, rather than against every world the atoms could describe.

The branch stays; nothing here is deleted, only unmerged.

@FBumann FBumann closed this Aug 25, 2026
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 deleted the claude/constraint-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