Conversation
`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
Documentation build overview
48 files changed ·
|
… 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
… 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
removed this pull request from stack #587
September 20, 2026 21:35
FBumann
added this pull request to stack #588
September 20, 2026 21:35
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
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
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
…rom what the check settled (#623)
FBumann
added this pull request to stack #636
September 22, 2026 20:47
FBumann
removed this pull request from stack #636
September 23, 2026 09:01
FBumann
added this pull request to stack #642
September 23, 2026 09:01
Contributor
Author
Note The following content was generated by AI. Closed as superseded by #630, which carries every commit of this branch and now has Generated by Claude Code |
FBumann
removed this pull request from stack #642
September 23, 2026 12:07
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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."
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 onewhere:, 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.points:is gone;where:may read the breakpoint dimension. Since #609 apoints: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 awhere:over the frame says which curves exist, one that also readsalongsays which breakpoints each runs through, andhas_curve AND bp_xcomposes both. The rows over the frame andalongtake it as written; the edge rows shift it; the rows over the frame alone, which cannot readalong, takecount(where, over=bp) > 0.where: bp_xis the oldpoints: 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:into: carriercarrierby: converter_of, over: converter, into: flowconverter, plusflowEither 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_ofrather 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:
rate == 0where a member had no curvewhere:reaches the link rowsmethod: lpwith three links raisedValueErrorrather than aLanguageErrorlpstates its own two-link ruleEvery 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; andpiecewise.check, which returns aCurveof 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 aSchemaErrorwith the file's other cross-declaration refusals;PiecewiseExpansionErroris 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
along:over:, onsos:toodims:where:alongtoo, how far each runsinto:by:/over:signWhy
along:. The language already separatesover=, which consumes a dimension, fromalong=, which keeps it and keeps it ordered. A breakpoint axis is ordered, it survives into the weights, and the expansion already wroteshift(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, becausemethod: sos2hands one block's axis straight to the other's.Why one
where:and nopoints:. The only thing that kept them apart was a rule thatwhere: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:shifttakes a where expression, so the neighbour, edge and interior predicates run on(where)in place of a name, and the_contiguousassumption 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 readingwhere:already gave the frame.Why the bare split is
into:and notover:. Withby:present,over:is what the row loses andinto:what it gains. A split along a local dimension gains one and loses none, so it isinto:with nothing consumed — the same law, entered from the other side. Spelling itover:would put two meanings on one key within one link.over:withoutby:is refused, and the message namesinto:.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
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.convexandlptake exactly two links and refuse a refinement — for two different reasons, and each message says its own.lpwrites 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.convexis 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 awhere: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 dimensiondims: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.
…reaches_every_row_the_block_emits[link0]…[convexity]…reaches_the_weights[lam]_all_of's parenthesisation…a_where_and_a_points_both_reach_the_weights…cannot_read_is_refused[…]…the_data_guards_are_read_under…segment_lines_carry_the_mask……one_curve_per_coordinate_of_itatemission…one_curve_per_coordinate_of_it…one_curve_per_coordinate_of_itdims:required for a refinement…[refined-link-without-a-declared-frame]by/over/intowritten together…[a-walk-that-does-not-name-both-ends]convex/lprefusing a refinement…refuse_a_walked_link_for_their_own_reasons[convex]points:refusing a refined values…points_naming_a_refined_links_values_is_refused…[a-walk-through-an-undeclared-relation]…[a-frame-a-link-expression-leaves]…links_that_disagree_on_their_dims……links_that_disagree_on_their_dims……split_along_a_dimension_reads_the_curve_once……split_along_a_dimension_reads_the_curve_once……a_curve_holds_its_variables_through_the_rows_it_emitsinto:refusing the breakpoint dim…[splitting-along-the-breakpoint-dim]into:refusing a dim the frame has…[splitting-along-a-dim-the-frame-has]over:requiringby:beside it…[over-without-a-relation]…a_block_mask_reaches_a_split_link==)…[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 inexamples/andtests/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
convexandlpcases matched one shared message, soconvexwas assertinglp'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 underlp-with-three-links.This table was measured before the two merges with
main, thepoints: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 oldpoints:dtype and nomination checks is dropped, not moved: a float parameter in awhere: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
mainnow, as squashes, so not one of this branch's base commits is an ancestor ofmain. Merged, never rebased: the hard rule here is never to force-push.A straight
git merge mainreads 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 ontomain, which leaves 13 files and 20 hunks to settle.mainis 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
mainwhile 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:
PiecewiseDeclaration.checksis gone;where:stays. feat(language): a model writes its formulations out on request, and states what each assumes of its data #602 retiredprogram.Check—Increasing,Curved,AtLeastTwo,Contiguous— forassumptions:entries the block derives as where-text. The branch's mask keeps its job: it isPiecewiseDeclaration.where, and it is what the derived conditions are asked under.method: adjacencyno longer writes its own binaries. feat(language): a model writes its formulations out on request, and states what each assumes of its data #602 states it as thesos:blocksos2states and writes that out, so the branch's hand-writtenpickandadjacencyrows go. The set's row issum(seg, over=bp) <= 1, which reads0 <= 1where no binary exists, so it needs no mask of its own — where the block's own rows would read0 == 1and do carry one.test_a_where_reaches_every_row_the_block_emits[pick]moved totest_the_row_the_set_states_needs_no_mask_of_its_own, which asserts both halves.Resolved.piecewisecollided outright. feat(language): a model writes its formulations out on request, and states what each assumes of its data #602 keys it by declared block and holds the link expressions; this branch keys it by written-out block and holds the mask. Both are kept, the second asResolved.expanded_piecewise. A block is in one mapping or the other, never both: writing it out is what moves it.wherehad nothing to read.expand_piecewiseandexpand_setsdrop the cachedresolvedonce the records are in place, and the pass runs again with them._ExpandedSpecis gone; the records hang offSpecitself.sos:'sover:→along:had to reach the three curves feat(language): a model writes its formulations out on request, and states what each assumes of its data #602 added to the golden model, thesee-an-expansion.mdhow-to, andsos.emit.big_monsos:is gone — feat(language): a model writes its formulations out on request, and states what each assumes of its data #602 scales the linking rows by the member's own bounds — and the branch's twobig_mrefusal cases go with it.docs/howto/curve-by-hand.mdis deleted here and its nav row with it; feat(language): a model writes its formulations out on request, and states what each assumes of its data #602'ssee-an-expansion.mdstays.convexandlpprose on the reference page was rewritten on both sides. The merged text keeps this branch's reason for the two-link rule and feat(language): a model writes its formulations out on request, and states what each assumes of its data #602's sentence on what the bounded link's sign decides.A second merge (
32ddc35) carries alpha.112 to alpha.116: #609, #612, #615 and #618. Four files conflicted, all on the lines #609 removed:lprows sat on the derived_starts/_endsparameters and recorded them onExpandedPiecewise. They now sit on the edge predicatesmainwrites, with the block's ownwhere:conjoined by_all_ofas before. The record of an expanded block is the block itself, soResolved.expanded_piecewisetypespw.whereoff it anddeclaration_oftakes the block.to_program; they expand first.along:reaches whatmainadded: the twelvetests/expand/fixtures from docs(expand): every formulation expands to a hand-written file the suite compares whole #611 and fix(typeset): a set prints under the name its sos: block declares #615's set-label test. The how-to page is regenerated from them.wherereadsposition(bp) > 0rather thanposition(bp) != 0, the same rows.The fold (
d182f28) removespoints:from the model, the schema and the expansion.Resolved.piecewiseholds 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 in89a702c) puts the mask behind one class.piecewise.CurveMaskanswers each shape of row:textas written for the weights,framefor the position tests a non-ragged mask is conjoined onto,existsfor the rows over the frame alone (which is also whatPiecewiseDeclaration.wherecarries, so a consumer's conjunction stays over the frame), andneighbours(),edge()andinterior()for the rows and assumptions that sit on a curve's edges. It is not aprogram.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 awhere:, 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 inSpec._validate_expressionsto 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:pytest -q -n autoruff checkruff format --checkpyrefly checkmissing-importfor pydantic and yaml stubs from the venv layout, the same count asmainmkdocs build --strictdocs.python.org/objects.invthrough the proxy (403); no other warningpoints, the how-to's two assumption descriptions namewhere:typos,reuse lint,taplo,zizmor,prettier,compile-tex89a702cand on #623pixi run cias a wholeThree defaults departed from, each deliberately:
pixi run." This environment's egress proxy refusespixi.shwith a 403, so the gates ran from auvenvironment on Python 3.12 with the pinnedruff==0.16.1andpyrefly==1.2.0.pixi run cibefore pushing." Not possible for the reason above. CI will be the first to runcompile-texand the pinned toolchain.piecewise:needs awhere:#77 andpiecewise: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
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.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.points:on a block with a walked link was allowed; awhere: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 awhere: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.mdis deleted. Its premise — "apiecewise: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 toexamples/piecewise_coupling.yamland the reference page's refinement section.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.convexandlp, the two methods that state a shape, refuse a refinement already.Checkretirement 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
flowbuilt 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