Skip to content

feat(language): several files compose into one model, so a component library is templates rather than generated YAML - #244

Closed
FBumann wants to merge 14 commits into
claude/parameter-coveragefrom
claude/spec-merge
Closed

FBumann wants to merge 14 commits into
claude/parameter-coveragefrom
claude/spec-merge

Conversation

@FBumann

@FBumann FBumann commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Please do a few stacked PRs implementing this / All stacked onto the cases pr / And to the description, add what lpspec removes or changes" — and later, "fold it into examples/ as a gallery page"

Note

The following content was generated by AI.

What this changes

ms.merge({...}) takes fragments and hands back one mapping to validate, resolve and lower exactly once.

model = ms.merge({'generator': 'generator.yaml', 'demand': 'demand.yaml'}, description='a fleet')
spec = ms.to_spec(model)  # one flat namespace, checked here and nowhere else

One decision makes it cheap: a fragment is not a Spec. It is merged before it is validated, so a template may name what a sibling declares — a shared bus, the flow every component writes into — without being loadable alone. No free-name concept, no import list, no new resolution rule: validation still runs once, over one flat namespace, exactly as for a file somebody typed.

The split that gives merging its meaning:

  • dimensions and lookups are the coordinate space, shared on purpose. Declared twice and agreeing, one declaration; disagreeing, the disagreement is the error.
  • Everything else is the math, which a template owns, so a name two fragments both declare is a collision naming both.
  • Objectives are summed, each term parenthesised. A fragment running the other way is refused rather than negated: a composed model has one sense and nothing in the files says which.

⚠ This takes a side in an open design question

Read this before the rest. The third bullet above is not settled, and an earlier version of this description presented it as though it were.

#12 is open and contested, and #30 is named in it. @brynpickering there:

you can't delete YAML entries when you have multiple files … as I mention in #30, I want the YAMLs to hold the information about how to handle possible clashes when combined with other YAMLs in the same project

That is a different composition model from this one, and the two are not compatible:

this PR the model #12/#30 describes
Fragments are disjoint peers a base, plus overrides
A name in two fragments is an error is the mechanism
Order irrelevant — merging is associative significant, later wins
A framework's base math, extended by a project cannot be expressed is the motivating case

Associativity is a property of the model chosen here, not a neutral win. A layered-override merge needs order to matter, so the test pinning associativity would be wrong under that design rather than merely unnecessary.

So: this is a complete implementation of disjoint composition, which is what ceiling.md's component-library section describes (topology is data, templates bounded by type). #250 later builds the other half as a second verb, override, on the argument that the two obey opposite laws and neither is a mode of the other.

The collision error names the rewrite it usually is

A collision refuses, and the question is what the author should do about it. Two of the same kind of thing — a battery and a pumped hydro — are the same math with different numbers, so they are two rows of a dimension, not two fragments, and the message says that first:

fragments 'battery' and 'hydro' both declare the variable 'soc'. Two of the same kind of
thing are two rows of a dimension rather than two fragments: merge the template once, and
let the data carry both. Different math under one spelling is a rename — call one of them
something else.

The earlier message only offered the rename, which is the right advice for genuinely different math and the wrong advice for the case a component library hits most. The #29 promise moved out of the message and into the module docstring, where a maintainer reads it and a file's author does not need it.

The same pass fixed a message defect in both merge and override: the singular noun was section[:-1], so a sos collision was reported as a so and a piecewise one as a piecewis. There is a parametrized case asserting the English now.

The worked example

examples/composed/ is four files and a gallery page. base.yaml declares the coupling surface — one flow per port, one balance per bus — and the other three name flow without declaring it, so each is a load error alone.

The balance does not grow when a component type is added. Adding storage.yaml as a fourth fragment:

three components -> balance: sum(flow, by=port_bus) == 0
four  components -> balance: sum(flow, by=port_bus) == 0
balance identical            : True
objective, four  : (sum(gen_p * gen_cost)) + (sum(st_soc) * st_holding)

That is what the port convention buys, and it needs no language this repository does not already have — each component pins its own port's flow with at(flow, by=…) rather than owning a flow variable the composer must then sum by hand.

Why

#30, under the reading above. ceiling.md already argues the shape — topology is data, compose-then-build producing one Spec before a single lower pass, structure bounded by component types while cardinality lives in data. What was missing was the function.

Names are not rewritten, the deliberate boundary. Qualified names are #29 and need an unparser this repo does not have. Until they land, a library keeps templates apart by naming them apart — which is what examples/pypsa.yaml already does with Generator_p_nom and Link_ramp_limit_up.

What lpspec removes or changes

Nothing. Not one line. Every verb already takes str | Path | dict | Spec and merge returns a mapping, so this works today against an unchanged engine:

lps.solve(ms.merge({'generator': gen, 'demand': demand}), sources)

Because names are not rewritten the composed declarations keep the fragments' spellings, so the sources mapping is just the union and no manifest, binder or diagnostic moves. That is the layering's strongest evidence: the piece enabling component libraries costs the engine nothing, being a Spec → Spec function on the far side of the waist.

Verified

Toolchain reconstructed at the versions pixi.toml pins: ruff format --check / ruff check (0.16.1, pinned) clean; pyrefly (1.2.0, pinned) 0 errors; pytest -q 854 passed, 0 skipped. reuse, typos, prettier --check over **/*.{md,yml,yaml}, render_tex (27 models) and mkdocs build --strict were run on the top of the stack (#250), which contains these commits.

Not run: zizmor, taplo (neither's files changed), and the tectonic half of compile-tex.

Two CI failures this PR earned, and what they say

Both were in gates I had claimed I could not run. Recording them because the claim was the problem, not the code.

Prettier reformatted the new YAML. {dtype: int} → { dtype: int }. My local check globbed **/*.md; .lefthook.yaml globs *.{md,yml,yaml}, and these were the first YAML files the stack added.

render_tex typesets every file under examples/ — and a composition fragment is a load error alone, which is the entire point of the directory. tools/render_tex.py already had NOT_MODELS for exactly this category (the symbol tables), so the fragments join it. It still renders 27 models, and the composition itself is covered twice over: the gallery page renders it, and test_a_composed_model_prints_as_math typesets it in all three formats.

That second one is the useful lesson. compile-tex was on my "not run" list, and it caught a real defect the other seven gates could not.

Mutation table

Every guard deleted in turn on a clean tree, restored with git checkout --:

Mutation Result
an owned name claimed twice not refused (last wins) 1 failed
a shared declaration's disagreement not compared 1 failed
objective senses not compared 1 failed
language versions not compared 1 failed
objective terms joined unparenthesised 1 failed
restored all green

The last is the quiet one: a + b where b is sum(x) * k reassociates, so an unparenthesised join composes a different objective and nothing else in the suite would notice.

A composed model is still one a reviewer can open

Hard rule 5 wants the model to be the file you review and diff, and "a construct that cannot be printed is not in the language." Both are asserted: to_yaml on a merged model writes a file that loads back to the same Spec, and all three formats render it.

Writing that round-trip test found the bug in the third commit: merge read a str fragment as YAML text where to_spec reads one as a path. merge now takes exactly what every other verb takes.

Deliberately not done — and one correction

No qualified names (#29); needs an unparser, and merging is useful without it.

No provenance returned. With no renaming every declaration keeps its fragment's name, so the origin is recoverable by looking, and collisions name both fragments when refused. It becomes essential the day #29 renames things.

No active:. #12 is open; nothing here implements or precludes it, though the collision rule above is the part that would have to give — and #250 is the verb that gives it, without touching this one.

Correction. An earlier version of this description claimed exclusivity.overlapping() from #168, pointed at where masks on a shared variable, would catch "two components claiming the same port". Wrong, and I withdraw it: two constraints bearing on one variable is ordinary linear programming, and because each declaration is owned by exactly one fragment a cross-fragment mask overlap is not expressible.

The residual gap is real, and building the example above gave it a name: port_bus is a lookup, a partial lookup is legal, and sum(by=) places an unmapped port's terms nowhere — so a mis-wired system solves and is quietly wrong. That is #245, stacked on this.

Stack: third of nine, on #243 → #242 → #168.

FBumann and others added 3 commits August 28, 2026 14:53
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…no two regions claiming one coordinate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`examples/commitment.yaml` is the formulation #2 factors. The state a
unit carries into a snapshot has three regimes — a unit that is never
off, the first snapshot, and every later one — and writing them at the
constraint would fork `ramp_up` three ways. With the regimes named once,
the inequality is written once.

`tools/render_tex.py` picks it up like every other model, so the LaTeX
gate compiles it, and it gets a page in the example gallery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann requested a review from brynpickering as a code owner August 29, 2026 13:52
FabianHofmann and others added 10 commits August 30, 2026 12:50
Resolved import-block conflicts in lowering, program and test_lowering.
boundedness now walks program nodes, so the Cases arm is ported from
CasesNode to program.Cases. `_lower_where` is `resolution.where_of` on
main, which folds an always-true mask to None, so the exclusivity tests
build their masks the way validation does, unfolded.
…outside the cases (#251)

The value wherever no `when` holds moves out of `cases:` and becomes the
block's own `otherwise:`, so no name inside `cases:` is reserved and the
cases carry no order. Five hand-written errors policing `default` are
replaced by the closed schema's own: a required `otherwise:`, a required
`when:` on every case, and `min_length=1` on `cases:`.

The fallback's dims are checked where the cases' are, which the loop over
`cases:` used to give it for free.
… is set as one the solver decides (#252)

* fix(typesetting): a cased expression whose otherwise holds a variable is set as one the solver decides

Reading only `cases:` for what a block is left the fallback out, so a
quantity whose only variable sits there printed upright — the notation
for something the model is handed rather than something it solves for.
`previous_status` in the commitment example is exactly that shape, and
its generated page disagreed with the hand-written one in the language
reference.

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

* fix(language): an error inside a case names the expression that declares it, and is printed once

A cased expression is expanded where its name stood, so a fault in one
of its arms was reported against the constraint that pulled it in — as
`Constraint 'ramp_up', case 'boundary'`, a case on a constraint that has
none, once per constraint naming the expression. The arm now names its
declaration, and the arm with no `when` is named `otherwise` rather than
a case, which is what every other check already calls it. Two errors
that differ at all carry different contexts, so an exact repeat is one
fault seen twice and is printed once.

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

* fix(language): two integer bands with no integer between them are proved apart

The cells for an ordered subject put a representative in every gap
between the literals a pair of masks names, and the midpoint it chose
was one no integer can be. So `n < 1` against `n > 0` on an `int` was
refused with a witness of `n is 0.5` — a coordinate the data cannot
produce, naming a rewrite the file had already made. A date was already
handled this way; an integer is discrete for the same reason and now
says so.

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

* test(exclusivity): the soundness fuzz draws two masks apart rather than one and its negation

Every pair it generated was a complement by construction, so the grid
assertion was `X and not X` — false at every point, under every
implementation, for every set of cells. Collapsing each subject to a
single cell, which is the most a cell-coverage bug could ever be, left
both seeds green while nine other tests in the file went red. Drawn
independently and filtered to the pairs the check proves apart, the same
mutation now fails it 1295 times over.

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

* chore: drop an unreachable lookup guard and the default spelling the otherwise rename left behind

`_lower_expression` looked its name up in the very mapping its only
caller iterates, so the `KeyError` and its `did_you_mean` were a branch
nothing reaches — and the one place in the file raising something other
than `LanguageError`. Deleting it leaves the suite green, which is what
the guard was worth.

Beside it, five docstrings and a test name still called the fallback
`default`, which the language no longer has.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…o whichever engine reads the data

Three facts decide which model a file and a table make together — where a
dimension's members come from, the order they stand in, and that a coordinate
appears at most once. Each was real and each was written down in a consumer, so
a second consumer would have inherited none of them.

Co-Authored-By: Claude <noreply@anthropic.com>
…oordinate, so a row lost in preparation is not read as a mask

A table short of a coordinate and a table that never had one are identical in
the data and mean opposite things. `coverage:` says which was meant, and a
parameter declaring itself a mask is refused as a bound or a divisor — the two
positions where absence has no reading — before any data arrives.

Co-Authored-By: Claude <noreply@anthropic.com>
…library is templates rather than generated YAML

merge takes the fragments and hands back one mapping to validate, resolve and
lower exactly once. A fragment is merged before it is validated, so a template
may name what a sibling declares without being a model on its own; the
coordinate space is shared where fragments agree, the math is owned, and the
objectives are summed.

Co-Authored-By: Claude <noreply@anthropic.com>
…g being a path rather than a second convention

merge read a string as YAML text where to_spec reads one as a path, so the
front door and the composition disagreed about what a caller had handed over.
A composed model is also held to round-tripping through to_yaml and printing
in all three formats: composing may not reach a model no reviewer can open.

Co-Authored-By: Claude <noreply@anthropic.com>
…one document they print

The gallery showed one model per page, which a composition has none of. It now
shows each fragment as written and then the document the merge prints, so the
claim a reader checks is that four small files and one model are the same
thing — and that the balance is the row it was before storage was added.

Co-Authored-By: Claude <noreply@anthropic.com>
…k writes them

The new files were the only YAML in the stack the formatter had not already
written, and the check that missed it globbed markdown alone where the hook
globs md, yml and yaml alike.

Co-Authored-By: Claude <noreply@anthropic.com>
…are not models and say so

render_tex renders every file under examples/, and a fragment naming what a
sibling declares is a load error alone — which is the thing examples/composed/
exists to show. The model they make is rendered by the gallery and typeset in
all three formats by the merge tests, so nothing goes unrendered.

Co-Authored-By: Claude <noreply@anthropic.com>
…g one fragment is that fragment

merge wrapped every objective in parentheses, so a lone objective came back
parenthesised: composing one fragment differed from the fragment, and every
nesting added another pair. It is returned as written now, and the idempotence
and associativity a composition verb has to keep are pinned by tests.

Moved here from the what-counts-as-a-verb docs PR, where the fix does not
belong — the behaviour is merge's, and so is its test.

Co-Authored-By: Claude <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 31, 2026
…ge's surface

The language has a test for what belongs in it and the ceiling has one for what
may enter it; the API had neither, which is how a small surface grows a verb per
feature. A verb decides nothing the language has not stated and needs nothing
but the file, and the three properties it keeps are pure, total at load, and
closed under composition.

Reframed to stand on main: the merge.py bugfix and its tests that were bundled
here now live on the composition PR (#244), where the behaviour and its test
belong, and the page states composition-closure as the property a verb earns
rather than a past defect in shipped code.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/parameter-coverage branch from 003ea7c to 200b831 Compare September 2, 2026 08:28
@FBumann
FBumann force-pushed the claude/parameter-coverage branch from 200b831 to 4ada6e5 Compare September 2, 2026 08:44
FBumann added a commit that referenced this pull request Sep 2, 2026
…ge's surface (#246)

The language has a test for what belongs in it and the ceiling has one for what
may enter it; the API had neither, which is how a small surface grows a verb per
feature. A verb decides nothing the language has not stated and needs nothing
but the file, and the three properties it keeps are pure, total at load, and
closed under composition.

Reframed to stand on main: the merge.py bugfix and its tests that were bundled
here now live on the composition PR (#244), where the behaviour and its test
belong, and the page states composition-closure as the property a verb earns
rather than a past defect in shipped code.

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann FBumann added the area: composition Assembling a model from files, and fixing a decision label Sep 15, 2026 — with Claude

FBumann commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Note

The following content was generated by AI.

Closed at the author's request, in a sweep of the open PRs. Closed rather than retargeted, because the base branch never landed.

The facts behind that:

The design question is the valuable part here, and it lives on #12 and #30, where it is still open. The implementation is a fragment merger written against a language that has since renamed the block it reads.

Not lost: the collision-error wording, the port-convention example, and the argument that a fragment is not a Spec are all in this thread. A fresh merge against current main can start from them.


Generated by Claude Code

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