Skip to content

feat(language): two files that mean the same model write one text - #530

Closed
FBumann wants to merge 5 commits into
mainfrom
claude/adoring-dijkstra-yh8ubw-canonical-dump
Closed

FBumann wants to merge 5 commits into
mainfrom
claude/adoring-dijkstra-yh8ubw-canonical-dump

Conversation

@FBumann

@FBumann FBumann commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Provide a spec dump that removes ambiguity from ordering and other things that let the file differ but mean the same" (#487) — then, setting the scope: "Lets build both rungs as stacked PRs" and "Test the dump thoroughly" … "Rebase 530 onto main." … "This conflicts" … "And do we show how to use this in a how-to? Diff changes etc?" … "add tests tracking the sorting behaviour!"

Note

The following content was generated by AI.

spec.to_yaml(canonical=True), and python -m mathspec canonical model.yaml from a shell. Two files that mean the same model write one text, so a diff shows what they mean differently rather than how they were spelled.

constraints:
  k:
    dims: [h, u]
    expression: |-
      -cap
      + cost * p
      + p
      <= 10

A new how-to page, Compare two models, shows how to diff two files in this form: once, through git diff with a textconv driver, and from Python. #718 is stacked on this PR and adds --check and --write for CI.

The rules, whole

What is sorted

  • The sections come in the order of the fields on Spec (version, description, dimensions, relations, parameters, variables, constraints, objective, expressions, macros, piecewise, sos, assumptions), whatever order the file wrote them in. The keys of a declaration also come in one order.
  • Declarations sort by name, within every section.
  • The terms of a sum sort; each keeps the sign it was written under.
  • The factors of a product sort.
  • A call's keyword arguments sort by keyword.
  • Sorting is by the text an operand prints as, which is a total order over trees: a printed tree parses back to the tree it came from (chore(parser): every expression node prints itself as the file writes it #529's fence), so two operands that print alike are one tree.
  • A sign is the tie-breaker, never part of the key, so a - b and b - a order their terms alike and differ only in which is negative.

What is not sorted, because moving it changes the model

  • Subtraction, division and exponentiation keep their operands where they are.
  • A call's positional arguments keep their places.
  • The regions of a cases: block keep the file's order.
  • A piecewise block's links keep the file's order.

What is folded

  • A sign written twice cancels: - -x is x, and + -x is - x, wherever it stands, including at the top of a tree.
  • A sum under a plus is spliced into the sum around it, on either side: a + (b + c) is a + b + c.
  • A product is spliced on either side: (a * b) * c and a * (b * c) are one product.
  • A sum under a minus is not spliced. a - (b - c) keeps its group, because splicing would flip every sign inside — a rewrite of what the file wrote rather than an ordering of it.
  • Each term and factor is flattened after it is normalised as well as before, because normalising reveals what the written tree hid: a unary plus over a product is one factor until the plus is folded away.

What is never done

  • No constant is folded into another. 2 * 3 stays 2 * 3: a coefficient that changed is what a reviewer is looking for.
  • No macro is expanded. A file that calls one and a file that writes it out still differ, which the thread already called a fair difference.
  • No predicate in the where grammar is touched — a where: string, and an assumption's holds:. It is a second grammar, and printing it is in neither PR of this stack.
  • No dims list is reordered.

How it is laid out

  • An expression of one term is one line, operator and all.
  • A sum of two or more terms breaks one term to a line, each line under its own sign, and a comparison's operator opens the last line. A term that changes is then one line of a diff.
  • Every operand that is itself an operator is bracketed, so the text never relies on the reader knowing what binds tighter.
  • A multi-line expression is written as a YAML block scalar, so the newlines are real rather than escaped.

What the form guarantees, and what it does not

  • Dumping the form again gives the same text. It is a fixed point, not a rewriting.
  • The form loads to the same model: every declaration is there, under its own name, on its own frame, with its sense and its domain.
  • It does not load to an equal Spec. A reprinted expression is a different string, so the default to_yaml() — the round trip reading.md documents — is untouched and the form is an argument away.
  • The form holds no YAML comments.
  • Sorting variables: changes the order a piecewise: expansion meets them in, so a constraint the expansion emits can carry its dims in another order. The frame is the same set; a consumer that lays arrays out in dims order lays them out differently.
The merge onto 0.1.0, the sorting tests and the how-to page

Merge. main renamed the package from math_spec to mathspec (#702), so canonical.py conflicted with the moved package. origin/main was merged in, not rebased, and there was no force-push. The module, its tests and the CLI verb now use the new name. The PR adds its line under ## Upcoming version, which the changelog check on main now requires.

Sorting tests in tests/test_canonical.py:

  • test_a_file_written_backwards_writes_the_same_text: every model in the tree, with its sections, the declarations in each section and the keys in each declaration all written in reverse, writes the same text.
  • test_the_sections_come_in_one_order: every model's sections follow a pinned SECTIONS list, so a reordered field on Spec fails a test instead of changing every diff without notice.
  • test_every_section_is_sorted_by_name: every section of every model.
  • test_an_order_the_form_keeps_is_a_difference_in_the_text: dims, the regions of a cases: block, the links of a piecewise block and the predicates of a where are each reversed alone, and each changes the text.
mutation result
declarations keep the file's order 53 tests fail, among them 26 written_backwards and 25 sorted_by_name
every dims list is sorted a-declarations-dims fails

Docs. New docs/howto/compare.md, in the nav under How-to guides. reading.md names the section order and links the how-to page. The textconv recipe was run in a scratch repository: swapping two factors shows no diff, and changing a coefficient shows one line.

The rebase onto 0.0.0-alpha.120

The branch was rebased onto main as one commit carrying the same change: its ten commits held two merges of main and would not replay one by one. The dump itself is unchanged; two things around it are.

what moved on main what this branch does now
an expansion no longer derives parameters from a curve, so to_yaml() no longer refuses one (#638) the refusal this branch read before canonical, and _derived_parameters, are gone with it; main's test that an expansion declares exactly the file's parameters replaces the two here that tested the refusal
to_program refuses a piecewise: block left as written (#626) the sweep that loads every model twice expands the curves first and keeps the sets, so all nine sections are still compared

The earlier merge onto alpha.111 had already taken main's node printers, the explicit-any rule, the expressions rename and the move of the page to docs/reference/reading.md; that is all in the one commit now.

Testing it thoroughly, and the three bugs that found

The models in the tree carry a few hundred expression shapes between them, and all of them were already idempotent. Generated trees were not:

bug what it printed why it is wrong
a sum under a plus left whole 3 - (-(2 + d)) → 2 / + d / + 3 the layout flattens what the form kept, so the text reads back as three terms sorted differently
a product group left whole (-(5 / 5)) * ((d - 2) * c) → (((-2) + d) * c) * (-(5 / 5)) the same, one operator over
a sign at the top of a tree - -(b + d) → b + d the tree still held both signs, so the text and the tree disagreed

None changes a value — every one of them is text that reads back as a different tree, which is what makes a normal form not normal. Two earlier bugs of the same kind (a leading negative term, a dropped bracket) were found the same way.

Four properties now hold, as tests:

  • A fixed point on any tree at all — 2000 generated trees, every node kind the grammar has, comparisons and calls included: print, re-parse, normalise, compare.
  • The value is unchanged — 500 trees evaluated before and after the rewrite. Sorting, folding and splicing are rewrites, and a rewrite that changed a value would be a wrong answer rather than an untidy file.
  • Every spelling of one sum writes one text — 150 sums, every permutation of their terms with signs attached, rather than the one permutation a test author would have picked.
  • The form declares the same model — every model in the repository, loaded twice: the same names in all nine sections, the same frame, sense and domain on each.

Plus the unit cases for each rule above, the idempotence sweep over all 30 models, and two CLI tests.

Verification

pixi cannot be installed in this container (the proxy refuses pixi.sh), so the gates ran in a Python 3.12 virtualenv with the versions pixi.toml pins. On head 81993c9:

pytest tests -n auto                    1846 passed, 21 skipped
ruff check . / ruff format --check .    clean
pyrefly check (1.2.0)                   0 errors (9 suppressed)
reuse lint                              compliant
typos                                   clean (the changed files)
prettier --check (compare.md, reading.md)  clean
python -m zensical build --strict       No issues found

Not run: compile-tex, zizmor, taplo and the rest of pixi run lint's hooks.

Mutation table, taken before the first merge of main. canonical.py carries no change since but its annotations and the package rename, so each guard is the same line:

guard deleted result
a sum under a plus is not spliced caught — 2 cases of test_a_sum_flattens_into_the_terms_its_text_shows
a product on the right is not spliced caught — a-product-on-the-right-is-spliced
a group under a minus is spliced too caught — 3 tests, the fixed point among them
nothing is re-spliced after it is normalised caught — test_the_form_is_a_fixed_point_on_any_tree_at_all
a sign at the top of a tree is not folded caught — the same
_signed folds no sign caught — [pypsa_linearized_uc] and and-a-sign-written-twice-is-folded-once
a term's line is not bracketed caught — a-bracketed-group-stays-one-term
the first line is signed by hand instead of through _sum caught — three pypsa models
keyword arguments keep the file's order caught — a-calls-kwargs-are-sorted
declarations keep the file's order caught — the headline test and the sort test

The first two survived until a test was written for them: the re-splice covers the same shapes, so the suite stayed green without them. They are guards a purpose-built test now reaches, rather than guards nothing needs.

Prose measured: docs/reference/reading.md n 91, median 13, 10 over 25 (main: n 71, median 13, 8 over 25). docs/howto/compare.md n 25, median 10, 1 over 25.

Coverage moved: test_the_verbs_are_check_and_the_formats_and_nothing_else is now ..._the_two_readers_and_the_formats_... and pins {check, canonical} | FORMATS. The canonical verb's own CLI tests sit in tests/test_canonical.py, with the form rather than with the renderer.

Not done: the canonical verb takes no --expand, which main added to the typeset verbs while this branch was open.

The dump was written in session 01WZLCgQU3TMWMHQU9HkQrum; the merge onto alpha.111 in 01LCUHhoVyReQaBh2pj8CuGd; the rebase in 015h57WkBDnpxrknuJ5zZy9F; the merge onto 0.1.0, the sorting tests and the how-to page in 0159UqsefN2Xzi5XJ4ppoU4y.

🤖 Generated with Claude Code

https://claude.ai/code/session_0159UqsefN2Xzi5XJ4ppoU4y

@read-the-docs-community

read-the-docs-community Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

@FBumann
FBumann added this pull request to stack #531 September 18, 2026 08:29
@FBumann
FBumann force-pushed the claude/adoring-dijkstra-yh8ubw-canonical-dump branch from 63970db to ad07fc7 Compare September 18, 2026 12:11
Base automatically changed from claude/adoring-dijkstra-yh8ubw-node-syntax to main September 20, 2026 11:06
@FBumann FBumann added design: open In scope; the spelling is undecided meta About the project rather than the language — trackers, naming, process labels Sep 21, 2026
@FBumann
FBumann removed this pull request from stack #531 September 23, 2026 13:49
@FBumann
FBumann force-pushed the claude/adoring-dijkstra-yh8ubw-canonical-dump branch from 1345913 to a145d93 Compare September 23, 2026 13:53
The shell front keeps this branch's canonical verb and takes main's
docstring and its hoisted model for the typeset verbs. The reading page
now carries main's 22 claims and this branch's one, so the test counts
23. main removed to_program, so the canonical test reads a model's
program as spec.program.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpnFcQDuvt2bExGngb6MYk
main renamed the package from math_spec to mathspec (#702). The canonical
module, its tests and the CLI verb follow the new name. The PR adds its
line under `## Upcoming version`, which the changelog check now requires.

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

read-the-docs-community Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 mathspec | 🛠️ Build #34757737 | 📁 Comparing 81993c9 against latest (ca2e04b)

  🔍 Preview build  

4 files changed
+ howto/compare/index.html
± changelog/index.html
± reference/api/index.html
± reference/reading/index.html

The form writes the sections in the order of the fields on `Spec`, and
the keys of a declaration in one order. The tests now pin both:

- every model written with its sections, declarations and keys in
  reverse writes the same text;
- the sections follow a pinned list, and every section is sorted;
- the orders the form keeps (dims, cases regions, piecewise links, a
  where predicate) are each a difference in the text.

A how-to page shows how to diff two models once, through git's
textconv, and from Python. reading.md names the section order.

Mutations: dropping the declaration sort fails 53 tests; sorting dims
fails `a-declarations-dims`. Prose: compare.md n 25, median 10,
1 over 25; reading.md n 91, median 13, 10 over 25 (main: n 71,
median 13, 8 over 25).

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

FBumann commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Prompt: "I think we should close all superseeded ones that are from me (FBumann), and name what superseeded them "Superseeded by #xyz""

Note

The following content was generated by AI.

Superseded by #731.


Generated by Claude Code

@FBumann FBumann closed this Sep 25, 2026
FBumann added a commit that referenced this pull request Sep 28, 2026
…d `canonical --check` fails a file that is not in it (#731)

* feat(language): two files that state the same spec write one text, and canonical --check fails a file that is not in it

Spec.to_yaml(canonical=True) writes the normal form: sections in one
order, declarations sorted by name, every expression printed from its
parsed tree with the terms of a sum and the factors of a product sorted,
one term per line. python -m mathspec canonical writes it; --check exits
1 for a file not in the form and --write rewrites it.

Squashes #530 and #718 onto main after #721, in its words: a file states
a spec. Also normalises a named expression written on one line, which the
form passed through as written.

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

* Add the changelog link for #731

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Sep 28, 2026
…ession with `empty: true` (#742)

* feat(language): two files that state the same spec write one text, and canonical --check fails a file that is not in it

Spec.to_yaml(canonical=True) writes the normal form: sections in one
order, declarations sorted by name, every expression printed from its
parsed tree with the terms of a sum and the factors of a product sorted,
one term per line. python -m mathspec canonical writes it; --check exits
1 for a file not in the form and --write rewrites it.

Squashes #530 and #718 onto main after #721, in its words: a file states
a spec. Also normalises a named expression written on one line, which the
form passed through as written.

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

* Add the changelog link for #731

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

* feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it

merge composes fragments as peers: each loads on its own, owns what it
declares, and reads what a sibling declares under given:, which now takes
parameters, variables, named expressions and row families. A given
expression's dims bound what it reads. A given: expressions: entry marked
additive: true says the name is a sum other files add terms to; each
contributor declares an ordinary named expression, merge sums them and
keeps the marked entry. override lays patches over a base, field by
field, with null as the removal. Both return a loaded Spec.

Squashes #571, #690, #728 and #729 onto #731, in #721's words. Adds the
tutorial 'A spec in several files', with a test that runs every step, and
sorts the names under given: in the canonical form.

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

* Add the changelog link for #732

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

* refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum

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

* Add the changelog link for #734

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

* The reader's description wins, and the term checks stay at load

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

* feat(language): a file adds a term to an expression another file defines, under `given:`

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

* Walk the loaded fragments by value

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

* A term names an expression of its file, and merge adds it by name

`term:` on a `given: expressions:` entry now names a named expression the
same file declares, and no longer takes an expression written inline. The
term is then free: it takes `cases:`, a description and every rule of a
named expression, and it counts as read by the math.

`merge` adds the terms by name, without brackets around a name, and keeps
each term as a named expression of the composed spec. A refusal for terms
that land on no name names only a near miss.

The typeset term line is `name = ⋯ + term`. The tutorial, the reference,
the how-to, the golden model and the notation page follow. The #734 line
leaves the changelog; #732 carries the design.

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

* Name the term in a term collision, and suggest only a name a term can land on

Two fragments that declare one term name now hear that terms share one
namespace, not the dimension-rows advice. Terms that land on no name are
refused with a near miss among the definitions and readings only, since a
term name is no place for a term to land; `did_you_mean` takes
`listing=False` for the case where only a near miss helps. The how-to says
to name each term after its component.

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

* fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out (#739)

* Choose a merged description by fragment name, and let a reader's fill a gap

A shared dimension or relation, a reading several fragments share, and a
sum built from terms took the description of whichever fragment was passed
first. Each now takes the first description in the fragments' name order, as
the objective already did, so the order of the arguments reaches no field.
The order of the declarations still follows the order passed in, which is
presentation.

A folded given declaration dropped its reader's description even where the
declaration it folds into had none; it now fills that gap, and yields to the
owner's own.

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

* Add the changelog line for #739

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

---------

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

* refactor(language): a term prints as the definition it is, and one rule folds every reading (#738)

* refactor(language): a term prints as the definition it is, and merge takes the fragments in name order once

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

* Add the changelog link for #738

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

* Retitle the changelog line: the name-order rule is #739's

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

---------

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

* feat(language): a named expression may declare the frame it is read over

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

* Add the changelog link for #741

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

* feat(language): a sum other files add terms to is declared as an expression with a frame and no body

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

* Add the changelog link for #742

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

* docs: a file restates a shared dimension as its dtype alone, and merge carries the one description written for it (#743)

* An empty sum prints as a definition with an ellipsis for its body

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

* Attribute comments say what a name holds, and internal names carry none

* An empty sum is written `empty: true`, so a frame with no body is refused

A `dims:` with no body loaded as an empty sum, so a forgotten body went
unnoticed. The flag is explicit: `empty: true` over a `dims:`, refused beside
`expression:`, `cases:` or `otherwise:`. `empty` defaults to false and is
written only where true. The program flag `owned` is renamed `empty`, to
match the file.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Fabian <fab.hof@gmx.de>
FBumann added a commit that referenced this pull request Sep 28, 2026
… it, each component adding its share of a sum by name (#736)

* docs(pypsa): a single file covers the standard, stochastic and multi-period classes

Fold pypsa_stochastic and pypsa_multi_period into examples/pypsa.yaml; retire
the sibling pages, symbols and example files. Add the rung 14 and rung 15
sections to the gallery page, and guard the plain-run collapse to the standard
model with a frozen shape fixture.

* docs(pypsa): the spec covers the process and transformer components

Add the Process component (a generalized multi-port converter, superset of
Link) and the full-depth Transformer (a passive branch like Line, with tap
ratio and a fixed phase shift in the KVL cycle) to examples/pypsa.yaml, with
symbols, the regenerated gallery, rung 17 and rung 18 reference networks and
oracles, and the collapse guard extended to the new standard names.

* docs(pypsa): the spec covers quadratic marginal cost

* docs(pypsa): the spec covers transmission losses

* docs(pypsa): the spec covers the secant loss mode

* docs(pypsa): the secant loss mode carries a solved reference

rung_19 records the secant-mode triangle solved through the pinned pypsa,
objective 10840.93, the same 150 rows as the tangent rung. The two loss-cut
blocks now stand for both their tangent and secant PyPSA names, matched by a
gallery helper that reads every backticked name before the dash.

* docs(pypsa): the spec covers the optimised transformer phase shift

A phase-shifting transformer's angle becomes a per-snapshot decision where
phase_shift_min < phase_shift_max, bounded by them and entering the KVL cycle
sum in place of the fixed constant; a plain run keeps the shift fixed and
collapses byte-for-byte. rung_20 records the phase-shifter triangle solved
through the pinned pypsa, objective 16455.0.

* docs(pypsa): the carrier growth limit binds every extendable component

* docs(pypsa): the spec covers transformer transmission losses

A transformer carries its own loss under transmission_losses, as a line does:
the loss counted against its rating, its cap, its tangent or secant cuts and half
of it at either bus. A plain run collapses byte-for-byte. rung_22 and rung_23
record the transformer triangle in both modes, objectives 10643.48 and 10822.00.

* docs(pypsa): a committable unit serving its brought-in down time stays off

Generator_com_status_must_stay_down mirrors the must-stay-up block over a
data-prep mask, status fixed to zero; PyPSA names the row
Generator-com-status-min_down_time_must_stay_up. rung_24 records a cheap unit
held off for two snapshots, objective 9007.5.

* docs(pypsa): a committable link carries the generator's unit commitment

Link gains the status, start-up and shut-down variables, the committable,
big-M and modular p bounds, transitions, up and down times, both must-stay
rules, the committed ramp rows, n_mod and its costs, mirroring Generator.
rung_25 records five committable links, objective 14013.0.

* docs(pypsa): a committable process carries the generator's unit commitment

Process gains the same unit commitment blocks as Link, over its internal
power. rung_26 restates rung 25's links as processes drawing a quarter more
than they deliver, objective 15956.125.

* docs(pypsa): a ramp row follows pypsa for a modular committed build and a start-up ramp alone

A committable extendable modular unit takes the ordinary ramp rows against
one module, not the big-M rows. A start-up or shut-down ramp alone builds
the row, and a missing limit reads as the full build. rung_27 (45469.5) and
rung_28 (83283.0) record both for Generator, Link and Process.

* docs(pypsa): storage cycles or reopens per investment period, and a ramp restarts at a period start

* docs(pypsa): a security-constrained run limits every branch flow after any one listed outage

* docs(pypsa): an mga flag caps the system cost at a budget and minimises weighted builds

* docs(pypsa): mga leaves the standard spec until it can land as a patch file

Reverts 5109686. The commit is kept on docs/pypsa-mga and waits for #571.

* docs(pypsa): a storage built in a later period opens at its first active snapshot

* docs(pypsa): a unit may be scheduled off for maintenance

* docs(pypsa): the spec states what maintenance and late-opening storage assume of the data

* docs(pypsa): a growth limit counts no transformer, as pypsa does

* docs(pypsa): a global constraint may count one investment period, weighted by its years

* docs(pypsa): a process, a storage unit and a store may carry a quadratic marginal cost

* docs(pypsa): a store's power and a storage unit's dispatch and charging may each be pinned to a schedule

* docs(pypsa): a link's and a process's delay applies within each investment period

* docs(pypsa): a negative relative growth adds nothing to a carrier's growth limit, as pypsa clips it at zero

* docs(pypsa): a tech capacity expansion limit on a network with several scenarios is refused, as pypsa does

* docs(pypsa): the carrier growth rung says a transformer counts in no carrier

* docs(pypsa): a global constraint takes its own constant and sense in each scenario

* docs(pypsa): the stochastic rung says which component data spans a scenario

* docs(pypsa): component data spans a scenario wherever pypsa reads it per scenario (#688)

* docs(pypsa): component data spans a scenario wherever pypsa reads it per scenario

* docs(pypsa): two rungs show operating and first-stage data that differ by scenario

* docs(pypsa): a component's sign turns its term in the bus balance around

* docs(pypsa): the linearized commitment file keeps a unit down, ramps at the full build where a limit is missing, and schedules maintenance

* docs(pypsa): a ramp limit may change over time and lift at a snapshot

* docs(pypsa): a unit that came in running ramps from its p_init into the first snapshot

* docs(pypsa): the relaxed commitment file ramps every generator per snapshot and from its p_init, tightens at the full build and signs its balance

* docs(pypsa): a start and a stop cost what they cost, with no snapshot or period weight

* docs(pypsa): a growth limit binds only under multi_investment_periods

* docs(pypsa): a load that is not active draws nothing from its bus

* docs(pypsa): a security-constrained run dissipates no transmission loss

* docs: the changelog lists the pypsa spec that covers every model class and component

* docs(pypsa): an efficiency, a rate or a phase shift may change from snapshot to snapshot

* docs: the changelog lists efficiencies per snapshot

* docs(pypsa): a growth limit counts an asset only in the first period it stands in

A rung may now record a PyPSA bug: its intended objective from an oracle,
and what PyPSA 1.3.0 gives instead. Rung 51 records PyPSA/PyPSA#1938.

* docs(pypsa): a transmission cost or volume limit holds in every scenario

Rungs 52 and 53 record PyPSA/PyPSA#1939, where PyPSA 1.3.0 drops both rows.

* docs(pypsa): each scenario delays a link's and a process's flow by its own delay

Rung 54 records PyPSA/PyPSA#1941, where PyPSA 1.3.0 delivers the flow twice.

* docs(pypsa): transformer cycles, security-constrained runs, fixed builds and committable units hold in every scenario

Rungs 55 to 58 record PyPSA/PyPSA#1942 and PyPSA/PyPSA#1913, where PyPSA 1.3.0 raises.

* docs(pypsa): the first-snapshot ramp refusals link the open PyPSA question

* feat(language): two files that state the same spec write one text, and canonical --check fails a file that is not in it

Spec.to_yaml(canonical=True) writes the normal form: sections in one
order, declarations sorted by name, every expression printed from its
parsed tree with the terms of a sum and the factors of a product sorted,
one term per line. python -m mathspec canonical writes it; --check exits
1 for a file not in the form and --write rewrites it.

Squashes #530 and #718 onto main after #721, in its words: a file states
a spec. Also normalises a named expression written on one line, which the
form passed through as written.

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

* Add the changelog link for #731

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

* feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it

merge composes fragments as peers: each loads on its own, owns what it
declares, and reads what a sibling declares under given:, which now takes
parameters, variables, named expressions and row families. A given
expression's dims bound what it reads. A given: expressions: entry marked
additive: true says the name is a sum other files add terms to; each
contributor declares an ordinary named expression, merge sums them and
keeps the marked entry. override lays patches over a base, field by
field, with null as the removal. Both return a loaded Spec.

Squashes #571, #690, #728 and #729 onto #731, in #721's words. Adds the
tutorial 'A spec in several files', with a test that runs every step, and
sorts the names under given: in the canonical form.

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

* Add the changelog link for #732

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

* refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum

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

* Add the changelog link for #734

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

* The reader's description wins, and the term checks stay at load

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

* feat(language): a file adds a term to an expression another file defines, under `given:`

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

* Walk the loaded fragments by value

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

* A term names an expression of its file, and merge adds it by name

`term:` on a `given: expressions:` entry now names a named expression the
same file declares, and no longer takes an expression written inline. The
term is then free: it takes `cases:`, a description and every rule of a
named expression, and it counts as read by the math.

`merge` adds the terms by name, without brackets around a name, and keeps
each term as a named expression of the composed spec. A refusal for terms
that land on no name names only a near miss.

The typeset term line is `name = ⋯ + term`. The tutorial, the reference,
the how-to, the golden model and the notation page follow. The #734 line
leaves the changelog; #732 carries the design.

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

* Name the term in a term collision, and suggest only a name a term can land on

Two fragments that declare one term name now hear that terms share one
namespace, not the dimension-rows advice. Terms that land on no name are
refused with a near miss among the definitions and readings only, since a
term name is no place for a term to land; `did_you_mean` takes
`listing=False` for the case where only a near miss helps. The how-to says
to name each term after its component.

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

* Cut pypsa.yaml into topic fragments whose components add named terms

`examples/pypsa/` holds the 24 fragments `tools/pypsa_split.py` writes from
#620's `examples/pypsa.yaml`. Each component's share of a sum (the bus
balance, the cycle sum, the operating cost, the carrier additions and the
five global-constraint totals) is a named expression of its own, such as
`Generator_injection`, and the `term:` of its `given:` entry names it. The
reader of each sum describes it: `network` and `power_flow` for the two
rows, `core` for the rest.

`check` merges the fragments to the canonical form of the one file with its
sums written as named terms, and compares that file with `pypsa.yaml` row by
row, every term substituted back.

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

* Add the changelog line for #736

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

* fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out (#739)

* Choose a merged description by fragment name, and let a reader's fill a gap

A shared dimension or relation, a reading several fragments share, and a
sum built from terms took the description of whichever fragment was passed
first. Each now takes the first description in the fragments' name order, as
the objective already did, so the order of the arguments reaches no field.
The order of the declarations still follows the order passed in, which is
presentation.

A folded given declaration dropped its reader's description even where the
declaration it folds into had none; it now fills that gap, and yields to the
owner's own.

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

* Add the changelog line for #739

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

---------

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

* refactor(language): a term prints as the definition it is, and one rule folds every reading (#738)

* refactor(language): a term prints as the definition it is, and merge takes the fragments in name order once

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

* Add the changelog link for #738

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

* Retitle the changelog line: the name-order rule is #739's

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

---------

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

* fix(typeset): a substituted term prints its leading minus as a subtraction, and the PyPSA split has a page per fragment

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

* Expect the substituted sum over both dimensions, and link the changelog line to #740

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

* feat(language): a named expression may declare the frame it is read over

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

* Add the changelog link for #741

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

* feat(language): a sum other files add terms to is declared as an expression with a frame and no body

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

* Add the changelog link for #742

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

* The owner of each PyPSA sum declares it with a frame and no body

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

* The golden model substitutes a signed sum into a plus

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

* docs: a file restates a shared dimension as its dtype alone, and merge carries the one description written for it (#743)

* An empty sum prints as a definition with an ellipsis for its body

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

* The owner pages of the PyPSA split print their sums with an ellipsis

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

* Keep main's patch case in test_composition, which #740 carried from an earlier #742

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

---------

Co-authored-by: Fabian <fab.hof@gmx.de>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

design: open In scope; the spelling is undecided meta About the project rather than the language — trackers, naming, process

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants