Skip to content

feat(language): a base and its patches compose into one model, so shared math travels as files rather than as a script - #250

Closed
FBumann wants to merge 22 commits into
claude/fix-a-decisionfrom
claude/override-a-base
Closed

FBumann wants to merge 22 commits into
claude/fix-a-decisionfrom
claude/override-a-base

Conversation

@FBumann

@FBumann FBumann commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Then let's discuss how we need to change merge to handle this as well, Or if we need another method / Or do we need flags inside the file really?" — and, after the discussion, "Yes, build it!"

Note

The following content was generated by AI.

What this changes

model = ms.override('base.yaml', {'pathway': 'pathway.yaml', 'project': 'project.yaml'})

A framework ships math; a project extends it. #12 is where this is argued, and #244 took the other side of it — this is the side it did not take.

A patch says only what it changes, because declarations are laid over field by field:

constraints:
  flow_out_max: { foreach: [node, tech, carrier, snapshot, investstep] }

A declaration set to null is removed:

constraints:
  no_longer_relevant: null
variables:
  cap: null # a decision becomes a number the next block declares
parameters:
  cap: { dims: [generator] }

examples/composed/operate.yaml is that patch in four lines, laid over the four-fragment library from #244: capacity stops being a decision and every constraint expression and the objective are identical either side of it. It is executed on the file.md page rather than described there.

The three decisions, and why

Two verbs, not a mode on merge. Composition and override obey opposite laws — a name two peers declare is a collision, a name a patch declares is the point — and erroring on a shared name cannot also mean winning with it. A mode= flag would make one call mean two things. The collision error is a real safety property for component libraries (two templates both declaring cost by accident), and a layered model must not have it.

merge override
Shape N peers base + patches
A shared name an error the mechanism
Order irrelevant, associative the instruction

A marker in the file is genuinely needed — but only for removal. This is the part I think is under-appreciated in #12: an ordered list of files gets you replacement but not deletion. A declaration a patch does not mention is left alone, so "absent" means unchanged and a deletion has no other spelling. That is the irreducible core of the issue, and @brynpickering is right that it has to travel with the file rather than sit in a script.

null rather than active:, and on @FBumann's own objection. His complaint is that twenty deactivated components make a file harder to read than not having them. null is one line carrying no lingering declaration to skip past, where {active: false} is one line and gives active a meaning in ordinary model files — where it buys nothing and costs exactly the readability he named. @brynpickering offered null himself in the thread; this is that suggestion, with the ambiguity he worried about resolved below.

The ambiguity null had, and how it is resolved

He noted that a parameter-to-variable swap gets murky:

parameters:
  becomes_a_var: null
variables:
  becomes_a_var: ...

The problem underneath is real and sharper than stated: where: null is already a legal value — a variable with no mask — so if null meant "remove" everywhere, one spelling would mean two things.

The marker is positional. constraints: {ramp: null} removes the constraint; variables: {p: {where: null}} sets that variable's mask to none. Removal is a declaration-level marker and reaches no deeper. That is rule 4's instinct — position decides which kinds of name are legal — applied one level out, and it makes the swap above unambiguous rather than merely conventional.

Why nothing in the schema changes

A patch is not a model, for the same reason #244 established a fragment is not: it is read before validation. So null never appears in a file anyone loads as a model, no block gains a key, and a validated Spec cannot contain one.

That is also the answer to #13, which was closed design: declined on "I'm not sure if we should widen the language this way". Its motivating case — capacity a variable in expansion, a parameter in dispatch — is now sayable at file level, and the language is not widened: variables: and parameters: stay separate blocks and the schema is untouched. Three routes now reach that outcome and none is redundant: #249's fix is programmatic and mid-workflow, a patch is file-level and shareable, and the declined component_type: was neither.

The risk, and the answer to it

override is dangerous in a way merge is not: it is designed to collide, so a patch that quietly replaces a constraint the base relied on yields a valid model meaning something else, with no error anywhere.

