feat(language): a model writes its formulations out on request, and states what each assumes of its data - #602
Merged
FBumann merged 14 commits intoSep 22, 2026
Conversation
… 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
Documentation build overview
29 files changed ·
|
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
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
…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
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.
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 apiecewise: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
mainat0.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, andexpand()writes each one intoassumptions:verbatim as below. Each column is one condition, read top to bottom.Read with: breakpoints
x(the input axis) andy(the output), over dimensionbp.on_curveis thedtype: boolparameterpoints:names, marking which breakpoints the curve actually runs through.<block>_increasing<block>_curvature, convex<block>_curvature, concave<block>_curvature, either way<block>_breakpoints<block>_contiguousconvex,lpconvexorlp, link bounded>=convexorlp, link bounded<=convex, both links pinnedlppoints: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))>=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 thewhere:below itcount(x, over=bp) >= 2count(on_curve AND NOT shift(on_curve, along=bp, offset=1), over=bp) == 1where:position(bp) > 0position(bp) > 0 AND position(bp) != -1\boxminus_{0}is the translation with a zero fill — one breakpoint back, the vacated row excluded by thewhere:.adjacencyandsos2state 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. Eachwhere:narrows to the admitted neighbours —on_curve AND shift(on_curve, along=bp, offset=1)for a segment, the same withoffset=-1added for a bend — the breakpoint count readscount(on_curve, over=bp) >= 2, and the either-way column carrieson_curveinside its counts rather thanposition(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.
lpstates one side of the curve as its segment lines andconvexrelaxes 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: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 amethod: convexblock with both links pinned states:and its typeset form:
The concave
holds:, which is the convex one with the relation flipped: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 plainSpec;_ExpandedSpecis deleted, andresolvedplus the record of what a curve derived live onSpecitself.to_yaml()refuses on an expansion that derived parameters, and namestypesetas the reader's route. A fullexpand()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 ownbounds:, so a negative bound and one the data carries are both admitted, and a set's member has to declarebounds.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: adjacencyismethod: sos2written out.The typesetter prints what it was handed — a
piecewise:block is one line, andtypesetno longer expands. The three typeset verbs take--expand, because a shell cannot composespec.expand().typeset_declarationfinds a derived assumption by name on the unexpanded model too.Increasing,Curved,AtLeastTwo,ContiguousandCheckare gone, andAssumptionisHolds.A how-to shows the two readings.
docs/howto/see-an-expansion.mdruns 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: convexblock was under-checked before this_curvature_requiredanswered'either'for everyconvexblock, whatever its links said, and read the sign only underlp. That is not the condition the formulation rests on.method: convexlets the weights range over the hull the breakpoints span. A bounded link binds from one side of it: undery >= sum(lam * y_bp)the solver drivessum(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 undermethod: convexwith 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: lpstates 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.
convexreturns'either'without reading the sign (the answer before this commit)_CURVATURE_CASESrows failconvex-pinned-both-ways-states-a-single-bend, bothtest_a_curve_prints_what_its_method_assumes_of_the_breakpointscases, and all three golden files failWhy this replaces #582 rather than continuing it
#582 forked at
mainbefore #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:PiecewiseDeclaration.checksandcheck_message. A curve's conditions stand inProgram.assumptionssince feat(language): a model declares what it assumes of its data #589.reading.md's paragraph onchecksis the assumptions section, and the invariant test reads that mapping.program.WalkisDirection, andParameterDefinedNodeisParameterDefined— both refactor(program): a program names its nodes by the naming rule and its groups as the file does #585's renames.assumptions_ofread the expansion record, so a curve's conditions vanished from the document the momenttypesetstopped expanding. It reads the block instead.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.sos:block is refused at load unless each side of its member carries a coefficient:bounds.lower, andbounds.upperordomain: binary. A model whose member is unbounded on a side stops loading.piecewise:block prints as a curve rather than as its expansion. Every consumer of the typeset output sees different math for the same file.Program.soslosesbig_m, andResolvedcarriespiecewiseandassumptions.Increasing,Curved,AtLeastTwo,ContiguousandCheckare removed frommath_spec.program. A consumer matching on them matchesHoldsinstead, and readsdescriptionfor the sentence it used to get fromcheck_message. The derived names lose their space:cost_curve increasingiscost_curve_increasing, which is a name the collision check now reserves.method: convexblock with a bounded link statesconvexorconcavewhere it statedeither. 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()andshift()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 awhere:alone does not lift it, because the check is on the expression. So each neighbour term isedge=0under awherethat excludes the vacated row — the third spelling that refusal names.Gates
pixiis not installed in this environment, so the gates ran from auvenvironment on Python 3.12 with the pinnedruff==0.16.1andpyrefly==1.2.0. That is a departure from the "prefix every command withpixi run" default.On the head commit:
pytest -q -n 4ruff check,ruff format --checkpyrefly checkpython -m tools.schema,python -m tests.typesetting.golden, the notation and gallery generatorsboundproperty and nothing else movesrender-texmkdocs build --strictdocs.python.orginventory dropped for the run, which the proxy refuses with 403prettier --checkcompile-tex,typos,reuse lintEvery 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_ownandtest_writing_everything_out_reuses_the_curves_the_load_wrote_out.Coverage, and what was left out
points:mask and a gate only some units have; the hull), plus twomethod: lpcurves — 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_toois the load-time half of the invariant, and it is stronger than it was:to_program(spec).assumptions == to_program(spec.expand()).assumptionsis 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_sentenceis parametrized over the four suffixes rather than overget_args(Check);test_a_curves_conditions_cannot_collide_with_a_written_assumptionasserts the emitted-name collision rather than the retired space convention.test_a_method_names_the_curvature_it_is_exact_forgains a boundedmethod: convexcase 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_upperis gone with the key;test_a_set_carries_no_coefficient_of_its_ownasserts the refusal and says why. The three fixtures that declaredbound: 10declarebounds.upperon the member instead.Walk._derived,_admittedand_parameterare gone with the union: an assumption is one line, whoever stated it.python -m math_spec check.examples/declarespoints:, 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.method: convexcurve. Itshull_curveis pinned both ways and its twolpcurves already print the one-line comparison each way, so a fifth curve would print no form the document has not got.validate_piecewise_dataholds the bend against1e-9of 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.spec.expand(), rather than rewriting it behind the author fluxopt/specsolve#1702) is the next change and waits on a release carryingSpec.expand.expandtakes 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