Skip to content

feat(language)!: a missing row is refused only where the model reads it, a file names its value instead of neutral, and the program says where each parameter is read - #880

Closed
FBumann wants to merge 26 commits into
mainfrom
feat/refused-where-read
Closed

FBumann wants to merge 26 commits into
mainfrom
feat/refused-where-read

Conversation

@FBumann

@FBumann FBumann commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: Lets do it — And we need to document the now more less ambigouos behaviour and cotract!

Prompt: Can mathspec provide sth in the Program so specsolve can refuse right at attach?

Prompt: Should we combine them into one PR? With one description?

Prompt: Yes, go ahead. The decisions are right. I have one correction to the review and four additions.

Note

The following content was generated by AI.

missing: refused now refuses a row only where the model reads it, and Program.reads gives that set as a mask a tool evaluates at attach. neutral is gone: a file writes the value. A masked variable may stand as any number. Imports break: TranslatedPredicate is WindowPredicate.

This PR carries what were #881 and #885. Departure from AGENTS.md: the two halves can be separated, but the contract has no code in mathspec except Program.reads, and its oracle is the only check of the contract, so they are reviewed together.

Contract: where a parameter is read

absence.md has a new section, Where a parameter is read. The model reads a parameter at these coordinates: a row it builds, a summand that exists, a bound of a variable that exists, an offset=/window= amount, the objective, a reported expression, the breakpoints of a curve, and a term with adds_to: a given expression wherever the term is present. A row that an absent term removes reads nothing, and a row with no variable term left is not built.

Inside a where, only a bare bool name reads. A flag such as where: committable is read wherever the model evaluates that where: every coordinate of a variable's or constraint's dims, the when of a case wherever the expression that holds it is evaluated, and the positions that count, shift or at look at. A flag in an assumptions: entry is read at every coordinate of the frame of the assumption. So a missing flag is still refused, as in 0.3.0. Without this rule, 45 bool flags in the PyPSA spec would never be refused.

A comparison and a bare number or label read nothing. A bare number tests whether the row is there, and a comparison at a missing row is false unless missing: names a value. Both sides of AND are evaluated, so if a comparison counted as a read, the guard where: cost AND cost > 0 would refuse the rows it is there to skip. Two str labels in PyPSA are used only in comparisons, so they are not protected.

Two load refusals relied on "a row at every coordinate", so they are removed. This moves a limit: a file may now write both forms.

  • a bare int/str name in a where under refused;
  • a values table of a curve under points: declared refused. The curve reads no row past its mask.

limits.md never listed either refusal, so it has no line to change.

Readings: refused, absent, or a value

neutral read a missing row as 0 or false. It was named for "contributes nothing", which holds only for an additive term. As a factor it dropped the whole term. As an exponent it gave 1. As an offset= it read the coordinate itself. Its three refusals (divisor, bound, constant side) are now what refused does.

  • A parameter takes refused, absent or a value. 'neutral' gets the closed-vocabulary error, which names the valid readings.
  • absence.md has a table with one row per position (coefficient or constant, summand, divisor, bound, offset=/window= amount, where comparison, bare flag, bare number or label) and one column per reading. Under absent, a missing amount makes the shift/sum_back absent there.
  • A divisor that a missing row of a missing: 0 parameter can make 0 is refused at load. The check walks the whole divisor: a factor, the numerator of a quotient, the base of a power, the operand of a sum, a window, a shift or a lookup, a named expression, any region of a case, and a sum only where every addend can be 0, so c + 1 loads. where: c > 0 beside x / c is still refused: the refusal is strict but sound, and its rewrite (missing: absent) works without a guard.
  • A variable takes absent or any finite number. It needs a where:, and the number must fit the variable: 0/1 for binary, an integer for integer, and inside any literal bound. A parameter bound is data, so it is not checked. A curve gate is binary, so it takes 0 (no curve) or 1 (ungated, which is also what absent gives).

One reading changes: a where comparison. Under neutral, a comparison at a missing row was false. A value compares as itself, so cost < 1 becomes true at a missing row under missing: 0. Every comparison on a migrated parameter in examples/ is > 0, which is false both ways. To keep the old reading, write where: cost AND cost < 1.

