Repository navigation
feat!: specsolve requires mathspec 0.3.0, where a file says what a missing row means, a sum or a lookup names the columns of its relation, and a dimension declares whether its order counts - #2047
Conversation
…ssing row means, a sum or a lookup names the columns of its relation, and a dimension declares whether its order counts The engine builds 0.3.0's program: a sum through a relation is a Sum over the axes of a Join, a lookup is a bare Join, and a where reads a JoinedPredicate or a NamedMask. Attaching applies each parameter's and relation's `missing:`: a short table is refused by default and the first missing coordinate is named, a declared value fills the gap, and an absent row is absence in every position that reads it. The linopy lane follows, every spec and inline test spec is rewritten in the 0.3.0 language, and an archive's inputs layout rises to 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
… missing coordinates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…d a dimensionless parameter may have no row mathspec's own PyPSA file sums a sparse neutral table onto a constant side, which the per-member refusal turned away. A parameter over no dims may now arrive with no row unless it refuses one, so `where: CVaR_omega` can switch a block off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…us against PyPSA master at 51986084 The 0.2.1 corpus does not load under 0.3.0, so the corpus tag moves with the pin. prep.py prepares the parameters 0.3.0 adds, the patched rungs are read through their PATCH, and the projections keep the masks a rung needs. 66 rungs match. Rung 63 differs in structure, from pypsa.yaml building p_nom for an extendable unit that is not active, and rungs 55 and 67 differ only in how a degenerate dual is split between parallel rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
Merging this PR will not alter performance
Comparing Footnotes
|
…s environment skips them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
|
Note The following content was generated by AI. Red checks after
None of these is a difference in the model. Rung 63: filed as energy-models/mathspec#883. Rungs 55 and 67: the duals there are not unique, and both solves report a valid one. I checked this by moving each differing row's right-hand side by ±0.001 in PyPSA's own model and measuring the slope of the objective on each side. The dual is any value between the two slopes.
An earlier version of this comment said rung 67's rows are parallel. They are not: several rows bind at the same point of the solution, so their duals form a range rather than one value. A Generated by Claude Code |
…aves as the gate measures them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
… no labels, rather than raising Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…ask's name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…ficient as not built, as linopy does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…opy lane, which builds them now Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…only inside the range of duals PyPSA's model admits on that row A degenerate optimum has more than one optimal dual on a row. The gate now moves each differing row's right-hand side in PyPSA's own model by 0.001 down and up, and accepts our dual if it lies between the two slopes of the objective. PyPSA's own dual must lie there too, or the run stops. Process-p-ramp_limit_down is written negated, as Link-p-ramp_limit_down is, and now says so in deviations.yaml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…ccepted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…de the range PyPSA's model admits Rung 57's two identical pins now pass the same check, so their duals: reason goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
…odels/mathspec#883's fix differential/pypsa/known_failing.yaml lists the rungs the pinned corpus gets wrong, each with the issue that fixes it. The parity gate and the ladder-page test expect them to differ, and fail when one matches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP
> **Prompt:** Prepare a new release > [!NOTE] > The following content was generated by AI. When this PR merges, it releases specsolve 0.10.0. It moves `## Upcoming version` into a `## 0.10.0 (2026-10-19)` section. That section has a lead paragraph, a "Breaks" list and grouped pull-request lines. An empty `## Upcoming version` stays on top. <details><summary>Why 0.10.0, what was checked, what was not done</summary> **Why a minor version:** three changes since v0.9.0 break something a caller has. AGENTS.md says a break like this raises the minor version. - **Model files** must be in the mathspec 0.3.0 language (#2047). - **0.9.x archives** are refused. `INPUTS_LAYOUT` goes from 1 at `v0.9.0` to 2 on `main`. `ANSWER_LAYOUT` stays at 4. The refusal names the plain files to solve from again. - **Module imports**: every module outside `specsolve`, `specsolve.types` and `specsolve.errors` is private (#2055). **Grouping:** every line from `## Upcoming version` is kept, each with its link. The lines are grouped into: the language, fixes and speed, the Python API, the benchmarks, and the site. #2056 is a `chore` and has no line. **Checks run:** - `python -m tools.changelog check`: `releases 0.10.0 on merge` - `python -m tools.changelog notes 0.10.0`: read through - `pytest tests/test_changelog.py tests/test_docs_site.py`: 49 passed - `pixi run format-check`: clean - `pixi run docs-build` (strict): no issues The full suite was not re-run, because this PR changes only `CHANGELOG.md`. **Not done:** - No tag was pushed by hand. `release.yaml` tags the merge commit and publishes to PyPI after a reviewer approves the `pypi` environment. - The date is today's. If the PR merges on a later day, the heading can be changed to that day before merging. - `fluxopt/specsolve-showcase` pins `specsolve<0.9`, so this release does not reach it. When it moves up, `tests/test_solve.py` must name `specsolve._strategy`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QpfwQs9pCQFKxsL8ZCXaC6)_ Co-authored-by: Claude <noreply@anthropic.com>
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.What changed in the engine
Sumover the axes of aJoin, and a lookup is a bareJoin. Awherereads aJoinedPredicateor aNamedMask.relations.pymapsJoinColumnsonto the old consumed/produced/joined roles: dropped, added, kept.missing:at attach (attaching.py):wherestill asks whether the data gave the row, through thegivenframes, as 0.3.0 says.missing:.missing: absentin the build.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 forsum(Transformer_phase_shift_weight, over=transformer).given: masks:, new in 0.3.0, is refused like every othergiven:name. The refusal named no mask before (this spec reads under given:).a-mask-another-file-declaresintest_composition.pyfails without the fix.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 filledshiftcould reach it before. The absent breakpoint table of a ragged curve now reaches it, andtest_a_ragged_curve_down_to_one_point_is_refusedcovers it.INPUTS_LAYOUTrises to 2, becausespec.yamlis now written in the 0.3.0 language.How the specs were migrated
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 awhere;absence: zero/undefined→missing: neutral/absent.ordered:andmissing:were decided by what each declaration means, not by the errors. Time axes, periods, breakpoints and SOS sizes areordered; sets of things are not. A parameter keeps the defaultrefusedunless its description, or the intent of its test, says the table is sparse.build_costandholding_costare given only for technologies that have a cost, so they areneutral.missing: neutral, the only reading that still reaches those guards.tests/test_missing.pycovers the default refusal and each reading on both lanes.generatornow shifts alongsnapshot, because 0.3.0 refuses ashiftalong a set.PyPSA parity: the corpus moves to v0.3.0
The v0.2.1 corpus does not load under 0.3.0, so
pypsa-parity.ymlnow checks outv0.3.0. That corpus pins PyPSA master at51986084, not a release, and the workflow pins the same commit.prep.pyprepares the 9 parameters 0.3.0 adds. Rungs that patch the base file are now read through theirPATCH. The projections keep themasks:a rung needs.untested.txtlists 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_atraisedIndexErrorthere. 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_lanesfails 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 asinside PyPSA's range.Process-p-ramp_limit_downis written negated, asLink-p-ramp_limit_downis, anddeviations.yamlnow says so. Rungs 17 and 45 check that record too.duals:reason forGenerator-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.yamlbuildsp_nomand its floor row for an extendable unit that is not active, and PyPSA builds neither (energy-models/mathspec#883). The fix is energy-models/mathspec#886. It adds a<Component>_active_assetparameter for each of seven components, so this gate'sprep.pymust feed them when the corpus moves past 0.3.0.differential/pypsa/known_failing.yamllists 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.Mutation table of the dual range check (taken by hand)
Each mutation was made in
differential/pypsa/slopes.py, the file restored withgit checkout --, and__pycache__dropped on both sides.tests/test_slope_ranges.pyran each time.inside)past-an-end-by-less-than-the-toleranceparity.py)Mutation table of the engine (taken by hand)
Each guard is a condition inside a line, which
tools/mutate.pycannot express as a line deletion. Each mutation was taken in a clean worktree atc801bf1. The file was restored withgit checkout --,__pycache__was dropped on both sides, and the full suite ran without the two parity-dependent files. The tree came back clean.attaching.py)attaching.py)attaching.py)predicates.py)compiler.py)compiler.py)assembly.py)assembly.py)absent-summand-on-a-constant-side-is-not-owedpieces.py)The
assembly.pyfilter this last probe tested was removed later, ine154a77, when summed gaps stopped being refused. The probe still checks the behaviour.Gates run, what was not run, and defaults departed from
86324dd: 5759 passed.b913a56: 68 match, 1 differs (rung 63). Its exit at86324ddwas 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 .andpyrefly checkpass.pixi run check(no pixi in this container; the same gates ran through uv),docs-build, and the benchmarks.refusedcheck where the model reads the value, which 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 energy-models/mathspec#880 introduces, is left for the release that carries it. This PR follows 0.3.0 as released.🤖 Generated with Claude Code
https://claude.ai/code/session_01A481aCHLAzU6coTE2VB1zP