Skip to content

feat(program): a model is a document another language can read, so a second consumer needs no second parser - #247

Closed
FBumann wants to merge 16 commits into
mainfrom
claude/the-waist-as-a-value
Closed

FBumann wants to merge 16 commits into
mainfrom
claude/the-waist-as-a-value

Conversation

@FBumann

@FBumann FBumann commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "Please add a pr that is visionary regarding the best and most capable api which stays safe and predictable."

Note

The following content was generated by AI.

What this changes

Two verbs, and the API stops being Python-shaped.

document = ms.to_json('dispatch.yaml')  # or a Spec, or a Program you already hold
ms.from_json(document) == ms.to_program('dispatch.yaml')  # True

A consumer written in Python imports Program and is done. A consumer written in anything else has had exactly one route to the same model: implement the language a second time — a second parser, a second name resolution, a second dim algebra. That is the one thing hard rule 1 exists to forbid, and shipping the waist only as importable Python quietly guaranteed it.

Everything in a program is already resolved, so the document carries no grammar, no expression strings and nothing to parse. A reader dispatches on a tag and builds a node.

Why this and not another verb

The companion page in #246 argues that capability here is measured on the far side — what a second consumer can build, not what a caller can call — and names this as the largest capability the API can gain, precisely because it is not a feature. One serialisation, and every non-Python consumer becomes possible without a line of language reimplemented. Nothing else on any list multiplies like that, and it adds nothing a caller must learn.

This PR is that argument cashed. It also settles the decision I flagged as needing to be made before there are readers, not after: what a consumer does with a document it does not understand.

Safe and predictable, concretely

Reading is closed. A tag names a class in a registry built by reflection over the two modules that define nodes. Nothing is imported, evaluated, or constructed by name from the document — a hostile document can name a node that does not exist and get an error, and cannot name anything else at all.

Refusal is by name, never by guess.

this document names a node 'Sumk', which is not one this release has. Did you mean 'Sum'?

this is a program document at wire version 1, and this release reads 0. The wire version
moves when the encoding changes, so a document from a newer release is not one to guess at
— read it with the release that wrote it.

WIRE_VERSION is the format's own and moves when the encoding changes, not when the language does — the two version things that would otherwise be conflated.

The encoding is reflective, so it cannot go stale. A node added to the program is serialisable the day it exists, with no table to update. And tests/test_program_nodes.py already refuses a node no file lowers to, so the round trip covers every node by construction rather than by a list somebody keeps in step.

It is exact, not approximate. Round-tripped against the every-node fixture and every model in examples/ — equality on the Program, field for field, because a consumer trusting this document instead of the language has nothing to compare a subtly different field against.

The landmine, which is not exotic

allow_nan=False, and it costs something real. An unbounded variable's bound is -inf — so every model with a one-sided bound contains a non-finite float, and the obvious encoding writes Infinity, which is Python's extension and not JSON. A reader in a language with a strict parser would have stopped on the first realistic model.

Non-finite floats are written {"$": "float", "value": "-inf"}, and there is a test per shipped model asserting no Infinity or NaN appears and that json.loads accepts it.

What lpspec removes or changes

Nothing today, and something worth having tomorrow.

Nothing, because lpspec imports math_spec and reads the Program directly; this adds a route it does not need.

Worth having, because the two lanes are differentially tested against each other and both consume the same resolved AST — which ceiling.md already names as the gap: "a shared misreading passes the differential suite green." A document is what lets a third consumer, in another language, be compared against both. The examples/ports/ corpus exists for that class of bug; this is what would let it be checked from outside Python.

It is also what a Julia or Rust reader needs before it can exist at all, which is the adoption question rather than a code one.

Verified