Migration. examples/, tests/expand/ and the golden fixture use 0 for float/int and false for bool. The divisor refusal caught the PyPSA module sizes: Generator_p_nom_mod divides in *_maint_modstatus_*. Every arithmetic read of the seven *_nom_mod parameters sits inside a *_nom_mod > 0 mask, so they keep the default refused. Regenerated: the schema, the golden output, the gallery and notation pages.

Program.reads, model_trees and WindowPredicate

Program.reads maps each refused parameter to a Mask over its dims that holds where the model reads it. A tool evaluates the mask at attach, as it evaluates a where, and refuses a missing row where it holds. No row needs building first. reading.md documents it with an example the suite runs. On a program that still holds a piecewise: curve, reads raises a LanguageError that names spec.expand('piecewise'), because the rows of the curve do not exist yet.

One list of what goes into the model. model_trees yields every tree whose value goes into the model, with what holds it: both sides of each constraint, the objective, each bound, each curve link and each term. Lowering marks in_math from it, and reads starts its walk from it, matching on the kind with assert_never, so a kind added to the list reaches both. A reported expression and an assumption are not model trees; reads walks them separately.

mathspec/_reads.py builds the masks with three walks.

  • Two run bottom-up, each over a node's own frame. present is where absence has not removed the value. live is where a variable term is left.
  • One runs top-down. read starts at each model tree. Each operator hands it to its operand in the opposite direction: a shift back by k becomes a window ahead by k, sum_back a window ahead of the same width, a lookup at() the sum(by=) that inverts it, and sum(by=) the lookup, both as a Join with the columns swapped. A summand also needs its own presence.
  • Flags: the top-down walk carries a second mask, evaluated, down to each case's when, and a walk over each where and each assumption passes it to the flags under count, shift and at.

WindowPredicate replaces TranslatedPredicate: the operand holds at some position of a window along a dimension, looking back or ahead, with wrap and a partition. A file's predicate shift is a window of one position, and offset=-1 loads as "ahead by 1". Only program.reads writes a wider window, or one that looks ahead by a named offset. Its contract states the normal form:

  • a literal offset is 0 or more, and ahead carries the direction;
  • a named offset carries its sign in the data, as on Translate;
  • a width of 0 or less is an empty window, which holds nothing;
  • where a named amount has no row, the window holds nothing; an amount declared with a value reads that value.

A missing named amount therefore makes its operator absent in the masks too: a shift with edge= and a sum_back are present only where the amount has a row. A read through a relation is a Join of an indicator (Cases of 1 and 0) compared > 0, so a missing map row reads as false.

Alternative rejected: reverse and ahead flags on Translate and WindowSum. A default-off flag gives one node two meanings and gets around the assert_never exhaustiveness every walker relies on.

Review fixes

A review of 34cc571 found three wrong masks, a contract gap on named amounts, a zero-divisor refusal that missed most spellings, and cost. Each bug was reproduced before it was fixed: commit c9a44ef7 adds the tests, and on it these fail:

  • the oracle models a-term-added-to-a-given-sum (12 of 12 draws), a-flag-in-an-assumption (12 of 12), and a-missing-named-offset-that-wraps (11 of 12), a case the review did not name: a wrapped shift by a named offset folded "present" to true, so a missing offset counted as present;
  • test_a_program_that_still_holds_a_curve_says_its_reads_are_not_known;
  • test_a_divisor_that_reads_a_missing_row_as_zero_is_refused for -c, 2 * c, c / 2, c ** 2, c + c, sum(c), shift(c) and a named expression standing for c.

The review's finding that a named offset was not recorded as read was wrong: amount() already records it.

Cost, on examples/pypsa.yaml, median of 5 runs, not on an idle machine, before and after the fixes on the same tree: computing reads takes 0.37 s and then 0.26 s. snapshot_weightings_objective had 31 parts with 22 distinct, and now has 22. The masks total 3,588,253 characters of repr and then 3,105,647; the largest is 434,407 characters, because repr writes each shared subtree out again. Connectives are flattened before repeated parts are dropped, and the dims of a predicate are cached by identity.

Verification

