Skip to content

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

Merged
FBumann merged 13 commits into
feat/canonical-formfrom
feat/composition
Sep 28, 2026
Merged

FBumann merged 13 commits into
feat/canonical-formfrom
feat/composition

Conversation

@FBumann

@FBumann FBumann commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "I think we should do 2 PRs. One doing the cononical stuff. And another doing all the composition stuff as one, coherent design. And the PR description should explain what it adds, changes, and how the composition works. Also with a tutorial"

Then: "You build it / Fold it into 732 / Close 734", and "Adjust the PR description of 732 after 738 merges."

Note

The following content was generated by AI.

A spec can now be written as several files. merge composes files that each state part of a spec. override lays patches over a spec. given: says what a file reads from another file, and term: says what it adds to a name another file has.

Stacked on #731 (the canonical form). This squashes #571, #690, #728, #729, #734, #738 and #739 into one design onto main after #721, in #721's words: a file states a spec, and a model is a spec with data.

How composition works

A fragment is a whole spec. Each file loads, prints and gets advice on its own. What it reads and does not declare, it states under given:, with the frame:

# emissions.yaml reads the fleet's dispatch, which generators.yaml declares
given:
  variables:
    dispatch: { dims: [snapshot, generator] }
constraints:
  emission_limit: { dims: [], expression: sum(dispatch * emission_rate) <= emission_cap }

merge composes fragments as peers, and returns one loaded Spec:

spec = ms.merge({'network': 'network.yaml', 'generators': 'generators.yaml', 'emissions': 'emissions.yaml'})
  • A dimension or relation may appear in several fragments, and they must say the same thing.
  • Every other declaration has one owner; a second is refused, both fragments named.
  • A given: entry is checked against the fragment that declares the name, then folded into it. A name nothing declares stays under given: for a host model to provide.
  • The objectives are summed.
  • Prose is not a claim. Where fragments word one thing differently, the first wording in fragment-name order is carried, and a reader's description fills a declaration its owner left undescribed. The order the fragments are passed in reaches no field.
  • The result goes through to_spec, so the composed spec is held to every rule again.

A term a file adds. A balance should not list every component. A component says what it puts in as an ordinary named expression, and names it as the term: of the entry it adds to. The file that reads the sum states only its frame:

# network.yaml reads the sum
given:
  expressions:
    injection: { dims: [snapshot, bus], description: what the components put into a bus }
constraints:
  balance: { dims: [snapshot, bus], expression: injection == 0 }

# generators.yaml adds a term
expressions:
  generation: { expression: sum(dispatch, by=gen_bus, over=generator, into=bus), description: what the fleet puts in }
given:
  expressions:
    injection: { dims: [snapshot, bus], term: generation }

merge defines injection as the definition one fragment writes, if any, plus every term by name, in fragment-name order: generation + consumption. Each term stays a named expression of the composed spec, with its description, so a host can read it and a patch can change it by name. A later merge adds to the composed definition the same way. A new component is a new file, and the network does not change.

The contributor decides alone. A file that defines the name does not opt in: it reads its own definition alone, and the definition plus every term once composed. Whoever composes the files answers for that sum. An opt-in or opt-out flag on the definition can be added later without breaking a file.

override lays patches over a base, the spec a framework ships, and returns the patched Spec:

spec = ms.override('base.yaml', {'carbon': 'carbon.yaml'})

A patch names only what it changes, field by field. A whole new entry is added. null removes a declaration or drops a field to its default. Sibling patches must write disjoint fields, so their order never matters. Layering is written as nesting, and the two verbs compose as override(merge({...}), {...}): build the spec, then configure the run.

What this adds

  • given:, a new top-level section with four kinds:

    Kind States The file reads it as
    parameters dims, dtype a parameter: in expressions, where masks and bounds
    variables dims, domain a column
    expressions dims, term a quantity over at most that frame, of degree one; the definer owns the body
    constraints dims a row family, named only in dual()
  • mathspec.merge and mathspec.override, in mathspec.composition. Both take paths, YAML text, mappings or loaded specs, and return a loaded Spec.

  • term: on a given: expressions: entry: the name of an expression the same file declares. At load, the term must exist, carry no dimension the entry does not state, not read the name it adds to (directly or through another name), and stay within degree two. It counts as read by the math.

  • Program.given (GivenTargets): what a program reads and does not build, by kind. GivenDeclaration.term is the Named node of the term. ExpressionDeclaration is unchanged, and program.given still means what a host must provide.

  • Advice of kind given: one note per name a file reads and does not build. A term gets its own note, saying merge sums it.

  • Typesetting: the legend gains Given. It says "data another file declares", "an expression another file defines", or "an expression this file adds generation to". A term prints under Definitions as the definition it is. A given name prints no line of its own, so typeset_declaration refuses it.

  • Docs:

    • the tutorial A spec in several files (docs/several-files.md), with tests/test_several_files_page.py, which runs every step and compares what it prints with the page;
    • the how-to Compose a spec from several files (docs/howto/compose.md);
    • the given section and "A term a file adds" in the language reference;
    • "What a program does not build" in reading.md;
    • a glossary entry for provide;
    • the component library example (examples/library/, five generated pages).

What this changes

Where each part came from, the rules in full, guards, gates, what was not done

Origin. #571 (claude/loving-thompson-vurdqx): given: for variables and constraints, merge, override, the library example. #690 (feat/compose-through-to-spec): both verbs load through to_spec and return a Spec, null makes what it names absent, and assumptions are kept. #728: given parameters and expressions. #729: a sum the reader marks additive, which #734 replaced. #734: the contributor's term:, which drops additive, the kept entry, defined_sums and the namespace exemption; then 1975438 makes the term a name and adds it by name. #739: descriptions follow fragment-name order, and a reader's fills an undescribed owner. #738: the ⋯ + term line and the ellipsis operator go, one agreement rule (_agreed) and one fold rule (_fits) serve every given kind, and the balance fixture is shared.