The mitigation needs nothing new. #244 proved a composed model round-trips through to_yaml, so the artifact to review is the output and a patch's real effect is a diff of base against result. That is asserted here too. I would document that as the workflow rather than build provenance reporting, until someone hits a case where the diff is not enough.

The one thing it does refuse: removing a declaration the base does not have, named with the near miss. A removal is a claim about what is there, so a stale one is a patch that no longer describes what it lands on — a base that moved on, or a section confused for another.

What lpspec removes or changes

Nothing. override returns a mapping and every verb already takes one, so lps.solve(ms.override(base, patches), sources) works against an unchanged engine — same as merge.

Verified

Toolchain at the versions pixi.toml pins: ruff format --check / ruff check (0.16.1) clean, pyrefly (1.2.0) 0 errors, reuse compliant, typos clean, prettier --check over **/*.{md,yml,yaml} clean, pytest -q 926 passed, 0 skipped (915 on the base — 11 new), mkdocs build --strict builds, python -m tools.schema and python -m tests.typesetting.golden regenerate to no diff.

Not run: zizmor, taplo, and the tectonic half of compile-tex. mkdocs build --strict needed docs.python.org/objects.inv dropped from mkdocs.yml, which this environment's proxy refuses; that affects cross-links into CPython's docs and nothing in this diff.

Mutation table

Mutation Result
null removes at any depth, not just a declaration 1 failed
a declaration replaces wholesale instead of field by field 2 failed
a stale removal is ignored 1 failed
the base is mutated in place 4 failed
patches applied in reverse, so the first wins 1 failed
restored all green

The first needed a second attempt: over is not None inside a isinstance(over, dict) branch is always true, so my first mutation changed nothing and passed the whole suite — a catch that did not happen. Re-expressed as popping the key on a nested None.

Deliberately not done

No active:. #12 stays open; this implements the removal it needs by the spelling that costs the model schema nothing. If deactivate-in-place is wanted for its own sake — keeping a switched-off declaration so it can be switched back — that is a separate argument this does not make.

No provenance for which patch won which key. The composed model is to_yaml-able and the diff is the answer; a report would be a second way to learn the same thing.

Order is not made explicit in the files. A patch carries its removals, but which file is the base and what order the rest apply in is still the caller's — and I think rightly, since a patch that named its own position could not be reused against a different base.

Changed in review. _lay_over returned None and wrote into its argument, which made override's "the base is never mutated" guarantee a property of the caller rather than of the function; it now takes a base and returns the laid result. And the stale-removal message called a sos collision a so and a piecewise one a piecewis, which #244 now fixes for both verbs.

Stack: ninth, on #249 → #248 → #247 → #246 → #245 → #244 → #243 → #242 → #168.


Generated by Claude Code

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>
FabianHofmann and others added 12 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>
… so a port nobody wired is not a term that vanishes

A map short of a label lands its terms in no group at all, which is a shape a
model wants and a wiring mistake in equal measure. `coverage:` says which, and
a composed model declaring its coupling map total turns the second from a
plausible answer into an error. The vocabulary is now one Coverage, shared with
the parameter key it repeats.

Co-Authored-By: Claude <noreply@anthropic.com>
…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.

merge lost the third and nobody noticed: a lone objective came back
parenthesised, so composing one fragment differed from the fragment and every
nesting added a pair. It is returned as written now, and the idempotence and
associativity the page argues from are pinned by tests.

Co-Authored-By: Claude <noreply@anthropic.com>
…at a time, before a driver cuts one

A rolling horizon or a myopic pathway asks one thing of a model before it
starts, and nothing answered it: storage carried over a snapshot survives being
cut into overlapping windows and an annual budget does not, while both window
into pieces that solve. separable reports the overlap two windows need and,
where there is no overlap that would do, names each declaration and the
construct tying the axis together.

The care is that a reduction means opposite things by position: in a constraint
a sum over the axis ties every window to every other, and in the objective it
is additively separable. A verdict treating the two alike would refuse every
windowable model there is.

Co-Authored-By: Claude <noreply@anthropic.com>
claude added 6 commits August 31, 2026 20:03
…de the footprint it already answers

It was a module and a verb, on the reasoning that a pass belongs outside the
module that defines the AST and that a dimension argument ruled out an
accessor. Both are wrong: footprint is a walk that lives there already, and
dimension(name) is a parameterised accessor. Nor was there a cycle to dodge —
where_parser reaches errors and expression_parser and neither reaches program.

So it reads off the program like every other question about one, and the
package exports one name fewer.

Co-Authored-By: Claude <noreply@anthropic.com>
… the measurement

Co-Authored-By: Claude <noreply@anthropic.com>
…ing for one already walked the whole program

It was a method taking a dimension, which meant a walk per axis and a caller
that had to know which axis to ask about. Every construct that ties an axis
names the axis it ties, so one walk answers for all of them: measured at 831us
against 844us for the single axis it replaced, on the largest model shipped
(#248).

So it is a cached mapping like the footprint beside it, complete over the
declared dimensions — an axis nothing mentions is trivially windowable rather
than absent. A reduction collapsing several axes now couples each of them,
which the per-axis form could not get wrong and this one could.

Co-Authored-By: Claude <noreply@anthropic.com>
…bproblem is a call rather than a second file

A myopic pathway, a rolling horizon and a Benders subproblem share one move,
and written by hand it is a second file to keep in step with the first. fix
makes it a function: the name does not move, so every expression naming it goes
on reading.

It reads like a rename and is not. A variable masked by where: has rows that do
not exist, so as a parameter it is coverage: masked, where the obvious rewrite
claims a number everywhere and binds cleanly against data that has none; and a
binary decision becomes an int rather than a float, a flag being a mask and not
something a constraint multiplies by.

Co-Authored-By: Claude <noreply@anthropic.com>
…red math travels as files rather than as a script

merge composes peers and refuses a name two of them declare, which is the wrong
law for a framework's math that a project extends. override lays patches over a
base in order: a declaration is replaced field by field, so a patch says only
what it changes, and one set to null is removed.

The removal marker is what an ordered list of files cannot supply for itself —
a declaration a patch does not mention is left alone, so a deletion has no
other spelling. It is positional and reaches no deeper than a declaration,
where a null is the value the schema already takes. A patch is not a model, so
nothing in the schema gains a key.

Co-Authored-By: Claude <noreply@anthropic.com>
The verb had a design argument and no example of the workflow it argues about.
examples/composed/ gains a capacity decision and a four-line patch that takes it
away, which is the base-to-operate move the issue is about: every constraint
expression and the objective are identical either side of it, and the cost of
capacity becomes the sunk constant it is.

file.md showed both verbs against paths that did not exist, so nothing could
have run them. They compose that library now, and the page's claims are
executed. Reading a claim off the line an expression *ends* on is what lets one
wrap: prettier formats python inside a fence too, and the page test read the
line it started on.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/fix-a-decision branch from cf0fe7c to 163000f Compare August 31, 2026 18:08
@FBumann
FBumann force-pushed the claude/override-a-base branch from 3036298 to ed85bb7 Compare August 31, 2026 18:08
@FBumann
FBumann force-pushed the claude/fix-a-decision branch from 163000f to 3348d70 Compare August 31, 2026 18:29
@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 whole stack under it is closed.

Bringing this up to date means resolving three dead stack levels and then a rename across every example it adds. Rebuilding override against current main is less work and starts from the language as it now is.

#12 stays open, and the three decisions in this description are the reason to keep it open: two verbs rather than a mode on merge, a marker in the file needed only for removal, and null as that marker with the ambiguity resolved positionally. Those survive this PR and should be re-argued there, not lost with the branch.


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