Skip to content

feat(language): a model writes its formulations out on request, and states what each assumes of its data - #602

Merged
FBumann merged 14 commits into
claude/adoring-brahmagupta-ovwe3v-operatorsfrom
claude/practical-allen-a6jdak
Sep 22, 2026
Merged

FBumann merged 14 commits into
claude/adoring-brahmagupta-ovwe3v-operatorsfrom
claude/practical-allen-a6jdak

Conversation

@FBumann

@FBumann FBumann commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Lets work on a PR stacked onto that. It should achieve: 1. establish Spec->Spec transformations that expand sos and piecewise, making the transformations observable (they are currently done in lpspec depending on the used solver) 2. Put all the data assumptions of piecewise into the assumptions block. Look at #579 for the details" — "Take care, 582 might be based on a different piecewise implementation. Take care of checking that the new PR is written against #592" — "I want a clear table that shows which kind of piecewise holds which assumptions, and how they are spelled in the yaml" — "The table should hold the actual yaml syntax!" — "Can you add the typesetting to the table?" — "Transpose the table!" — "Make convex read the link sign and update the PR"

Prompt: "Review the stack of 602 and below" — "Yes: delete bound: and require bounds.upper on a set's member — fix the typesetting — fix the expand cache"

Note

The following content was generated by AI.

Closes #579. Replaces #582, which is this work on a base that predates #566, #585 and #589. Spec.expand() writes a formulation out on request, sos: is the second formulation, and what a piecewise: method assumes of its numbers is an ordinary predicate rather than a closed union only this package can read.

Stacked on #592. The chain is 566 → 589 → 592 → this, and every rung carries main at 0.0.0-alpha.110.

What a method assumes of the data

A cheap method is exact only for numbers of the right shape. Which conditions a block states is the method:'s business, and expand() writes each one into assumptions: verbatim as below. Each column is one condition, read top to bottom.

Read with: breakpoints x (the input axis) and y (the output), over dimension bp. on_curve is the dtype: bool parameter points: names, marking which breakpoints the curve actually runs through.