Differential oracle. tests/test_reads.py holds an oracle that shares no code with _reads.py. It builds the rows of 32 small models on random data by the absence rules, and records each parameter coordinate that a built row, an existing variable's bound, the objective, a term, a reported expression, an evaluated where or an assumption reads. Each mask is evaluated on the same data and must hold at exactly those coordinates. 32 models × 12 draws = 384 cases. Named offsets and widths are drawn from −1 to 3, so they can be negative, zero, or missing. The data is sparse, so most masks hold on a strict subset. The oracle caught a real bug during the first build: a read through at() dropped the dimensions of the row beyond the node's frame.

Mutation table. Each guard was deleted by hand. The relevant tests were run, the file was restored through git checkout -- with __pycache__ dropped on both sides, and the tree came back clean each time. Rows before the review fixes were taken before the merge of main, which renamed the module and the node classes but changed no logic.

Guard deleted Caught by
zero divisor: a bare missing: 0 parameter 12 divisor cases
zero divisor: through -, sum, sum_back, shift / a named expression / a factor / a numerator / a base / every addend / a case / a lookup 3 / 1 / 1 / 1 / 1 / 1 / 1 / 1
zero divisor: isinstance(declared.missing, bool) still green. No file can reach it: false on a numeric parameter is refused. Kept on review request, so that False == 0 can never read as a zero divisor
binary / integer domain check on a variable number …_outside_what_the_variable_takes_is_refused[binary], [integer]
lower / upper bound check on a variable number …[below-lower], [above-upper]
finite, not-a-flag check on a variable test_a_variable_missing_that_reads_data_is_refused[a-flag], [an-infinity]
a row needs a variable term / both sides present / its where 22 / 34 / 12 oracle cases
a summand needs to be present 30
a shift is read from the other side / a window is read ahead 44 / 15
a join keeps the dims of the read / is read back with its columns swapped 12 / 8
a filled shift is present where it vacates 6
an absent parameter can be missing / a variable read as a number is present 11 / 12
a case is read only in its region / a bound where its variable exists 12 / 13
a named offset / width is read 12 / 12
a flag is recorded / only a bool name reads 59 / 12
a when is read where the case is evaluated / a case below is found 11 / 11
a flag shifted in a where / through a map / counted 12 / 5 / 12
a constraint's / variable's where is evaluated 24 / 24
a term is read where it is present / a flag in an assumption is read 12 / 12
a program that holds a curve is refused 1
a missing amount makes a filled shift absent (two branches) 11 / 11
a missing width makes a window absent 11
a named window never folds to true 11

Six deletions first survived and each got a model or a test that catches it: a row needs its where, a variable read as a number is present, only a bool name reads, a when is read where the case is evaluated, a missing amount makes a filled shift absent, and the zero divisor through a case or a lookup.

On the examples. program.reads computes for every spec in examples/; the ones with a curve are expanded first. The full pypsa.yaml has 188 masks. Only its two str labels are never read.

Coverage moved. neutral cases became 0/false cases. an-int-under-neutral, neutral-on-a-label and the neutral exclusivity reading are removed, and their value counterparts stay. The bare-name refusal cases became loading cases. …_under_points_is_not_refused became …_may_be_refused. The reading page no longer shows a mask for an unexpanded curve, because reads raises there.

Gates on 01c80e4: pixi run lint and pixi run test pass (3325 passed, 1 skipped). docs-build stops only on the strict warning for pathlib.Path, because the container's proxy blocks docs.python.org. compile-tex was not run.

Not verified. The oracle does not cover a partition (within=), a Dual in a reported expression, or given parameters. The masks pass a partition through unchanged, so their correctness there depends on WindowPredicate and Join meaning in an engine what they mean on a Translate.

Not done
  • No engine evaluates WindowPredicate or program.reads yet. specsolve and its linopy backend need their own PRs: the rename, the wider window, evaluating the masks at attach, and the value forms of missing:.
  • x / shift(p, along=t, offset=1, edge=0) divides by the edge= value 0 at the edge whatever missing: says. A check at load could catch it; it is a separate bug.
  • A relation's refused still checks every key combination when the data is attached.
  • missing: <parameter> on a variable. A named expression with cases: already says "the variable where the mask holds, the data elsewhere".
  • The masks share subtrees but are not simplified beyond folding literals and dropping repeated parts, so some carry a redundant ∃.
  • The 0.3.0 release notes still name neutral. They are history.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng

… reads it