Toolchain reconstructed at the versions pixi.toml pins: ruff format --check / ruff check (0.16.1, pinned) clean; pyrefly (1.2.0, pinned) 0 errors; pytest -q 885 passed, 0 skipped (858 on the base — 27 new). reuse, typos, prettier --check, render_tex (27 models) and mkdocs build --strict were run on the top of the stack (#250), which contains these commits.

The new reading.md section is executed, not just written: tests/test_reading_page.py runs every Python block on that page against a real model and checks every # value line. The round-trip claim there is one of them, which is why the page's claim count moved from 7 to 8.

Not run: zizmor, taplo (neither's files changed), and the tectonic half of compile-tex.

Mutation table

Each guard broken in turn on a clean tree, restored with git checkout --:

Mutation Result
non-finite floats written raw, so Infinity leaks 20 failed
the wire version is never checked 1 failed
an unknown tag is silently ignored 15 failed
arrays decode as lists rather than tuples 13 failed
restored all green

The last one is the reason the round trip asserts equality rather than resemblance: a list where a tuple belongs compares unequal and changes nothing you would see by reading a document.

Deliberately not done

No conformance corpus shipped as a fixture directory. The round-trip tests are the corpus, and committing generated documents beside them would be a second copy to regenerate — better once there is an external consumer to hand them to, which is when their shape will be known.

No schema for the document. A JSON Schema of the wire format is derivable from the same reflection and would be the natural next thing for a non-Python reader; it is not needed to write one, and guessing at its shape before a reader exists is what this repository calls YAGNI.

WIRE_VERSION is 0 and the language version is untouched. They answer different questions and this PR deliberately does not couple them.

Changed in review. _registry() is @cached and _decode no longer threads it through every recursive call — the reflection is per-release, not per-node, and the parameter was six mentions buying nothing. The one thing that did not survive the same pass: folding _encode's inline isinstance(value, tuple) and hasattr(value, '_fields') into _is_named_tuple loses the narrowing pyrefly needs to accept value._fields, so the apparent duplication is earning its keep and stays.

Stack: sixth, on #246 → #245 → #244 → #243 → #242 → #168.


Generated by Claude Code

FBumann and others added 3 commits August 28, 2026 14:53
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…no two regions claiming one coordinate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`examples/commitment.yaml` is the formulation #2 factors. The state a
unit carries into a snapshot has three regimes — a unit that is never
off, the first snapshot, and every later one — and writing them at the
constraint would fork `ramp_up` three ways. With the regimes named once,
the inequality is written once.

`tools/render_tex.py` picks it up like every other model, so the LaTeX
gate compiles it, and it gets a page in the example gallery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FabianHofmann and others added 13 commits August 30, 2026 12:50
Resolved import-block conflicts in lowering, program and test_lowering.
boundedness now walks program nodes, so the Cases arm is ported from
CasesNode to program.Cases. `_lower_where` is `resolution.where_of` on
main, which folds an always-true mask to None, so the exclusivity tests
build their masks the way validation does, unfolded.
…outside the cases (#251)

The value wherever no `when` holds moves out of `cases:` and becomes the
block's own `otherwise:`, so no name inside `cases:` is reserved and the
cases carry no order. Five hand-written errors policing `default` are
replaced by the closed schema's own: a required `otherwise:`, a required
`when:` on every case, and `min_length=1` on `cases:`.

The fallback's dims are checked where the cases' are, which the loop over
`cases:` used to give it for free.
… is set as one the solver decides (#252)

* fix(typesetting): a cased expression whose otherwise holds a variable is set as one the solver decides

Reading only `cases:` for what a block is left the fallback out, so a
quantity whose only variable sits there printed upright — the notation
for something the model is handed rather than something it solves for.
`previous_status` in the commitment example is exactly that shape, and
its generated page disagreed with the hand-written one in the language
reference.

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

* fix(language): an error inside a case names the expression that declares it, and is printed once

A cased expression is expanded where its name stood, so a fault in one
of its arms was reported against the constraint that pulled it in — as
`Constraint 'ramp_up', case 'boundary'`, a case on a constraint that has
none, once per constraint naming the expression. The arm now names its
declaration, and the arm with no `when` is named `otherwise` rather than
a case, which is what every other check already calls it. Two errors
that differ at all carry different contexts, so an exact repeat is one
fault seen twice and is printed once.

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

* fix(language): two integer bands with no integer between them are proved apart

The cells for an ordered subject put a representative in every gap
between the literals a pair of masks names, and the midpoint it chose
was one no integer can be. So `n < 1` against `n > 0` on an `int` was
refused with a witness of `n is 0.5` — a coordinate the data cannot
produce, naming a rewrite the file had already made. A date was already
handled this way; an integer is discrete for the same reason and now
says so.

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

* test(exclusivity): the soundness fuzz draws two masks apart rather than one and its negation

Every pair it generated was a complement by construction, so the grid
assertion was `X and not X` — false at every point, under every
implementation, for every set of cells. Collapsing each subject to a
single cell, which is the most a cell-coverage bug could ever be, left
both seeds green while nine other tests in the file went red. Drawn
independently and filtered to the pairs the check proves apart, the same
mutation now fails it 1295 times over.

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

* chore: drop an unreachable lookup guard and the default spelling the otherwise rename left behind

`_lower_expression` looked its name up in the very mapping its only
caller iterates, so the `KeyError` and its `did_you_mean` were a branch
nothing reaches — and the one place in the file raising something other
than `LanguageError`. Deleting it leaves the suite green, which is what
the guard was worth.

Beside it, five docstrings and a test name still called the fallback
`default`, which the language no longer has.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…o whichever engine reads the data

Three facts decide which model a file and a table make together — where a
dimension's members come from, the order they stand in, and that a coordinate
appears at most once. Each was real and each was written down in a consumer, so
a second consumer would have inherited none of them.

Co-Authored-By: Claude <noreply@anthropic.com>
…oordinate, so a row lost in preparation is not read as a mask

A table short of a coordinate and a table that never had one are identical in
the data and mean opposite things. `coverage:` says which was meant, and a
parameter declaring itself a mask is refused as a bound or a divisor — the two
positions where absence has no reading — before any data arrives.

Co-Authored-By: Claude <noreply@anthropic.com>
…library is templates rather than generated YAML

merge takes the fragments and hands back one mapping to validate, resolve and
lower exactly once. A fragment is merged before it is validated, so a template
may name what a sibling declares without being a model on its own; the
coordinate space is shared where fragments agree, the math is owned, and the
objectives are summed.

Co-Authored-By: Claude <noreply@anthropic.com>
…g being a path rather than a second convention

merge read a string as YAML text where to_spec reads one as a path, so the
front door and the composition disagreed about what a caller had handed over.
A composed model is also held to round-tripping through to_yaml and printing
in all three formats: composing may not reach a model no reviewer can open.

Co-Authored-By: Claude <noreply@anthropic.com>
…one document they print

The gallery showed one model per page, which a composition has none of. It now
shows each fragment as written and then the document the merge prints, so the
claim a reader checks is that four small files and one model are the same
thing — and that the balance is the row it was before storage was added.

Co-Authored-By: Claude <noreply@anthropic.com>
…k writes them

The new files were the only YAML in the stack the formatter had not already
written, and the check that missed it globbed markdown alone where the hook
globs md, yml and yaml alike.

Co-Authored-By: Claude <noreply@anthropic.com>
…are not models and say so

render_tex renders every file under examples/, and a fragment naming what a
sibling declares is a load error alone — which is the thing examples/composed/
exists to show. The model they make is rendered by the gallery and typeset in
all three formats by the merge tests, so nothing goes unrendered.

Co-Authored-By: Claude <noreply@anthropic.com>
… so a port nobody wired is not a term that vanishes

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

Co-Authored-By: Claude <noreply@anthropic.com>
…ge's surface

The language has a test for what belongs in it and the ceiling has one for what
may enter it; the API had neither, which is how a small surface grows a verb per
feature. A verb decides nothing the language has not stated and needs nothing
but the file, and the three properties it keeps are pure, total at load, and
closed under composition.

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

Co-Authored-By: Claude <noreply@anthropic.com>
…second consumer needs no second parser

A consumer in Python imports the program; one in anything else had a single
route to the same model, which was implementing the language again. to_json and
from_json make the waist a value instead: everything in a program is resolved,
so the document carries no grammar and a reader dispatches on a tag.

The encoding is reflective, so a node added to the program is serialisable the
day it exists, and the node fence already refuses a node no file reaches — the
round trip therefore covers every node by construction. Reading is closed: a
tag names a class in a registry or the document is refused, and a wire version
this release does not know is refused by number rather than guessed at.

Strict JSON, which is not free: an unbounded variable's bound is an infinity,
so every model with a one-sided bound would otherwise have written Infinity and
stopped a conforming reader.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann

FBumann commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Not in scope for now

@FBumann FBumann closed this Aug 31, 2026
@FBumann FBumann reopened this Aug 31, 2026
@FBumann FBumann closed this Aug 31, 2026
FBumann added a commit that referenced this pull request Aug 31, 2026
…at a time, before a driver cuts one

A rolling horizon or a myopic pathway asks one thing of a model before it
starts: would windowing change the answer? separability reports the overlap two
windows need and, where none would do, names each declaration and the construct
tying the axis together. A reduction means opposite things by position — in a
constraint a sum ties every window to every other, in the objective it is
additively separable.

Squashed from the four commits of claude/separable-along and rebased onto main
alone, dropping the closed #247 serialisation base it was stacked on.

Co-Authored-By: Claude <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 31, 2026
…bproblem is a call rather than a second file

A myopic pathway, a rolling horizon and a Benders subproblem share one move: a
variable stops being a decision and becomes a number somebody else chose. fix
takes every name in one call and validates once at the end. Two translations are
decisions, not copies: a where-masked variable becomes coverage: masked, and a
binary or integer variable becomes an int parameter — never a float, never a
bool that would read as a mask.

Rebased onto the parameter-coverage branch (#243) it needs for Coverage, off the
closed #247/#248 stack it was authored on; only the fix additions are kept.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann FBumann reopened this Aug 31, 2026
@FBumann
FBumann changed the base branch from claude/what-counts-as-a-verb to main August 31, 2026 20:57
@FBumann FBumann closed this Aug 31, 2026
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours (#374)

* feat(program): a model says whether a horizon may be solved a window at a time, before a driver cuts one

A rolling horizon or a myopic pathway asks one thing of a model before it
starts: would windowing change the answer? separability reports the overlap two
windows need and, where none would do, names each declaration and the construct
tying the axis together. A reduction means opposite things by position — in a
constraint a sum ties every window to every other, in the objective it is
additively separable.

Squashed from the four commits of claude/separable-along and rebased onto main
alone, dropping the closed #247 serialisation base it was stacked on.

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

* feat(program): a model says whether an axis may be built a window at a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

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

* refactor(program): an undecided reach says what it stands as, and the verdict folds a parameter's values in

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

* refactor(program): a verdict reports only what a window reads ahead, and nothing reads it for a scenario sweep

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann
FBumann deleted the claude/the-waist-as-a-value branch September 9, 2026 06:45
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.

3 participants