<block>_increasing <block>_curvature, convex <block>_curvature, concave <block>_curvature, either way <block>_breakpoints <block>_contiguous
Assumes breakpoints strictly increase the curve is convex the curve is concave the curve bends one way only at least two breakpoints the mask marks one run
Stated by convex, lp convex or lp, link bounded >= convex or lp, link bounded <= convex, both links pinned lp any method with points:
holds: shift(x, along=bp, offset=1, edge=0) < x (y - shift(y, along=bp, offset=1, edge=0)) * (shift(x, along=bp, offset=-1, edge=0) - x) <= (shift(y, along=bp, offset=-1, edge=0) - y) * (x - shift(x, along=bp, offset=1, edge=0)) the same, with >= count(⟨bends up⟩ AND ⟨interior⟩, over=bp) == 0 OR count(⟨bends down⟩ AND ⟨interior⟩, over=bp) == 0, where a bend is the cross-product to the left and ⟨interior⟩ is the where: below it count(x, over=bp) >= 2 count(on_curve AND NOT shift(on_curve, along=bp, offset=1), over=bp) == 1
where: position(bp) > 0 position(bp) > 0 AND position(bp) != -1 the same — (folded into the counts) — —
As typeset $\mathrm{x}_{p \boxminus_{0} 1} &lt; \mathrm{x}_{p} \quad \forall, p \in \mathcal{P} ,:, \mathrm{pos}(p) &gt; 0$ $\left( \mathrm{y}_{p} - \mathrm{y}_{p \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{p \boxplus_{0} 1} - \mathrm{x}_{p} \right) \le \left( \mathrm{y}_{p \boxplus_{0} 1} - \mathrm{y}_{p} \right) \cdot \left( \mathrm{x}_{p} - \mathrm{x}_{p \boxminus_{0} 1} \right) \quad \forall, p \in \mathcal{P} ,:, \mathrm{pos}(p) &gt; 0 \wedge \mathrm{pos}(p) \neq \lvert \mathcal{P} \rvert - 1$ the same, with $\ge$ $\lvert \{ p : \langle\text{up}\rangle \wedge \langle\text{int}\rangle \} \rvert = 0 \ \vee\ \lvert \{ p : \langle\text{down}\rangle \wedge \langle\text{int}\rangle \} \rvert = 0$ $\lvert \{ p \in \mathcal{P} ,:, \mathrm{x}_{p} \text{ is defined} \} \rvert \ge 2$ $\lvert \{ p \in \mathcal{P} ,:, \mathrm{on\_curve}_{p} \wedge \neg, \mathrm{on\_curve}_{p - 1} \} \rvert = 1$

\boxminus_{0} is the translation with a zero fill — one breakpoint back, the vacated row excluded by the where:.

adjacency and sos2 state nothing. They spend binaries and take a curve of any shape, so neither has a column above.

Under a points: mask every position test is replaced by the mask. Each where: narrows to the admitted neighbours — on_curve AND shift(on_curve, along=bp, offset=1) for a segment, the same with offset=-1 added for a bend — the breakpoint count reads count(on_curve, over=bp) >= 2, and the either-way column carries on_curve inside its counts rather than position(bp). This is what makes a ragged curve safe: without a mask the frame is rectangular, so a curve using three of five breakpoints has two rows whose missing data reads as a breakpoint at the origin.

The bounded link's sign fixes the direction of the bend, under both methods. lp states one side of the curve as its segment lines and convex relaxes the weights onto the hull, whose boundary on that side is the same set of points. So a >= link asks for a convex curve and a <= link for a concave one, whichever of the two methods is written.

Only a block with two pinned links states the weaker condition. There nothing in the block says which way the weights are driven within the hull, and "one bend either way" is the most that can be claimed: it rules out a mixed curve, which is wrong whichever way the pressure runs, and admits a single bend, which is exact one of the two ways. That is why that column is a disjunction of two counts rather than one comparison.

Each entry also carries a description:, the sentence a consumer raises when the data fails it. For the convex column:

piecewise 'curve': method: lp is exact only for a convex curve, and 'y' over 'x' along 'bp' is not one, so the answer is wrong rather than loose. Use method: adjacency or sos2, which take a curve of any shape.

A model that still declares the block derives the same text at load, so to_program(spec).assumptions == to_program(spec.expand()).assumptions.

The two long cells in full

The either-way holds:, which is what a method: convex block with both links pinned states:

holds: count((y - shift(y, along=bp, offset=1, edge=0)) * (shift(x, along=bp, offset=-1, edge=0) - x) > (shift(y, along=bp, offset=-1, edge=0) - y) * (x - shift(x, along=bp, offset=1, edge=0)) AND position(bp) > 0 AND position(bp) != -1, over=bp) == 0 OR count((y - shift(y, along=bp, offset=1, edge=0)) * (shift(x, along=bp, offset=-1, edge=0) - x) < (shift(y, along=bp, offset=-1, edge=0) - y) * (x - shift(x, along=bp, offset=1, edge=0)) AND position(bp) > 0 AND position(bp) != -1, over=bp) == 0

and its typeset form:

$$\lvert \{ p \in \mathcal{P} : \left( \mathrm{y}_{p} - \mathrm{y}_{p \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{p \boxplus_{0} 1} - \mathrm{x}_{p} \right) &gt; \left( \mathrm{y}_{p \boxplus_{0} 1} - \mathrm{y}_{p} \right) \cdot \left( \mathrm{x}_{p} - \mathrm{x}_{p \boxminus_{0} 1} \right) \wedge \mathrm{pos}(p) &gt; 0 \wedge \mathrm{pos}(p) \neq \lvert \mathcal{P} \rvert - 1 \} \rvert = 0$$

$$\vee\ \lvert \{ p \in \mathcal{P} : \left( \mathrm{y}_{p} - \mathrm{y}_{p \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{p \boxplus_{0} 1} - \mathrm{x}_{p} \right) &lt; \left( \mathrm{y}_{p \boxplus_{0} 1} - \mathrm{y}_{p} \right) \cdot \left( \mathrm{x}_{p} - \mathrm{x}_{p \boxminus_{0} 1} \right) \wedge \mathrm{pos}(p) &gt; 0 \wedge \mathrm{pos}(p) \neq \lvert \mathcal{P} \rvert - 1 \} \rvert = 0$$

The concave holds:, which is the convex one with the relation flipped:

holds: (y - shift(y, along=bp, offset=1, edge=0)) * (shift(x, along=bp, offset=-1, edge=0) - x) >= (shift(y, along=bp, offset=-1, edge=0) - y) * (x - shift(x, along=bp, offset=1, edge=0))
where: position(bp) > 0 AND position(bp) != -1

What else this changes

spec.expand(*kinds) — 'piecewise', 'sos', or nothing for both, in that order whatever order they are asked in. It returns a plain Spec; _ExpandedSpec is deleted, and resolved plus the record of what a curve derived live on Spec itself. to_yaml() refuses on an expansion that derived parameters, and names typeset as the reader's route. A full expand() reuses the curve expansion the load already cached rather than building and validating it again.

sos: is a formulation — it states <set>_seg, <set>_pick (<= 1), and two linking rows that hold an unpicked member at zero from both sides. Every coefficient is read off the member's own bounds:, so a negative bound and one the data carries are both admitted, and a set's member has to declare bounds.upper. The set carries no coefficient of its own: big_m: is gone, because a number below the member's bound capped a picked member the set does not cap, one above it was a looser row than the bound already states, and a solver taking the set natively ignored it either way — one file meant two models, which is the divergence fluxopt/specsolve#1702 records. method: adjacency is method: sos2 written out.

The typesetter prints what it was handed — a piecewise: block is one line, and typeset no longer expands. The three typeset verbs take --expand, because a shell cannot compose spec.expand(). typeset_declaration finds a derived assumption by name on the unexpanded model too.

Increasing, Curved, AtLeastTwo, Contiguous and Check are gone, and Assumption is Holds.

A how-to shows the two readings. docs/howto/see-an-expansion.md runs one model that states a curve and a set, and puts the construct beside the rows it stands for.

Why

An expansion that happens behind the author is a model they cannot read. Stating it on request makes the two readings one call apart — for review, for teaching, and for the engine that has no concept of a set, which can now refuse the model and name the rewrite instead of performing it (fluxopt/specsolve#1702).

The conditions follow the same thought. A rewrite that is exact only under a condition should say that condition in the language, beside the rows it writes — so a consumer reads one mapping with one kind in it, and a written-out model is not the way a model loses its data contract.

Why a bounded method: convex block was under-checked before this

_curvature_required answered 'either' for every convex block, whatever its links said, and read the sign only under lp. That is not the condition the formulation rests on.

method: convex lets the weights range over the hull the breakpoints span. A bounded link binds from one side of it: under y >= sum(lam * y_bp) the solver drives sum(lam * y_bp) onto the lower boundary of the hull, which is the curve itself only where the curve is convex. So a concave curve under method: convex with a >= link passed every condition the block stated, and the solve read the chord between the first and last breakpoint instead of the curve — wrong, not loose. method: lp states that same boundary as its segment lines and has read the sign since it existed; the two are one relaxation in two spellings.

The answer for a block with two pinned links is unchanged. It is also the only column this PR could have shortened and did not: the disjunction of two counts is what "one bend, either way" needs, and it is now stated only where the direction genuinely cannot be read off the file.

mutation result
convex returns 'either' without reading the sign (the answer before this commit) caught — the two new _CURVATURE_CASES rows fail
the pinned arm deleted, so a pinned block reads a sign it has not got caught — convex-pinned-both-ways-states-a-single-bend, both test_a_curve_prints_what_its_method_assumes_of_the_breakpoints cases, and all three golden files fail
Why this replaces #582 rather than continuing it

#582 forked at main before #566, #585 and #589. Merging the chain into it conflicts in 18 of the 42 files it touches, and three things it is written against no longer exist:

The four commits are re-applied here rather than replayed, and #582 stays open until you close it. The hard rule is never to force-push.

The break
  • big_m: is removed, with no key in its place. The closed schema's own error names the valid keys.
  • A sos: block is refused at load unless each side of its member carries a coefficient: bounds.lower, and bounds.upper or domain: binary. A model whose member is unbounded on a side stops loading.
  • A piecewise: block prints as a curve rather than as its expansion. Every consumer of the typeset output sees different math for the same file.
  • A file that names a declaration an expansion emits is refused at load, and that now includes the four assumption names a block states.
  • Program.sos loses big_m, and Resolved carries piecewise and assumptions.
  • Increasing, Curved, AtLeastTwo, Contiguous and Check are removed from math_spec.program. A consumer matching on them matches Holds instead, and reads description for the sentence it used to get from check_message. The derived names lose their space: cost_curve increasing is cost_curve_increasing, which is a name the collision check now reserves.
  • A method: convex block with a bounded link states convex or concave where it stated either. A curve that bends the wrong way for its link now fails the block's own condition when the data binds.
Why the curvature test is a cross-product, and what the neighbour terms need

The bend at a breakpoint compares the slope behind it with the slope ahead. Written as two quotients it would divide by a run that only the increasing condition rules out, so it is written as a cross-product instead.

A block with two pinned links has to count rather than compare. Which way the weights are driven within the hull is a fact about the rest of the model, so the claim is about the whole axis — one bend in either direction — rather than about a breakpoint. This is what #592's count() and shift() over a predicate were needed for, and it is why this PR sits on that one.

A translation over a variable-free expression needs an explicit edge=; the language refuses it otherwise and says a where: alone does not lift it, because the check is on the expression. So each neighbour term is edge=0 under a where that excludes the vacated row — the third spelling that refusal names.

Gates

pixi is not installed in this environment, so the gates ran from a uv environment on Python 3.12 with the pinned ruff==0.16.1 and pyrefly==1.2.0. That is a departure from the "prefix every command with pixi run" default.

On the head commit:

gate result
pytest -q -n 4 1538 passed, 1 skipped (#592's head: 1467 passed, 1 skipped)
ruff check, ruff format --check clean, 141 files
pyrefly check 0 errors, 10 suppressed
python -m tools.schema, python -m tests.typesetting.golden, the notation and gallery generators regenerated; the schema loses the bound property and nothing else moves
render-tex 31 models rendered
mkdocs build --strict clean, with the docs.python.org inventory dropped for the run, which the proxy refuses with 403
prettier --check clean at 3.8.1 on the two pages the earlier commits changed, which is not the pinned version; not run for the review commit
compile-tex, typos, reuse lint not run — the tools are not installed here. CI runs all three

Every rung of the stack was gated on its own after the review round: 1398 passed at #566, 1426 at #589, 1467 at #592. The review commit here (2361ee6) sits on a merge of #592's review head (8871a42), which merged clean, and was written as two failing tests first: test_a_set_carries_no_coefficient_of_its_own and test_writing_everything_out_reuses_the_curves_the_load_wrote_out.

Coverage, and what was left out
  • The golden fixture carries both sets of coverage: feat(language): a model writes its formulations out on request, and a curve prints as the curve it states #582's three curves (three links under a gate every unit has; a bounded link under a points: mask and a gate only some units have; the hull), plus two method: lp curves — one masked by its own breakpoints, one over a whole axis bounded the other way — which are the only curves that reach the increasing, breakpoints and one-direction curvature arms.
  • test_what_a_curve_assumes_of_its_numbers_rides_on_the_expansion_too is the load-time half of the invariant, and it is stronger than it was: to_program(spec).assumptions == to_program(spec.expand()).assumptions is now structural equality of predicates, so the text a load derives and the text the expansion emits have to agree exactly.
  • test_every_check_has_a_sentence is parametrized over the four suffixes rather than over get_args(Check); test_a_curves_conditions_cannot_collide_with_a_written_assumption asserts the emitted-name collision rather than the retired space convention.
  • test_a_method_names_the_curvature_it_is_exact_for gains a bounded method: convex case each way, and the pinned case keeps its answer under a new id.
  • test_the_declared_bound_is_the_coefficient_rather_than_the_tighter_of_it_and_the_upper is gone with the key; test_a_set_carries_no_coefficient_of_its_own asserts the refusal and says why. The three fixtures that declared bound: 10 declare bounds.upper on the member instead.
  • Walk._derived, _admitted and _parameter are gone with the union: an assumption is one line, whoever stated it.
  • The how-to's seven Python values and six math lines were checked against a real run and a real render, and its YAML fence loads with python -m math_spec check.
  • Left for a follow-up: no file under examples/ declares points:, so the masked curve — the one shape that emits all four conditions and the only one that derives parameters — has no worked example in the gallery. The YAML in the table is from a probe model rather than a committed one.
  • Deliberately not done: the golden fixture gains no bounded method: convex curve. Its hull_curve is pinned both ways and its two lp curves already print the one-line comparison each way, so a fifth curve would print no form the document has not got.
  • For the consumer to decide, not this PR: the predicate compares exactly. lpspec's validate_piecewise_data holds the bend against 1e-9 of the largest slope today, because an exactly collinear curve need not difference to exactly zero, and a cross-product scales as a product of differences rather than as a slope. Whoever runs these predicates over data settles what that tolerance is, and the language states none.
  • Not done, on purpose: the lpspec side (a sink without a construct refuses the model and names spec.expand(), rather than rewriting it behind the author fluxopt/specsolve#1702) is the next change and waits on a release carrying Spec.expand. expand takes no per-kind options, and no inexact rewrite is admitted. The mutation table from feat(language): a model writes its formulations out on request, and a curve prints as the curve it states #582 was measured on its old base and is not restated here, because the guards it names have moved.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CfY3asKSHvHahjNWxdDAER

https://claude.ai/code/session_019tCWoetBmjF1LpbTbjQY29

… curve prints as the curve it states

`Spec.expand()` is public and takes 'piecewise', 'sos', or nothing for both.
`sos:` is the second formulation: it states binaries and the rows that link
them, `method: adjacency` is `method: sos2` written out, and `big_m:` is
renamed `bound:` — the coefficient those rows link a member by, rather than
the tighter of it and the member's own upper bound.

A set is refused at load unless its members start at or above zero and one
finite coefficient links them, so nothing about a set waits for data.

The typesetter prints the model it was handed: a `piecewise:` block is one
line — the links on the locus through its breakpoints — and `typeset(spec)`
no longer expands. `_ExpandedSpec` is gone; `resolved` and the record of what
a curve derived live on `Spec`, and `to_yaml` refuses on an expansion whose
curves derived parameters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc
…ne symbol table spells both readings

A symbol table entry may name what a `piecewise:` or `sos:` block emits, so the
table that renames `<block>_lam` to lambda renders the file it came from too —
the expansion is consulted only where an entry needs it. The three typeset
verbs take `--expand`, because a shell cannot compose `spec.expand()` the way a
caller does.

The notation page shows both readings of every formulation: the curve or the
set as the file states it, and the rows it is written out as, per `method:`.

A linking row leaves out a coefficient of 1, which is the common one — a weight
and a binary are both bounded by 1 — so `lam <= seg + shift(seg, …)` reads as
the literature writes it rather than carrying a factor that multiplies nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc
…iting it out

`lp` and `convex` are exact only for a curve of the right shape, which the
program carries as a check for the consumer holding the data. Nothing said that
`expand()` keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc
…r carried by the data

The expansion writes a second linking row, `x >= lower * admitted`, so an
unpicked member is held at zero from below as well as above. A row multiplies by
its coefficient rather than reading it, so a parameter-valued bound needs no
knowledge of its value and the sign of the lower bound stops mattering.

The load rule is symmetric and shorter for it: each side of a member carries a
coefficient, or the set is refused. A `lower` of 0 writes no row, because the
variable's own bound already states it.

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

Re-applies #582 on the 566 -> 589 -> 592 chain, which supersedes the two
PRs it was blocked on.

#582 was written against the piecewise contract that stood before #589,
so three things it says no longer existed and were rewritten rather than
merged:

- `PiecewiseDeclaration.checks` and `check_message` are gone; a curve's
  conditions stand in `Program.assumptions`. The invariant test that a
  curve's contract survives expansion now reads that mapping, and
  `reading.md`'s paragraph on `checks` is the assumptions section.
- `program.Walk` is `Direction` and `ParameterDefinedNode` is
  `ParameterDefined`, both renamed by #585.
- `assumptions_of` read the expansion record, so a curve's conditions
  vanished from the document once `typeset` stopped expanding. It reads
  the block instead, and names the mask the file wrote rather than the
  column the expansion derives from it: the same breakpoints, under a
  name that exists whether or not the curve has been written out.

The golden fixture carries both sets of coverage: #582's three curves,
and two `method: lp` curves for the assumption arms no other method
reaches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
…guage the file writes

Every condition a `piecewise:` method puts on its numbers was a member of
a closed `Check` union that only this package could build and only
`check_message` could word. Each is now an ordinary predicate:

- `<block>_increasing` — the x-axis rises between neighbouring
  breakpoints.
- `<block>_curvature` — the two slopes at a breakpoint compare the way
  the method needs, as a cross-product so nothing divides by a run. A
  method exact for either bend counts the bends going each way and asks
  that one direction has none, which is what "convex or concave" says of
  a whole axis.
- `<block>_breakpoints` — `count(<mask or x-axis>, over=<dim>) >= 2`.
- `<block>_contiguous` — the mask marks one run.

`expand()` writes them into `assumptions:`, so a model that has been
written out carries its conditions as language; a model that still
declares the block derives the same text at load. The two agree by
construction, which is what
`test_what_a_curve_assumes_of_its_numbers_rides_on_the_expansion_too`
now pins: the mappings are equal, not merely both non-empty.

A translation over data needs an explicit `edge=`, so the neighbour
terms are `edge=0` under a `where` that excludes the vacated row — the
third spelling the language's own refusal names.

`Increasing`, `Curved`, `AtLeastTwo`, `Contiguous` and `Check` are gone;
`Assumption` is `Holds`, which gains the `description` a refusal quotes
and which an `assumptions:` entry could already carry. `Walk._derived`,
`_admitted` and `_parameter` go with them: an assumption is one line,
whoever stated it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
The reference says what `expand()` accepts, and `howto/print.md` uses
`--expand` to produce a document. Neither answers the task a reader
arrives with: show me the rows this block stands for.

The page runs one model that states both constructs. Tabs carry the two
places a comparison is the point — Python beside the command line, and
the construct as the file states it beside the rows it states.

Every claim is measured rather than written: the seven Python values are
the real ones, and all six math lines are taken from a render of the
model on the page. The page's own YAML fence loads with
`python -m math_spec check`.

Prose, by the docs-writing skill's measure: n 28, avg 13.2, median 13,
over25 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
…d/582-onto-592

Carries main at 0.0.0-alpha.110 to the top of the stack. Merged clean at
every rung, and the suite is green here: 1516 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
@FBumann
FBumann added this pull request to stack #601 September 21, 2026 19:58
…d/582-onto-592

#589 grew `feat(language): a refusal quotes the assumption's description`
while this branch was building, and it lands on the same two things this
branch changed: `Holds.description` and `assumption_message`.

Both sides added the field. They differ on what the refusal does with it:

- there, the description **trails** the generic sentence, so a failure
  names the columns and then says why the rule is there;
- here, it **replaced** the sentence, so a curve's condition read as its
  own prose and the columns were lost.

Resolved to the trailing form. It loses nothing either side had — a
derived condition still says everything it said, with the columns in
front of it — and replacing would have reverted a decision taken on the
rung below. The `Check` arms of their `assumption_message` go, because
this branch retires that union; what they said now rides on each entry's
`description:`.

`test_every_check_has_a_sentence` asserted the message *starts* with the
method's sentence. It now asserts both halves: the columns lead, and the
method's sentence trails.

`reading.md` keeps their `cost_is_never_negative` entry, which is what
shows a file-written description, and its two message values are
recomputed rather than merged: both now carry the columns and the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
A missing parameter row is not absence — it reads as a zero. So an
undeclared breakpoint sat the curve on the origin, and the conditions a
`piecewise:` method stated disagreed about what a missing row meant:

- `<block>_breakpoints` counts `count(x, over=bp)`, a definedness test,
  so three rows of five cleared `>= 2`;
- `<block>_increasing` is arithmetic, so the same two rows read as 0 and
  the condition failed.

A ragged unmasked curve therefore passed the breakpoint count and failed
the increasing test, and the refusal said "requires strictly increasing
breakpoints" about data that increases on every row it has. The
actionable fix — declare `points:` — was named nowhere.

`<block>_complete` states it instead: every values parameter has a row
at every breakpoint the curve runs through, under the mask where one is
declared. Its sentence names `points:`. Every method states it, because
the origin-reading is the expansion's rather than any one method's: a
`method: adjacency` block now states this one condition and still
nothing about the shape.

Also in here, because the same reading found them:

- `typeset_declaration` looked a derived condition up in
  `schema.assumptions`, which holds what the file wrote, so the document
  printed a line no caller could ask for until `expand()` ran. Both
  lookups read `resolved.assumptions`, where the document reads them.
  `test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_is_written_out`
  fails with the guard reverted, on a SchemaError naming the curve.
- `examples/piecewise_ragged.yaml` is the first model in the tree to
  declare `points:`, so the masked path — the only shape stating all
  five conditions, and the only one deriving parameters — is now
  rendered and compiled by the gates rather than probed by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
@FBumann FBumann added this to the Conditional piecewise curves milestone Sep 22, 2026
…that side's shape

`method: convex` answered `'either'` whatever its links said, so a curve
bounded on one side was held only to "one bend, either way". That is not
the condition the formulation rests on. The weights range over the hull,
and a bounded link binds from one side of it: `>=` reaches the lower
boundary, which is the curve itself only where the curve is convex. A
concave curve under `method: convex` with a `>=` link therefore passed
every condition the block stated, and the solve read the chord between
the first and last breakpoint instead of the curve — wrong, not loose.

`method: lp` states that same boundary as its segment lines and has read
the sign since it existed. The two are one relaxation in two spellings,
so they now read the sign the same way and state the same shape.

With both links pinned nothing in the block says which way the weights
are driven, so `'either'` stays: a mixed curve is wrong whichever way
the pressure runs, and a single bend is exact one of the two ways.

For the reader, the long `<block>_curvature` cell — two counts and eight
shifts, joined by `OR` — is now what a block with two pinned links
states. A bounded one states the single comparison `lp` states.

`_CURVATURE_CASES` gains the two bounded convex cases; both fail on the
tree before this change. The convex case that stays keeps its answer
under a new id, since "cuts the corners of a mixed curve" was the
reading this commit drops.

Swept with it: `Curvature`'s comment pointed at `program.Curved`, which
this branch deleted, and `assumptions.md` still spelled the derived
names with a space.

docs/reference/language/piecewise.md, the sentences the paragraph adds:
n 7 avg 14.7 median 15 over25 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfY3asKSHvHahjNWxdDAER
…v-operators' into claude/practical-allen-a6jdak
…ds, and bound: is gone

bound: replaced the member's upper bound in the row a set expands to. Below
that bound the row capped a picked member the set does not cap; above it
the row was a looser big-M; a solver taking the set natively ignored it
either way, so one file meant two models. The coefficient is the member's
own bounds.upper, which a set's member now has to declare. expand() reuses
the curve expansion the load cached rather than building it again.

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

`ruff`'s TC001 refused it, and CI is the gate that said so: the import is
read only by `asked: list[Spec]`, and the module already imports
annotations from `__future__`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQHKbwGtpVBm6yKHzrjMx9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spec.expand() lowers a formulation on request, the typesetter prints the block it was handed, and sos: becomes a formulation

2 participants