`refused` was checked over every coordinate of a parameter's dimensions,
so no `where:` could excuse a row the model never reads. It now refuses a
missing row only where the model reads the value: a row it builds, a bound
of a variable that exists, a summand that exists, an offset or window
amount, the objective, a reported expression, a curve's breakpoints. A
`where` reads no parameter.

Two load refusals that read `refused` as complete coverage go: a bare
`int` or `str` name under `refused`, and a values table of a curve under
`points:` declared `refused`.

absence.md: median 16 words, 4 sentences over 25.
piecewise.md: median 16 words, 3 sentences over 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
@read-the-docs-community

read-the-docs-community Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

… `missing: neutral` is gone

`neutral` read a missing row as 0 or false, named for an intent that
holds only where the parameter is an additive term. Elsewhere it read 0
anyway: a whole term dropped, an exponent gave 1, an offset read the
coordinate itself. Write the value instead: `missing: 0` or
`missing: false`. A variable takes `missing: 0` for "zero outside the
mask". A parameter named `missing: 0` that stands as a divisor is
refused at load.

absence.md states what each reading does at each position, amounts
included: under `absent`, a missing offset or window makes the operator
absent there.

The PyPSA module sizes `*_nom_mod` keep the default `refused`: every
arithmetic read sits inside a `> 0` mask, and the divisor refusal caught
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
@FBumann
FBumann added this pull request to stack #882 October 15, 2026 09:58
…ide its mask

`missing:` on a variable took `absent` or `0`. It now takes any finite
number, refused at load where the number leaves the variable's domain or
its literal bounds. The absence table states a constant once, on either
side of a comparison, and the zero-divisor refusal ignores a `false`.

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

FBumann commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Prompt: And merge 880 and 881 into one PR

Note

The following content was generated by AI.

Closed in favour of #881, which carries every commit of this PR and has one changelog line for both changes.


Generated by Claude Code

@FBumann FBumann closed this Oct 15, 2026
@FBumann FBumann reopened this Oct 15, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
@FBumann FBumann changed the title feat(language)!: a refused parameter needs a row only where the model reads it feat(language)!: a missing row is refused only where the model reads it, and a file names its value instead of neutral Oct 15, 2026

FBumann commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Prompt: Could mathspec also statically collect that info about a table from its spec? SO we can check upfront?

Note

The following content was generated by AI.

Two points for the contract, from porting specsolve to 0.3.0 (fluxopt/specsolve#2047):

  1. Leave the timing to the tool. "Not done" says tools must move the check from attach to build. The contract only needs the coordinates where a row is required. A table with a row at every coordinate meets any such set, so a tool can accept it at attach and look closer only at a short table.
  2. mathspec can publish where each parameter is read, so a tool checks before it builds. Which coordinates are read depends on the data, because masks read data. But the rule that decides it is fixed by the spec. If the program carries that rule per parameter, every tool evaluates the same object at attach, with the machinery it already has for masks. No tool then needs its own read tracking, and the contract can be tested in mathspec.
What the read set is made of, and what it cannot be

For a parameter p, the coordinates read are the union over every place p is read (a read site). Each site contributes: the coordinates where the site exists, mapped onto p's dims.

  • Where a site exists is a data-only predicate:
    • A constraint row exists where its where: holds and every term that propagates absence is present. That means each variable's where:, each absent parameter's rows, a shift's edge, and a partial relation's rows.
    • A summand exists where its own factors are present.
    • A bound exists where its variable exists.
    • A curve's breakpoints exist where its points: mask admits them.
    • The objective and a reported expression follow the same rules.
  • The map onto p's dims is one of these:
    • identity;
    • a projection, which is ∃ over the dims p lacks, like count(..., over=d) >= 1;
    • a shift by a literal or a named amount;
    • a join through a relation. A lookup at(price, by=zone_of[zone]) reads price at the image of the rows under zone_of.

The image through a relation is a push-forward. As far as I can see, the where grammar has no node for it: a pullback is at(pred, by=...), but the image is not. So the read set should be a program-level object, not a Mask. For example, program.reads[name] could hold a tuple of sites, each with its declaration, its frame, the predicate under which it exists, and its map onto the parameter. Authors never write it; tools only evaluate it.

This also answers the guard idiom without exempting masks: where: cost AND cost > 0 reads cost only where the first conjunct holds, if the site predicate keeps the AND spine in order. A plain capacity > 0 on a lost row could then be refused rather than silently dropping the generator.


Generated by Claude Code

…rogram

Work in progress, not for review. `Program.reads` maps each refused
parameter to the mask over its dims where the model reads it. The
reversed shift and the look-ahead window still use the `reverse` and
`ahead` flags on `Translate` and `WindowSum`, which are to be replaced by
one predicate node. No tests or docs yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
…ing oracle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
…evaluated

A `where` read no parameter, so a `refused` boolean flag that only masks
rows, such as an `extendable` or `committable` flag, was never refused:
a missing row read as false. A bare `bool` name reads the value of the
flag, and has no guard idiom to protect, so it now counts as a read at
every coordinate where the model evaluates that `where`. A comparison and
a bare number or label still read nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
…ameter

`Program.reads` maps each `refused` parameter to a mask over its dims
that holds where the model reads it, so a tool evaluates it when it
attaches the data and refuses a missing row before it builds anything.

`TranslatedPredicate` is `WindowPredicate`: the operand holds at some
position of a window along one dimension, back or ahead, with wrap and
a partition. A file's predicate shift is a window of one position.

docs: reading.md documents `program.reads` and the window predicate,
and absence.md points to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
@FBumann FBumann changed the title feat(language)!: a missing row is refused only where the model reads it, and a file names its value instead of neutral feat(language)!: a missing row is refused only where the model reads it, and the program says where that is Oct 15, 2026
Conflicts came from #855, #878 and #870 on main. Resolved toward main's
names: modules start with an underscore, so the read masks live in
`mathspec._reads`; program nodes are `ParameterReference`,
`VariableReference`, `ExpressionReference` and `MaskReference`; spec
classes end in `Spec`; the docs call a named item an entry. The refusals
main still carried for `refused` under `points:` and a bare `int` or
`str` name stay removed, and `neutral` stays gone from main's new lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
@FBumann FBumann changed the title feat(language)!: a missing row is refused only where the model reads it, and the program says where that is feat(language)!: a missing row is refused only where the model reads it, a file names its value instead of neutral, and the program says where each parameter is read Oct 15, 2026
FBumann added a commit to fluxopt/specsolve that referenced this pull request Oct 15, 2026
…ssing row means, a sum or a lookup names the columns of its relation, and a dimension declares whether its order counts (#2047)

> **Prompt:** Lets update specsolve to the latest mathspec

> [!NOTE]
> The following content was generated by AI.

specsolve now requires mathspec 0.3.0. The engine and the linopy lane
build 0.3.0's program and apply each declaration's `missing:`. Every
spec is in the 0.3.0 language. The PyPSA parity gate runs the 0.3.0
corpus: 68 rungs match, and rung 63 differs as a known failure of that
corpus.

<details><summary>What changed in the engine</summary>

- **Relations.** A sum through a relation is now a `Sum` over the axes
of a `Join`, and a lookup is a bare `Join`. A `where` reads a
`JoinedPredicate` or a `NamedMask`. `relations.py` maps `JoinColumns`
onto the old consumed/produced/joined roles: dropped, added, kept.
- **`missing:` at attach** (`attaching.py`):
- A parameter or relation short of a row is refused by default. The
message names the first missing coordinate and the number of others.
- A declared value fills the table, so nothing downstream sees the gap.
A bare numeric name in a `where` still asks whether the data gave the
row, through the `given` frames, as 0.3.0 says.
- A dimensionless parameter may arrive with no row when it declares
`missing:`.
- **`missing: absent` in the build.**
- The parameter's piece carries a presence frame, so absence follows
mathspec's rules: a constant or divisor removes the row, a summand is
one fewer, a bound leaves its side open.
  - A constant piece's presence now restricts the rows (`assembly.py`).
- **`missing: neutral`.** It keeps 0.2.1's reading. The engine refuses a
missing row only where it stands as the whole constant side, as a
divisor or as a bound. Under a sum, a missing row is one summand fewer:
mathspec's own PyPSA file needs this for
`sum(Transformer_phase_shift_weight, over=transformer)`.
- **`given: masks:`**, new in 0.3.0, is refused like every other
`given:` name. The refusal named no mask before (`this spec reads under
given:`). `a-mask-another-file-declares` in `test_composition.py` fails
without the fix.
- **A latent bug, fixed** (`pieces.py`). A product carried a presence
whose key was only implied onto a piece with more dims, which crashed. A
masked variable under a filled `shift` could reach it before. The absent
breakpoint table of a ragged curve now reaches it, and
`test_a_ragged_curve_down_to_one_point_is_refused` covers it.
- **The archive's `INPUTS_LAYOUT` rises to 2**, because `spec.yaml` is
now written in the 0.3.0 language.

</details>

<details><summary>How the specs were migrated</summary>

- **The call syntax** was rewritten mechanically: `sum(x, by=l, over=a,
into=b)` → `sum(x, over=a, by=l[b])`; `at(x, by=l, over=a, into=b)` →
`at(x, by=l[a])`; `by=l, within=c` → `within=l[c]`; `rel.col` →
`rel[col]` in a `where`; `absence: zero/undefined` → `missing:
neutral/absent`.
- **`ordered:` and `missing:` were decided by what each declaration
means, not by the errors.** Time axes, periods, breakpoints and SOS
sizes are `ordered`; sets of things are not. A parameter keeps the
default `refused` unless its description, or the intent of its test,
says the table is sparse.
- **Errors after that pass were treated as findings:**
- OSeMOSYS `build_cost` and `holding_cost` are given only for
technologies that have a cost, so they are `neutral`.
- The tests of the engine's own refusals (sparse divisor, sparse
constant side, NULL bound) now declare `missing: neutral`, the only
reading that still reaches those guards. `tests/test_missing.py` covers
the default refusal and each reading on both lanes.
- A test that shifted along `generator` now shifts along `snapshot`,
because 0.3.0 refuses a `shift` along a set.
- **Generated pages** were regenerated or re-synced: the gallery fences,
gallery math, constructs, the walkthrough golden file and the ladder
pages.

</details>

<details><summary>PyPSA parity: the corpus moves to v0.3.0</summary>

The v0.2.1 corpus does not load under 0.3.0, so `pypsa-parity.yml` now
checks out `v0.3.0`. That corpus pins PyPSA master at `51986084`, not a
release, and the workflow pins the same commit. `prep.py` prepares the 9
parameters 0.3.0 adds. Rungs that patch the base file are now read
through their `PATCH`. The projections keep the `masks:` a rung needs.
`untested.txt` lists the gaps as the gate measures them; the gate
reports 0 unlisted.

**The linopy lane now builds rungs 56, 66 and 68.** Each has a dimension
with no labels, and the lane's `operator_at` raised `IndexError` there.
It now stands a made-up label in, which the mask removes again.
`test_a_lookup_into_a_dimension_with_no_labels_reads_nothing_on_both_lanes`
fails without the fix. On rung 66 the lane keeps rows of zero
coefficients, as linopy does, so the gate's row reader now skips a label
with no nonzero coefficient, as linopy's LP writer and row count do. All
three rungs are structurally equal to PyPSA.

**A dual that differs is checked against the range PyPSA's model
admits.** Where an optimum is degenerate, a row has a range of optimal
duals, and the two solvers may return different ones. For each differing
row, the gate moves the row's right-hand side by ±0.001 in PyPSA's own
model, re-solves on HiGHS over a copy of its LP file, and measures the
slope of the objective on each side (`differential/pypsa/slopes.py`).
Ours counts as equal if it lies between the two slopes. PyPSA's own dual
must lie there too, or the run stops; that self-check caught a sign
error in the first version. The ladder pages show the count as `inside
PyPSA's range`.
- **Rung 55:** 8 differing rows, all inside.
- **Rung 67:** 6 differing rows, all inside. `Process-p-ramp_limit_down`
is written negated, as `Link-p-ramp_limit_down` is, and
`deviations.yaml` now says so. Rungs 17 and 45 check that record too.
- **Rung 57:** the `duals:` reason for `Generator-p_nom_set` (two
identical pins that split one dual) is gone, because the range check now
accepts that split and the gate refuses a reason no rung needs.

**Rung 63 is a known failure of the 0.3.0 corpus.** `pypsa.yaml` builds
`p_nom` and its floor row for an extendable unit that is not active, and
PyPSA builds neither
([energy-models/mathspec#883](energy-models/mathspec#883)).
The fix is
[energy-models/mathspec#886](energy-models/mathspec#886).
It adds a `<Component>_active_asset` parameter for each of seven
components, so this gate's `prep.py` must feed them when the corpus
moves past 0.3.0. `differential/pypsa/known_failing.yaml` lists the rung
with that issue. The gate and the ladder-page test expect it to differ,
and fail once it matches, so the line goes with the corpus move that
carries the fix.

</details>

<details><summary>Mutation table of the dual range check (taken by
hand)</summary>

Each mutation was made in `differential/pypsa/slopes.py`, the file
restored with `git checkout --`, and `__pycache__` dropped on both
sides. `tests/test_slope_ranges.py` ran each time.

| guard | result |
|---|---|
| a dual outside the range is refused (`inside`) | caught |
| a side that makes the model infeasible has an infinite slope | caught
|
| the range takes both sides, not one | caught |
| the tolerance at an end of the range | still green, then caught by the
probe `past-an-end-by-less-than-the-tolerance` |
| PyPSA's own dual lies in the range (`parity.py`) | not reachable from
the suite; the gate's own self-check |

</details>

<details><summary>Mutation table of the engine (taken by hand)</summary>

Each guard is a condition inside a line, which `tools/mutate.py` cannot
express as a line deletion. Each mutation was taken in a clean worktree
at `c801bf1`. The file was restored with `git checkout --`,
`__pycache__` was dropped on both sides, and the full suite ran without
the two parity-dependent files. The tree came back clean.

| guard | result |
|---|---|
| a relation short of a key is refused (`attaching.py`) | caught |
| a parameter short of a row is refused (`attaching.py`) | caught |
| a declared value fills a missing row (`attaching.py`) | caught |
| a bare numeric name asks whether the data gave the row
(`predicates.py`) | caught |
| an absent parameter carries its presence (`compiler.py`) | caught |
| an absent bound leaves its side open (`compiler.py`) | caught |
| a constant piece restricts the rows (`assembly.py`) | caught |
| an absent parameter is not owed its coordinates (`assembly.py`) |
still green, then caught by the purpose-built probe
`absent-summand-on-a-constant-side-is-not-owed` |
| a product spells out the keys it carries (`pieces.py`) | caught |

The `assembly.py` filter this last probe tested was removed later, in
`e154a77`, when summed gaps stopped being refused. The probe still
checks the behaviour.

</details>

<details><summary>Gates run, what was not run, and defaults departed
from</summary>

- **Run:**
  - Full suite, at `86324dd`: 5759 passed.
- The PyPSA parity gate, in full at `b913a56`: 68 match, 1 differs (rung
63). Its exit at `86324dd` was checked on the committed stamps: 0 with
rung 63 listed as a known failure, and 1 when a rung that matches is
listed.
  - `ruff check .`, `ruff format --check .` and `pyrefly check` pass.
- **Not run:** `pixi run check` (no pixi in this container; the same
gates ran through uv), `docs-build`, and the benchmarks.
- **One PR, not three.** The pin forces the engine port, the spec
migration and the corpus move together, because none of them loads
without the others. The commits keep them apart for review.
- **Not done:**
- The `refused` check where the model reads the value, which
energy-models/mathspec#880 introduces, is left for the release that
carries it. This PR follows 0.3.0 as released.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP

---------

Co-authored-by: Claude <noreply@anthropic.com>
A term added to a given sum, a flag in an assumption, and a wrapped
shift by a missing named offset each get a wrong read mask. A program
that still holds a curve answers its tables with a mask false
everywhere. The zero-divisor refusal misses every spelling but a bare
parameter. All of these fail on this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng
One list, `model_trees`, names each tree whose value goes into the
model: constraint sides, the objective, bounds, curve links and terms.
Lowering marks `in_math` from it, and `Program.reads` walks from it, so
a term added to a given sum is read where it is present, and a program
that still holds a curve is refused with the rewrite. A bare flag in an
assumption is read at every coordinate of its frame.

A named offset or width with no row makes its operator absent, and the
window predicate holds nothing there; a wrapped shift no longer folds
that away. The window contract states its normal form: a literal offset
is 0 or more, a named offset is signed, and a width of 0 or less is an
empty window.

The zero-divisor refusal walks the whole divisor tree. Connectives are
flattened before repeated parts are dropped.

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

FBumann commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Superseeded by #894

@FBumann FBumann closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants