Skip to content

feat(language): a piecewise curve applies to the members that have one, and ties as many expressions as the data says - #569

Closed
FBumann wants to merge 70 commits into
mainfrom
claude/mathspec-piecewise-api-7j0wpt
Closed

FBumann wants to merge 70 commits into
mainfrom
claude/mathspec-piecewise-api-7j0wpt

Conversation

@FBumann

@FBumann FBumann commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Let's work on the piecewise part of mathspec. I'm not fully satisfied with the api we buildt"

The scope was set later by: "fluxopt, a downstream user wants to add piecewise coupling power[flow], where a generator can have multiple flows connecting it to buses. A generator can have 1 up to 5 flows."

Prompt: "Please rebase 569 onto main"

Prompt: "Then discuss its api. Im not sure about points:" — "Lets do it!"

Prompt: "Rename the naming and complexity f the resulting piecewise code. Can it be simpler? Can we name classes or attributes better?"

Prompt: "Do a stacked PR trying to simplify the picewise feature as much as possible, removing all indirection. Also centralize the checks of whats allowed and what not."

Note

The following content was generated by AI.

What this changes

A piecewise: block says which members have a curve and how far each runs in one where:, one link ties as many expressions as the data says, and a curve may bound as many of its links as the model needs. Closes #77 and #82.

piecewise:
  operating_point:
    along: bp # renamed from `over:`
    dims: [converter, snapshot] # the curve's frame, declared rather than inferred
    where: has_curve AND bp_rate # which coordinates have a curve, and how far each runs
    links:
      - { expression: rate, values: bp_rate, into: carrier } # a tie per carrier
      - { expression: power, values: bp_power, by: converter_of, over: converter, into: flow }
      - [fuel, fuel_bp, ">="] # bounded by the curve, not pinned to it

points: is gone; where: may read the breakpoint dimension. Since #609 a points: was a predicate the rows read, not a parameter, so the two keys were one mask split by which dimension each was allowed to carry. Now a where: over the frame says which curves exist, one that also reads along says which breakpoints each runs through, and has_curve AND bp_x composes both. The rows over the frame and along take it as written; the edge rows shift it; the rows over the frame alone, which cannot read along, take count(where, over=bp) > 0. where: bp_x is the old points: bp_x, and a boolean mask is the old boolean mask.

A link's row is (frame − over) | into — the frame law #516 settled for relations, now the one rule for a link too. Two forms fall out of it:

written the row the weights
into: carrier the frame, plus carrier broadcast across it
by: converter_of, over: converter, into: flow the frame, less converter, plus flow read through the relation

Either way one link entry builds many rows sharing one set of weights, so λ stays invisible and every guard the block carries still applies. A sixth flow is a row in converter_of rather than an edit to the model.

Signs are per link, and at least one link is pinned. The old rule allowed one <=/>= and only in a block of exactly two links, so a curve needing a >= and a <= at once could not be written. Nothing in the emission needed that cap.

Five models that loaded clean and stated something else are now refused:

was now
two links at different grains built one curve per fine coordinate a link expression carries exactly its row's frame
a link coarser than its row pinned it to one operating point same rule, other direction
an unmasked link row read rate == 0 where a member had no curve where: reaches the link rows
every link bounded left the weights free, so only some point had to work at least one link is pinned
method: lp with three links raised ValueError rather than a LanguageError lp states its own two-link rule

Every check has one home (#623, merged into this branch). A block passes three layers in order: its own validators for arity, signs and which methods take a refinement or a gate; the file's cross-declaration validator, beside the sos: checks, for what it names by key and the names it writes; and piecewise.check, which returns a Curve of plain facts — the frame, each link's row, the mask, the gate rows, the names — from which four emitter functions write rows and decide nothing. The link expressions and the where are typed with the rest of the model, so the expansion no longer parses them. A refusal about a key or a collision is a SchemaError with the file's other cross-declaration refusals; PiecewiseExpansionError is left for a link that does not fit its row or a where outside the frame. Every message is unchanged.

The API, key by key
key
along: the dimension each curve runs along. Renamed from over:, on sos: too
dims: the curve's frame. Required once any link refines it
where: which coordinates have a curve; reading along too, how far each runs
into: on a link: what its row gains — a dimension it spans, or the columns a walk produces
by: / over: on a link: the relation, and the columns the walk consumes
sign on a link: bounded by the curve rather than pinned to it, any number per block

Why along:. The language already separates over=, which consumes a dimension, from along=, which keeps it and keeps it ordered. A breakpoint axis is ordered, it survives into the weights, and the expansion already wrote shift(seg, along=bp) for that same dimension. The reference page had to gloss the old key as "the dimension it runs along" in order to explain it. sos: moves with it, because method: sos2 hands one block's axis straight to the other's.

Why one where: and no points:. The only thing that kept them apart was a rule that where: may not carry the breakpoint dimension — a restriction this branch chose, not one the rows need. Every derived row and assumption already worked on a predicate: shift takes a where expression, so the neighbour, edge and interior predicates run on (where) in place of a name, and the _contiguous assumption wrote the first-of predicate that way already. What the split bought was a second key, a dtype check on it, a nomination rule, and a message telling the author to move a test from one key to the other.

One semantic shift comes with it: a coordinate whose mask admits no breakpoint has no curve, where points: refused it at data time. That is the reading where: already gave the frame.

Why the bare split is into: and not over:. With by: present, over: is what the row loses and into: what it gains. A split along a local dimension gains one and loses none, so it is into: with nothing consumed — the same law, entered from the other side. Spelling it over: would put two meanings on one key within one link. over: without by: is refused, and the message names into:.

A block whose links all refine the curve needs only one of them. Two links is what a curve needs when a link is one row; a refinement is one row per fine coordinate, so the data supplies the arity the second link otherwise would. One unrefined link is still a bound rather than a curve, and still refused.

What each refusal names, and why it is a refusal rather than a guess
  • A link expression carries exactly its row's frame, the rule a constraint's own dims: already holds to. Finer multiplies the rows; coarser repeats one row across a dimension the curve varies over, pinning the expression to a single operating point. Both are viable-sounding models that nothing in the file distinguishes from a mistake. The message differs by whether the frame was declared or read off the links.
  • At least one link is pinned. A pinned link fixes the operating point the bounded ones are read at. With every link bounded the weights are free and the block states only that some point on the curve satisfies the bounds — a different model, so it is refused rather than guessed. A refinement supplies arity, not a pin, so a lone bounded split or walk is refused too.
  • convex and lp take exactly two links and refuse a refinement — for two different reasons, and each message says its own. lp writes a segment line, which is one quantity against another, so without a link for the abscissa there is no line to write; under a refinement, which of the rows plays the abscissa is data rather than declaration. convex is not like that: its rows are weights on the simplex plus one row per link, a formulation that would serve any number. What it cannot do past two links is certify that relaxing onto the hull is exact — the sign on the bounded link names the direction the curvature is checked in, and a third link leaves no single direction to check against. Under a refinement it loses the pair of values parameters it reads a shape from.
  • where: beside a walked link is refused: the walk replaces the frame dimension the mask tests, so the row would read its weights as absent and pin its expression to zero. The message names the rewrite, a mask on the link's own variable. This lifts when may a where: carry operators — relocation through a lookup, reduction over a dimension it does not span #258 does. A split link keeps every frame dimension, so the mask reaches it and is allowed — a ragged one too, as the count of breakpoints its curve admits.
  • where: reading a refined link's values is refused by the rule every mask holds to: those values carry the link's own frame, and a mask cannot add coordinates. Raggedness belongs to the curve.
  • into: naming a dimension dims: already carries is refused: the curve builds one per coordinate of those, so they cannot also index a link's ties.
Guard table — every guard deleted in turn, suite run, tree restored clean

Twenty-eight guards. All caught.

guard caught by
the link row mask …reaches_every_row_the_block_emits[link0]
the convexity row mask …[convexity]
the weights mask …reaches_the_weights[lam]
_all_of's parenthesisation …a_where_and_a_points_both_reach_the_weights
the breakpoint-dim and stray-dim refusals …cannot_read_is_refused[…]
the mask on the declaration …the_data_guards_are_read_under
chord / domain row masks …segment_lines_carry_the_mask…
the declared frame replacing inference …one_curve_per_coordinate_of_it
the walked link frame and its at emission …one_curve_per_coordinate_of_it
values following the link frame …one_curve_per_coordinate_of_it
dims: required for a refinement …[refined-link-without-a-declared-frame]
by/over/into written together …[a-walk-that-does-not-name-both-ends]
convex/lp refusing a refinement …refuse_a_walked_link_for_their_own_reasons[convex]
points: refusing a refined values …points_naming_a_refined_links_values_is_refused
the relation existing …[a-walk-through-an-undeclared-relation]
the stray-dim arm …[a-frame-a-link-expression-leaves]
the missing-dim arm …links_that_disagree_on_their_dims…
the fit running on the inferred frame …links_that_disagree_on_their_dims…
the span path in the link frame …split_along_a_dimension_reads_the_curve_once…
produced dims appended for a split …split_along_a_dimension_reads_the_curve_once…
the weights broadcasting rather than walking …a_curve_holds_its_variables_through_the_rows_it_emits
into: refusing the breakpoint dim …[splitting-along-the-breakpoint-dim]
into: refusing a dim the frame has …[splitting-along-a-dim-the-frame-has]
over: requiring by: beside it …[over-without-a-relation]
the mask reaching a split but not a walk …a_block_mask_reaches_a_split_link
the pin rule (at least one ==) …[every-link-bounded]
convex/lp's two-link rule …take_exactly_two_links_for_their_own_reasons[convex]
lp's one-bounded-link rule …[lp-with-both-links-pinned]

One guard initially survived: the link-expression frame check. Deleting it still refused the model, but with Constraint 'coupling_link0' in the message — which is what the upfront checks exist to replace. The assertion moved onto the message text, and the suite now fails without the guard (9f450b3).

Before implementing the link-frame law I checked every piecewise: block in examples/ and tests/typesetting/golden/ against it. All nine links were already exact, so it refuses nothing correct today.

Two tests were passing on the wrong sentence. Both convex and lp cases matched one shared message, so convex was asserting lp's reason. They are now parametrised over (method, match) with a note that neither reason may stand in for the other, so collapsing the messages again fails the suite.

Coverage moved, not dropped. Two tests asserted the old sign cap. The two-bounded-links case keeps its row in the refusal table under every-link-bounded, the reason that now refuses it; the lp three-link case keeps its row under lp-with-three-links.

This table was measured before the two merges with main, the points: fold and the check split described below, and was not re-measured after them. The fold's own guards each land with a test: the count on the frame rows (…reaches_the_weights_as_written_and_the_frame_rows_as_a_count), the parenthesisation an edge row's shift needs (…is_grouped_where_an_edge_row_shifts_it), a ragged mask on a split block (…a_split_block_is_ragged_on_the_curves_own_frame), and the stray-dim refusal reaching a refined link's values (…naming_a_refined_links_values_is_refused). Coverage of the old points: dtype and nomination checks is dropped, not moved: a float parameter in a where: reads as "has a row", which is the nomination. The check split moved no guard without its test; #623 lists each.

The base, and what carrying it took

The base was #566's branch. #566, #589, #592 and #602 are all on main now, as squashes, so not one of this branch's base commits is an ancestor of main. Merged, never rebased: the hard rule here is never to force-push.

A straight git merge main reads that squashed history as unrelated work and conflicts in 27 files, most of them on #566's own lines. The merge is resolved instead as this branch's own diff — three-way against the commit where it last took #566 in — replayed onto main, which leaves 13 files and 20 hunks to settle. main is an ancestor of the result, so the PR diff is this branch's work alone.

Earlier, #585 renamed the program's where vocabulary and expression nodes on main while this branch was building. The same rename was applied here first, as one commit, so that the #566 merge agreed line for line instead of conflicting on every one of them: merged straight it conflicted in 86 places.

What the two sides genuinely disagreed about, resolved by hand:

A second merge (32ddc35) carries alpha.112 to alpha.116: #609, #612, #615 and #618. Four files conflicted, all on the lines #609 removed:

The fold (d182f28) removes points: from the model, the schema and the expansion. Resolved.piecewise holds each declared block's links and its mask, so the typesetter prints a ragged mask on the breakpoint set and a frame mask on the quantifier; the latter was not printed before.

The cleanup (96eb703, named in 89a702c) puts the mask behind one class. piecewise.CurveMask answers each shape of row: text as written for the weights, frame for the position tests a non-ragged mask is conjoined onto, exists for the rows over the frame alone (which is also what PiecewiseDeclaration.where carries, so a consumer's conjunction stays over the frame), and neighbours(), edge() and interior() for the rows and assumptions that sit on a curve's edges. It is not a program.Mask, because the expansion writes text and hands it back to the loader, and nothing prints a resolved mask back into the where grammar; it is named for the curve because every declaration has a where:, and what a curve alone has is one mask read by three shapes of row.

The check split (#623, merge e4962e6) is described above and in that PR. It flips the load order in Spec._validate_expressions to resolve the file before expanding it, which is what lets the expansion read the typed links and mask instead of parsing them; a fault in a link is still named against the link the file wrote, by resolution's own context.

Gates, and what could not be run

Run on 89a702c, and on #623's head before its merge:

gate result
pytest -q -n auto 1615 passed, 1 skipped; 1619 with #623's new test
ruff check clean
ruff format --check clean
pyrefly check 12 errors, all missing-import for pydantic and yaml stubs from the venv layout, the same count as main
mkdocs build --strict aborts only on fetching docs.python.org/objects.inv through the proxy (403); no other warning
the schema, the golden output and the five page generators regenerated and read: the schema drops points, the how-to's two assumption descriptions name where:
typos, reuse lint, taplo, zizmor, prettier, compile-tex not run, for want of the tools; CI ran them green on 89a702c and on #623
pixi run ci as a whole not run locally

Three defaults departed from, each deliberately:

  1. "Every command runs under pixi run." This environment's egress proxy refuses pixi.sh with a 403, so the gates ran from a uv environment on Python 3.12 with the pinned ruff==0.16.1 and pyrefly==1.2.0.
  2. "Run pixi run ci before pushing." Not possible for the reason above. CI will be the first to run compile-tex and the pinned toolchain.
  3. "One issue, one PR." This carries piecewise: needs a where: #77 and piecewise: a link should be a row, and its sign a column #82 rather than stacking them, because the session was given one designated branch.
Deliberately not done
  • An n-ary convex. Its rows would serve any number of links; only the exactness certificate stops at two. Shipping it uncertified would be a new silent-wrongness class, which is the thing the curvature assumption exists to prevent, so it stays refused — but the refusal is about the certificate, not the formulation.
  • An explicit spelling for the two refused broadcasts. A link finer or coarser than its row may one day be wanted. The refusal is where that spelling would hang, and by then there will be a real model to design it against.
  • A monotonicity check for a pinned link under adjacency/sos2. A pinned link fixes the operating point uniquely only where its values are strictly increasing; where they are not, the solver picks whichever consistent point suits the bounded links. That is unchanged by this PR — those methods never checked it — and widening it is a decision of its own.
  • A ragged mask on a walked block. points: on a block with a walked link was allowed; a where: beside a walked link is refused, ragged or not, because the walked row cannot read a mask over the frame it has walked out of. It lifts with may a where: carry operators — relocation through a lookup, reduction over a dimension it does not span #258 like the rest of that refusal.
  • docs/howto/curve-by-hand.md is deleted. Its premise — "a piecewise: block lists its links in the file, so it cannot say this" — is now false, and the form it taught drops every piecewise data guard. Its coverage moved to examples/piecewise_coupling.yaml and the reference page's refinement section.
  • No assumptions: entry is derived for a refined link. feat(language): a model writes its formulations out on request, and states what each assumes of its data #602 derives a block's conditions from the two links a curve is read as; a refinement is one row per fine coordinate, and what the conditions should say there is a decision of its own. convex and lp, the two methods that state a shape, refuse a refinement already.
  • lpspec. It follows on its next math-spec bump, which also has to take the Check retirement and the four predicate nodes added since alpha.108.

Why

fluxopt needs a curve tying however many flows a converter has, one to five, in one system. Written out by hand that works, but it loses every piecewise: data guard — lpspec measured a single dropped breakpoint row solving at 7096.25 instead of 5990.0, silently.

Trying it with the block instead exposed that the frame was inferred from the links, so a link carrying flow built one curve per flow rather than one per converter, and said so nowhere. That is the class this PR closes: the frame is something the file states, a link says how its row refines it, and everything that used to broadcast around it is refused with a named rewrite.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Hh3uK8SqVcXKuLnYHAjrVW

https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP

https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9

https://claude.ai/code/session_01GNZN57CR8oGTVpRmpXF8YZ

`program.Walk` becomes `program.Direction`, and the field both nodes hold
becomes `direction`. `_Resolver._walk` and `_partition_walk` become
`_direction` and `_partition_direction`, and nine refusals say what a call
does to a relation instead of describing a traversal.

A `Direction` is one relation and one join — which columns are consumed,
which produced, which joined on. It is never a sequence and carries no
accumulator, so "walk" promised a fold that is not there. The language now
says so itself: since #533 one call reads one relation, so there is not even
a list to compose.

The word stays where the thing is a traversal: `program.walk`,
`walk_regions`, `_expression_parser.nodes`, and the typesetter's own `Walk`,
which is a fold with `_Context` as its accumulator. That module imported
`Walk as RelationWalk` to hold both meanings at once; it imports `Direction`
now and the alias is gone. The axis sense stays too — `shift`, `sum_back`
and `position` step along an ordered dimension.

The refusals that changed, in full:

    ...names 2 relations, and one call reads one table
    ...this sum lands on the key ['g'], so each coordinate has one term
    A call names both of its ends, so that a relation may gain a value column...
    A relation is read between two of its columns and joined at the others...
    A call brings the dims it lands on, so that reading it tells you what it adds
    ...and a partition steps along a key column over the dimension it groups

`dimensions.md` renames its `### Walks` section to `### Directions`, and the
two links into it follow.

This renames an exported name: `math_spec.program.Walk` no longer exists and
`Direction` is in `program.__all__` in its place. lpspec reads the old name
and needs a follow-up once this tags; it pins math-spec by tag, so nothing
breaks before then.

pytest: 1294 passed, 6 skipped. ruff check, ruff format, typos, prettier and
`mkdocs build --strict` clean — the strict build is what proves the renamed
anchor has no dead link left. pyrefly reports the 3 pre-existing missing
`yaml` stubs. The five generators were re-run: only `notation.md` moved, and
only where it quotes the golden model's comments. The golden `.out` files did
not move, so the typeset math is unchanged.

`pixi` cannot be installed in this environment, so the gates ran from a pip
environment on Python 3.13; `compile-tex` is CI's to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUhbXWGiyZF46wHp3fVnr2
The rename lands on the docs and the partition rules that #539–#552 and
#540 rewrote. The relations page main added already says "direction", so
the sweep now only reaches the four operator-page sentences, the
dimension-set table's error column and the declare-a-column how-to that
still said "walk". `_partition_direction` takes the `within=` columns
main made required, and `Direction`'s docstring says the group is the
ones `within=` named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mvLvAhLGB26wJ4gsCtRSe
…st that still walked a relation

The relation docstring that the schema prints, the Direction class's own
docstring, three node docstrings, three example descriptions and the test
names and ids all said "walk" for a relation. They say "read" now. The axis
sense was half renamed: four places said "steps along" and six still said
"walks". It is "steps along" everywhere, which reaches the typesetter's
position note and so the golden outputs and the generated pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…ion with nothing consumed or produced

A translation, a window and a grouped position hold a Partition: the key
column stepped along, the group columns within= named, and the key columns
joined on. Direction keeps sum and at, where columns are consumed and
produced and the frame changes. A partition's frame does not change, so
its fields no longer have to be read against a paragraph that redefines
them. RelationNode carries either as `use`, and each consumer asserts the
kind its operator takes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…nsion it joins on, as sum does

The dim rule of a relation read is stated once, in the direction's own
terms: the consumed dims go, the produced arrive, the joined stay. sum and
at each stated it through RelationNode's fine and coarse sides, which
inverted between the two operators, and at's joined-column check read the
wrong side: it asked whether a joined dimension was produced, which the
landing check already refuses, rather than consumed. RelationNode holds the
use alone. The typesetter prints a relation read one way, from the
relation's name, so Direction and Partition no longer delegate key, values
and roles, and RelationDeclaration's key is required, since a bare
relation keys every column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…ode rather than one node holding either

Every consumer narrowed RelationNode's use field the moment it had the
node, so the union was a second assert at six sites for one fact. The
two nodes carry one kind each, and the kwarg union names both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…on is read directly rather than through accessors that invert

GroupSum and At lose relation, over, into and joined. On At the last
three inverted the call's own kwargs, so every reader had to know that
At.over was the produced dims. Readers name the side they mean off the
direction. Namespace.shape_of was one dict with two spellings. The
resolver's RelationDeclarations are the program's: Resolved carries them,
lowering reads them, and every Direction holds the same object the
program's dimensions hold rather than an equal copy built again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
… the at refusal names the columns the call lands on

Both messages were written in the node convention this PR removes, where
At.over was the produced side. The rewrite swapped over= and into=, so
following it was refused in turn; the at refusal said the operand fixes
the key columns, and it is the result that does. The tests match the
whole rewrite now, which is what let the swap survive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…ith the same message every operator does

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB
…ession grammar's arithmetic

The where grammar's three comparison rules — a name against a literal,
two relation columns, and position() against an integer — are one rule,
side <op> side, where a side is the expression grammar's ARITHMETIC. What
a side is, resolution decides with the schema in hand, and it hands back
the same typed nodes with the same messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…als it points at

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
Either side of a where comparison may be an expression over
parameters: arithmetic, a reduction, a pullback, a translation with
its edge, a macro, a named expression. A side is expanded, typed,
degree-checked and dim-checked as an expression is, and refused where
it names a variable or a dual. Two parameters compare the same way.

The resolved tree holds ArithmeticComparisonNode over the core syntax
tree, and lowering rebuilds every mask with ExpressionComparisonNode
over program expressions. A case when: comparing expressions is
refused as undecidable before the data arrives.

expressions.md sentence lengths: n 68, avg 14.0, median 13, over 25
words 3 (base: n 56, avg 13.0, median 13, over 25 words 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…ction that is not a mapping is refused

The public doors take Mapping[str, object] rather than dict[str, Any],
a raw value is an object until pydantic has read it, the two grammars
hand back the node type their child walk names, and the exclusivity
proof compares a cell with a literal of its own kind. Narrowing the
symbol table's sections turned an AttributeError on a list or a string
under dimensions: or names: into a SchemaError naming the rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…n rather than a protocol

A constrained type variable says what the four-method protocol said, in
one line. The two type statements are the plain unions every other alias
in the package is, and the symbol table section needs no cast once
narrowed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
… once, on the program's form

The resolved form of the comparison had a second walk collecting the
parameters and relations its sides read, and nothing asks a resolved
mask that question: lowering rebuilds every mask before one reaches a
consumer. The arm is the assertion the typesetter already carries for
the lowered node. The one-line mask wrapper in lowering is inlined, and
the two validation classes for what a where side may be are one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…ic-where' into claude/expression-parser-language-split-kdqegz-typing
…it, so nothing passes both

Namespace(schema) reads every declaration at construction; the eight
argument constructor and the classmethod that was its only caller are
gone. expression_of, _check_expression and _named took the schema and a
namespace built from that same schema; they take the namespace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…ic-where' into claude/expression-parser-language-split-kdqegz-typing
…e their only callers

expression_of and where_of had no caller in the package, and the two
assertions that named expression_of as the door now name
resolve_expression, which is the one the package walks through. The
plain form of a where comparison is read by one method: a name that is
a value against another is arithmetic, which used to be a second
question asked after the first. The "prefix a context once" rule has
one home in errors.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEeM2YoAk4Xr2uWB5qwMsH
…ic-where' into claude/expression-parser-language-split-kdqegz-typing
`piecewise:` takes a `where:`. It masks the frame, so a member the file
leaves off it gets no weights, no convexity row and no link row, and its
linked expressions stay free. Without it the only ways to leave a member
out were a curve of zeros, which pins its dispatch to zero, or missing
breakpoint rows, which the data refuses.

The mask reaches the data guards too: `PiecewiseDeclaration` carries it,
so a consumer asks `Increasing`, `Curved` and the rest only where a curve
exists, rather than refusing a member for breakpoints it has no rows for.

`where:` is refused where it carries the breakpoint dim, which `points:`
owns, and where it carries a dim no link expression does, because a mask
cannot add coordinates.

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

A link may name `by:`, `over:` and `into:`, the `at` walk. It then reads the
curve's weights through a relation, so one link entry builds one row per fine
coordinate: a generator with five flows and a generator with two share one
block, and a sixth flow is a row in the relation rather than an edit to the
model. The weights stay on the curve's frame, so the model never names them,
and every guard the block carries still applies.

Such a block declares `dims:`, the curve's frame. Inference reads the frame
off the link expressions, so a link carrying a dim the curve does not would
otherwise build one curve per flow instead of one per generator — which loads
clean today and states a different model.

A values parameter now follows its own link's frame rather than the block's.
`points:` naming a refined link's values is refused, because raggedness is a
property of the curve. `convex` and `lp` refuse a refined link, because each
proves its curvature by comparing the two values parameters and a refinement
puts them on two frames.

The `curve-by-hand` how-to is deleted. It existed because the block could not
say this, and the form it taught drops every piecewise data guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hh3uK8SqVcXKuLnYHAjrVW
…he link checks replace

The stray-dim case was refused either way, so the assertion moved onto the
message: with the link-frame check deleted the suite now fails, where before
it passed on `Constraint 'coupling_link0'`.

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

A block needed two links because a link was one row. A refined link is one row
per fine coordinate, so the relation supplies the arity the second link
otherwise would, and a converter tying only flows is a single link. One
unrefined link is still refused: one quantity on a curve is a bound.

`where:` is refused beside a refined link. The mask tests the curve's frame
and the refined row is built over a refinement of it, so the row would read
its weights as absent and pin its expression to zero — the silent failure
`where:` exists to prevent. The message names the rewrite, which is a mask on
the link's own variable.

`examples/piecewise_coupling.yaml` is a heat and power system whose converters
each run on one curve tying all their flows, with the symbol sidecar that
prints the weights as lambda.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hh3uK8SqVcXKuLnYHAjrVW
…not one they reduce

`piecewise:` and `sos:` spell their axis `along:`. The language already draws
the distinction the old key got backwards: `over=` consumes a dimension and
`along=` keeps it, ordered. A breakpoint axis is ordered, it survives into the
weights, and the expansion itself already writes `shift(seg, along=bp)` for the
same dimension. `sos:` moves with it, because `method: sos2` hands one block's
axis straight to the other's.

It also ends a collision the refined link introduced: `over:` on a link names a
relation column the walk consumes, which is what `over` means everywhere else.

No alias. The closed schema's error names the valid keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hh3uK8SqVcXKuLnYHAjrVW
…ile says which

A link expression now carries exactly the dimensions its row is built over.
Two silent wrong models are refused by the one rule.

A link carrying a dimension its row does not multiplied the rows it built. A
link missing one its row carries repeated a single row across that dimension,
pinning the expression to one operating point along an axis the curve varies
over. The second reached the inferred frame too: two links at different grains
made the frame their union, so a curve meant per converter was built per flow,
and the model loaded clean.

Neither is sayable another way today, so neither is guessed. The message names
which direction it is, and the rewrite differs by whether the frame was
declared or read off the links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hh3uK8SqVcXKuLnYHAjrVW
… to one operating point

A link may name `into:` on its own. Its row then gains that dimension and the
curve's weights broadcast across it, so every carrier or port of a converter is
a tie to the one operating point without a relation standing behind it.

This is the surface `sum` already has, on one law rather than two cases: a
link's row is `(frame - over) | into`. `into:` alone consumes nothing and gains
a dimension; `by:` with `over:` and `into:` is the `at` walk, which loses a
frame dimension and gains what the relation maps it to. `over:` without `by:`
is refused, because it names the columns a walk consumes.

A block mask reaches a split link, unlike a walked one: a split keeps every
dimension the frame has, so the mask still tests them.

`convex` and `lp` refuse both refinements, and now say why they do. Each states
the curve as one quantity against another, so each needs a link naming the
abscissa, and under a refinement which row plays it is data rather than
declaration. That is the reason; the curvature check was the consequence.

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

A link carrying `<=` or `>=` is bounded by the curve rather than pinned to it,
and any number of links may carry one. The old rule allowed a single sign, and
only in a block of exactly two links, so a curve needing a `>=` and a `<=` at
once could not be written. Nothing in the emission needed the cap: each link is
its own row against the shared weights.

What the cap stood in for is now stated directly. At least one link is pinned,
because a pinned link fixes the operating point the others are read at. With
every link bounded the weights are free and the block says only that some point
on the curve satisfies the bounds, which is a different model.

`lp` gains the rule it had been borrowing from that cap: it takes exactly two
links, as `convex` already did, because both state the curve as one quantity
against another. Three links reached `PiecewiseBlock.curve` and raised
`ValueError` rather than a refusal.

Coverage moved rather than went: the two-bounded-links case keeps its row in
the refusal table under the reason that now refuses it, and the lp three-link
case keeps its row under lp's own rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hh3uK8SqVcXKuLnYHAjrVW
FBumann and others added 6 commits September 20, 2026 21:17
… states

#585 renamed the program's where vocabulary and expression nodes on main. This
branch was written against the old names, and its own two comparison nodes
arrived with the suffix the rule reserves for the core AST. Applying the same
rename here first is what lets the base merge that follows agree line for line
rather than conflict on every one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP
#585 landed the program naming rule on main, and #584 cut alpha.107. The
branch's own two comparison nodes arrive under that rule as
`ExpressionComparison` and `ArithmeticComparison`.

Resolved by hand where the two sides changed the same code:

- `_check_where_dims` keeps this branch's `leaf` form. main's `noun` form
  cannot name a comparison of expressions, which carries no single name.
- `_declared_rhs_error` keeps this branch's three kinds. A parameter on the
  right-hand side is arithmetic here, not a refusal, so main's `parameter`
  branch has nothing left to fire on.
- `Namespace` keeps its schema-carrying form and builds a
  `RelationDeclaration` without the name main dropped from it.
- `expression_of` and `where_of` stay in `tests/fixtures.py`, where this
  branch moved them; main's copies in `resolution.py` go with the merge.
- `Translate.along` and `WindowSum.along`, `Program.roots`, `Footprint.kinds`
  and the relation records are main's.

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

#585 landed the program naming rule on main. This branch carries the arithmetic
where work under the old names, and applying the same rename here first is what
lets the merge that follows agree line for line rather than conflict on every
one of them.

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

The base branch carries #585's program naming rule and alpha.107 with it.

Resolved by hand where the two sides changed the same code:

- `Resolved` keeps this branch's `piecewise` field, and `lower_program` keeps
  the `piecewise` it builds above rather than the inline one below.
- `Program` is built with `relations=` and `expressions=`, the names main now
  gives those groups.
- `PiecewiseExpansion` keeps its per-expression dim cache.
- The relation records, `Translate.along`, `WindowSum.along`, `Program.roots`
  and `Footprint.kinds` are the base branch's.

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

The `Predicate` union said it was what a lowered mask's `root` is built of.
That is false here: it also holds `ArithmeticComparison`, which lowering
rewrites into an `ExpressionComparison`, so a consumer walking a program meets
every other member and never that one.

These lines were #580's, which is where they were written. They are only true
where the two nodes exist, and this is the branch that adds them.

docs/reference/reading.md: the two sentences added are 11 and 9 words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPqtQuarRGDmX2WrNVuKDP
…e-split-kdqegz-arithmetic-where' into wt/569
@FBumann
FBumann removed this pull request from stack #587 September 20, 2026 21:35
@FBumann
FBumann added this pull request to stack #588 September 20, 2026 21:35
@FBumann FBumann added the area: formulations Blocks expanding to declarations: piecewise, indicator, McCormick label Sep 21, 2026
@FBumann FBumann added this to the Conditional piecewise curves milestone Sep 21, 2026
@FBumann
FBumann removed this pull request from stack #588 September 21, 2026 16:15
main carries 566, 589, 592 and 602 as squashes, so none of this branch's
base commits are ancestors of it. The merge is resolved as this branch's
own diff replayed onto main rather than as the line-by-line merge that
shared history would have given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
@FBumann
FBumann changed the base branch from claude/expression-parser-language-split-kdqegz-arithmetic-where to main September 22, 2026 12:05
Carries #609 (a curve emits no parameter), #612, #615 and #618 onto the
branch. The masked lp rows keep the block's where conjoined onto the
edge predicates, the record of an expanded block is the block itself,
and main's expand fixtures spell the axis as along:.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNZN57CR8oGTVpRmpXF8YZ
points: is folded into where:. A where that reads the breakpoint dim marks
the breakpoints each curve runs through; the rows over the frame and the
breakpoint dim take it as written, the rows over the frame alone take the
count of breakpoints it admits, and the edge predicates shift it. A block
has one mask, and the rule about which dimension each of two masks may
carry goes with the second key.

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

Where resolves the block's mask once and answers each shape of row: as
written, the frame-only part, the count a frame row takes, and the edge
predicates. It replaces six module functions and four attributes threaded
through the expansion. The assumptions a block derives are AssumptionBlock
rather than a tuple that mirrored it, and the expansion names what it
holds: the block, the weights, the link rows and the set's names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNZN57CR8oGTVpRmpXF8YZ
Where read as if the concept were piecewise's own. Every declaration has
a where; what a curve alone has is one mask read by three shapes of row.

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

FBumann commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Prompt: Keep #569's design and re-port #638's load-time checks onto it, which also reverts the points: language. … YOu can also close and redo the contents!

Note

The following content was generated by AI.

Closed as superseded by #630, which carries every commit of this branch and now has main merged in with this design kept. The points: language is reverted there, and #638's load-time checks are on the block's own lines.


Generated by Claude Code

@FBumann FBumann closed this Sep 23, 2026
@FBumann
FBumann removed this pull request from stack #642 September 23, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: formulations Blocks expanding to declarations: piecewise, indicator, McCormick

Projects

None yet

Development

Successfully merging this pull request may close these issues.

piecewise: needs a where:

2 participants