Why a name, not an inline expression. A named term takes cases:, a description and every rule of a named expression. It stays in the composed spec, so a host reads it and a patch changes it. The composed sum prints generation + consumption, each term defined once with its description. An inline term would join the sum as anonymous text. A fold: flag, which would substitute a term into the sum, was left out until a caller needs it.

The rules of merge in full.

The entry What merge does
a fragment that does not load alone refused under its own name, with to_spec's refusal
two fragments on different language versions refused
a dimension or a relation shared; the declarations agree; the first description in fragment-name order is carried
any other declaration owned by one fragment; a second is refused, both named
a reading two fragments share, of any kind agree on the frame, as a set, and on every other field; the first description in fragment-name order is carried
a given: parameter, variable or constraint states the introducer's frame and says the same as it or less, then folds into it
a given expression the composed body carries no dimension the reader's dims do not name; a narrower body is folded, and the composed load refuses a row it would repeat
a reading of a name its owner left undescribed the reader's description fills it; the owner's own wins where it exists
a name read as one kind and declared as another refused, both named
a given expression with a term defined as the definition, if one fragment writes it, plus every term by name in fragment-name order; the terms stay named expressions
terms on a name no fragment defines, reads or uses refused, with a near miss named if there is one
a definition written as cases: that a term adds to refused: name the cased body, and define the name as that name
two definitions of one name a collision; the message names term:, and names the term where one of them is one
the objectives summed in fragment-name order; the senses agree; the first description in fragment-name order is carried
a given entry nothing declares stays under given: for a host model

The rules of override. A partial entry edits a declaration the base has; a miss is refused with the near miss named. A whole entry creates one. null removes a declaration, and refuses a removal of what the base lacks. null on a field drops it to its default, so term: null makes an entry a plain reading. A dimension or relation may be added or restated word for word, never changed or removed. A section set to null is refused. Two patches that write one field are refused. given: is laid over one kind at a time.

Tests. test_given.py, test_composition.py, test_terms.py, test_library_example.py and test_several_files_page.py. test_terms.py replaces test_additive.py: the refusals of a marked hub and of a contributor reading its own term have no counterpart, since neither case exists now. test_the_file_that_defines_the_name_reads_the_extended_sum_once_composed pins the one thing the contributor-only design accepts. #739's tests were written first and failed on the head before it; #738 moved one guard, test_a_given_expression_prints_no_line_of_its_own, which covers a reader and a contributor.

Guards of the term, each deleted in turn:

Guard Fails without it
a term names an expression of its file a-term-written-inline, a-mistyped-term
a term counts as read by the math test_a_term_is_read_by_the_math

A term that reads the sum through its name is refused by the existing walk, which steps into a Named body; a-term-reading-the-sum-through-its-name holds it.

Gates. Pixi cannot be installed in the containers this was built in. In a Python 3.12 virtualenv with the pins of pixi.toml, on the tree of 1339df3 (#738 merged):

pytest tests -n 8                         2071 passed, 27 skipped (the skips are the docs-feature tests, absent in that environment)
ruff check . / ruff format --check .      clean
pyrefly check                             0 errors (9 suppressed)
prettier --check docs CHANGELOG.md        clean
python -m tools.schema / tools.notation / tests.typesetting.golden   regenerated, committed
python -m tools.gallery --check           13 page(s) current

Earlier heads also ran typos, reuse lint and python -m zensical build --strict clean, the last apart from the docs.python.org inventory the proxy refuses. Not run: compile-tex (the tectonic bundle download is blocked), zizmor, taplo. CI runs all of them.

Not done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

… 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
@FBumann
FBumann added this pull request to stack #733 September 25, 2026 16:34
@FBumann
FBumann marked this pull request as ready for review September 25, 2026 16:34
@FBumann FBumann added the area: composition Assembling a model from files, and fixing a decision label Sep 25, 2026 — with Claude
@FBumann
FBumann marked this pull request as draft September 25, 2026 16:34
…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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
…nes, under `given:`

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
`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
… 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
FBumann and others added 2 commits September 25, 2026 22:26
…r 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>
…le 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>
@FBumann
FBumann removed this pull request from stack #737 September 25, 2026 21:31
…tack

# Conflicts:
#	tests/test_dimensions.py

@FabianHofmann FabianHofmann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trimmed the inline comments a bit which lead me to raise an issue on this. currenly we are risking to bloat to code with explainatory comments which neither help LLMs nor humans in my opinion but rather making thing clunky.

@FBumann
FBumann added this pull request to stack #755 September 28, 2026 10:56
@FBumann
FBumann merged commit 74c36d6 into main Sep 28, 2026
7 checks passed
FBumann pushed a commit that referenced this pull request Sep 28, 2026
The one commit of #732 this branch lacked. `Namespace.variables` and
`Namespace.bodies` carry no `#:` comment, as fc464a7 asks of internal names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1VBZvT7UNJJexTrMX7mRi
FBumann pushed a commit that referenced this pull request Sep 28, 2026
#732 landed on main as the squash 74c36d6, whose tree is feat/composition
at fc464a7, merged in the commit before. Every conflict of the squash is
this branch's side, so the merge keeps this branch's tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1VBZvT7UNJJexTrMX7mRi
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

area: composition Assembling a model from files, and fixing a decision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants