Conversation
Documentation build overview
42 files changed ·
|
8adfdfb to
c9187dc
Compare
c9187dc to
5fce291
Compare
5fce291 to
0750fbf
Compare
14a0587 to
ce98ce9
Compare
ce98ce9 to
454cf1f
Compare
ea759fb to
5e5a05e
Compare
5e5a05e to
3508834
Compare
|
Heads-up: I pushed a behaviour change to this branch, not just a rebase, and it should be reviewed as one rather than inherited. WhyThis branch is now rebased onto The rebase failed that guard immediately: What changed
The line it draws, and the one to argue with if you disagree. Only a case's value is scanned, never its Two of this branch's own tests moved to Checks461 passed, Revert the top commit if you would rather decide this differently — the branch is red against Generated by Claude Code |
d8c68bb to
7033bf4
Compare
`expressions:` takes `cases:` — one quantity whose value varies by
region — with `foreach:` naming the frame they partition:
expressions:
previous_status:
foreach: [snapshot, generator]
cases:
always_on: {when: "not committable", expression: 1}
boundary: {when: "committable and position(snapshot) == 0",
expression: status_initial}
interior: {when: "committable and position(snapshot) > 0",
expression: shift(status, over=snapshot, offset=1)}
Three independent regimes multiply into eight constraint cases and add
into seven expression cases, and the inequality using them is written
once rather than eight times.
The cases must partition the frame, and it is a load error when they do
not — decided before any data binds, with the overlap or the gap named
and a witness for it. Disjoint, because two values at one coordinate is
not a quantity; total, because a gap would leave the expression
undefined and absence spreads, so every constraint referencing it would
lose rows it never masked. Totality is what keeps a constraint's rows
readable at the constraint.
`when:` rather than `where:`: a case selects a value, creating no absence
and deleting no row, which is what `where` means on every other block.
`foreach:` is required with cases and refused without. An uncased
expression's dims fall out of its body; a cased one's cannot, since a
case may be a scalar where its `when` is not. Each `when` is held to the
frame by the same check, in the same words, that holds a variable's or a
constraint's mask to its.
Referencing a cased expression is a load error naming why: substitution
puts one body where the name stood, and what a reference should expand
to — a core node carrying the cases, or a constraint whose rows are
built per region — is the open half of #2. Checked, not guessed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
A reference to a `cases:` expression expanded to a load error: substitution
puts one body where the name stood, and the quantity has one per region. That
made the declaration checkable and useless — nothing could read it.
The reference now expands to `CasesNode`, a core arithmetic node carrying every
arm. The partition proved on the declaration is what makes that a value rather
than a choice: exactly one arm applies at each coordinate, decided without
data, so a consumer selects per row the way a `where` already filters.
Every dispatcher gains the arm — expansion, resolution, dims, degree,
boundedness, the typeset walk — and the format seam gains `cases()`, spelled
`\begin{cases}` in LaTeX, `cases()` in Typst, and `\cr` rather than `\\` in
Markdown, whose escape pass eats one of the two backslashes before MathJax
sees them.
The dims of a reference are the declared `foreach`, not the union of the arms:
an arm narrower than the frame broadcasts, exactly as a parameter with fewer
dims does.
The notation page gains the block it never showed — a named expression prints
nothing under its own name, so `cases:` is only readable beside the
declaration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
`examples/commitment.yaml` is the formulation #2 factors: three regimes for the state a unit carries into a snapshot, so `ramp_up` holds a running unit to `ramp_limit` and a starting one to `start_up_limit` in one inequality instead of three. It is picked up by `tools/render_tex.py` like every other model, so the LaTeX gate compiles it. Writing it found a bug in the documented example: `expression: 1` is how a constant case is spelled, YAML reads it as an int, and the annotation took only a string — so the block in `expressions.md` did not load. Nothing caught it because no test loads the page's snippets. A number is now accepted wherever an expression string is declared, and the checked-in JSON schema says so, since a `mode='before'` rewrite is otherwise invisible to it. Booleans still fail: `true` is not arithmetic, and an error naming the type reads better than one naming `'True'`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
`examples/commitment.yaml` arrives with this change, so its gallery page does too: the file verbatim, then the document the typesetter prints from it. The page is the fourth in `docs/examples/`, whose generator and currency test came in with the section itself. What it prints today is the block inlined at each use, twice in one row of `ramp_up` — which is the argument for the rendering change stacked on top of this, made in the place a reader meets it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
Four cleanups, no behaviour change beyond where an error is raised. A cased expression's `foreach` naming a declared dimension was a bespoke check in `dimensions.py`, with wording of its own. `Model` already holds that check for parameters, variables and constraints, keyed off `referenced_dims`, so the block joins that table and the message is the one every other declaration gets — raised at load rather than in `check_schema`. `MarkdownFormat.cases` rewrote the string `LatexFormat.cases` had just built, matching on ` \\ ` to swap in ` \cr `. A change to the spelling on the LaTeX side would have gone quiet rather than failing, and MathJax would have lost the row break with nothing to say so. Both now call one builder that takes the separator. `partition.py`'s docstring still said the module was not wired into the schema. It has been since the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
Inlining the block where its name stood is what the AST does, and it is the wrong thing to print. A three-arm block is three rows tall, so whatever follows it in the equation sits beside the middle arm and reads as part of that arm's condition. `examples/commitment.yaml` is the worst case and it is not contrived: `ramp_up` names the quantity twice, so one row carried the same three arms twice over. And a quantity written once in the file was written once per use on the page, which is the opposite of what naming it was for. So a use prints the symbol and the block prints under a `Definitions` section, which is how a paper states a quantity defined by region. Nothing about expansion changes — the AST still inlines, and `CasesNode` already carries the name and the frame the walk needs. Cased expressions join the symbol pool, so they derive a symbol like any other name and `--symbols` can rename one. Uncased ones stay out: they print nothing under their own name, and a table entry that never applies is the silent typo the table is strict to avoid. `definitions()` runs after the sections that use it, since what lands there is what they reached; an arm may name another cased expression, so it runs to a fixpoint. An expression nobody names prints nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
`walk.definitions()` prints what the other sections reached, so it has to run after them. It sat inside the section list, where that dependency held only because Python evaluates the tuple assignment above it first — invisible to anyone tidying the list back into inline calls, and silent when broken: the section would simply come out empty. It is a statement of its own now, after the three it depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE
Rebasing onto `main` brought the convention's whole-page guard with it, and it
failed here at once: `startup_cost` printed italic, and both of its arms are
parameters. Upright is what the model is given, and a quantity written in two
regions is still given when every region is — the number of regions is a
statement about the frame, not about who decides the value.
So `typeset` works out which cased expressions reach a variable, which it can
do and `Symbols` cannot: it holds the namespace an arm has to be resolved
against. The walk over the resolved arm answers for the whole chain, since an
arm naming another cased expression resolves to that one's arms.
startup_cost arms: cost * p_max, cost → upright, the model is given it
committed_power arms: p, 0 → italic, the solver decides it
The fixture gains the second, which it had no case for: with only the first,
the page showed the rule holding in one direction and nothing testing the
other. Two of this branch's own tests move to `upright` with it — `CASED`'s
`headroom` is a parameter in every arm — and one of them now counts the
*indexed* symbol, because Typst spells a row label and an upright symbol the
same way and only the symbol carries the dims.
The two halves of a case say different things. The value decides *what the quantity is*; the mask decides *which region applies*. A variable in a mask is asking whether that variable exists at a coordinate — which the model settles when it is built, not something a solver returns — so a cased expression whose every arm is a parameter is data however its regions are cut. That is what `_reaching_a_variable` already does, walking `case.expression` and not `case.when`. Worth a test rather than a reading of the code: the obvious "fix" for someone who meets this later is to scan both, and it would make a pure-data quantity print as one the solver decides.
7033bf4 to
35d62ac
Compare
|
Superseded by #70, which lands the same feature — The difference. Here the arms are an unordered set required to be a proved partition: pairwise disjoint and jointly exhaustive, decided before any data binds by #33's decision procedure, with the overlap or the gap named and a witness for it. In #70 the arms are ordered, first match wins, and the last one carries no That gives the same two guarantees — exactly one value per coordinate, and a value at every one — by making the bad states unrepresentable rather than diagnosed. Two arms cannot disagree, because only the first to match is read; no coordinate is left without a value, because the fallback has no condition to fail. Both are properties of the shape of the block, so nothing has to decide what a predicate could be true of. What that removes:
What it costs, stated plainly: a fully shadowed arm is now silently dead rather than a load error, and the arms are read top-down instead of as an unordered set. Two other things carried across, changed. #70 is also based on current The number coercion ( |
…rather than the first written winning The middle ground between #70 and #36, decided in #70. Each case's `when` is proved disjoint from every other's before any data binds, so the arms carry no order: each says where it applies on its own terms, and a reader checks one without the ones above it in mind. The fallback stays what #70 made it — the last case, carrying no `when` — so a value *everywhere* is still the block's shape rather than a second proof, and the complement no longer has to be written out as #36 required. `exclusivity.py` is #33's decision procedure with the exhaustiveness and dead-case halves cut, and the frame built per pair rather than over the whole case set: `when_i AND when_j` unsatisfiable, decided by cutting each subject into cells every atom over it is constant on. Independence between subjects over-approximates, so a spurious world can manufacture a witness but never hide one. A pair it cannot decide is refused the way a proven overlap is, and the message names the rewrite. The unit commitment example and the golden model pay the price the rule asks for: `boundary` says `committable and position(snapshot) == 0`, and the golden model's `winter` arm says `position(snapshot) > 0 and`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The feature from #2, stacked on #33 (which is stacked on #31) — base is
claude/constraint-cases. Retarget down the stack as each lands. #37 has been folded back in: it fixed a defect this PR introduced and rewrote nine of the files this PR writes, so the two are one change.Implements @FabianHofmann's proposal, with the decisions taken in #2.
What this adds
expressions:takescases:, withforeach:naming the frame they partition:Three independent regimes multiply into eight constraint cases and add into seven expression cases — and the inequality using them is written once instead of eight times, so changing it is one edit rather than eight that nothing checks agree.
The rules it enforces
The cases must partition the frame, and it is a load error when they do not — decided before any data binds by #33's procedure, with the overlap or the gap named and a witness for it:
Disjoint, because two values at one coordinate is not a quantity. Total, because a gap would leave the expression undefined there and absence spreads — every constraint referencing it would lose rows it never masked, which is what
diagnostics().omissionsexists to catch. Totality is what keeps a constraint's row set readable at the constraint.Being total is not free, and the tests show the price: with no mask to narrow the frame, the cases have to say what an absent parameter or an unnamed label gets.
when:, notwhere:. A case selects which value a coordinate takes; it creates no absence and deletes no row, which is whatwheremeans on every other block (rule 6). A cased expression has nowhereof its own, so the word would be free to mislead.foreach:required with cases, refused without. An uncased expression's dims fall out of its body; a cased one's cannot, since a case may be a scalar where itswhenis not —always_onabove is exactly that. Eachwhenis held to the frame by_check_where_dims, the same check in the same words that holds a variable's or a constraint's mask to its; each case's value must sit inside the frame too.The two forms do not mix. One
expression:or a set ofcases:, never both and never neither — with the near-miss named in each direction.What a reference expands to
A name reaching a cased expression expands to
CasesNode, a core arithmetic node carrying every arm. The partition proved on the declaration is what makes that a value rather than a choice: exactly one arm applies at each coordinate, decided without data, so a consumer selects per row the way awherealready filters — nothing about the shape of the plan depends on data.Every dispatcher gains the arm (expansion, resolution, dims, degree, boundedness, the typeset walk), which
pyrefly'sassert_neverexhaustiveness is what enforces. The dims of a reference are the declaredforeach, not the union of the arms: an arm narrower than the frame broadcasts, exactly as a parameter with fewer dims does. Arms are substituted through, so a case body may name another expression, and a macro template may name a cased expression.How it prints — the half that was #37
A cased expression is the one named expression that does not inline. Rendered as an atom, the block printed where the name stood, and a three-arm block is three rows tall, so whatever follows it in the equation sits beside the middle arm:
That is one row of
examples/commitment.yaml, not a contrived model: the reader's eye lands on\wedge \mathrm{pos}(t) = 0 \cdot \mathit{start\_up\_limit}_g, and the same three arms print twice becauseramp_upnames the quantity twice. A quantity written once in the file was written once per use on the page, which is the opposite of what naming it was for.So a use prints the symbol:
and the block prints once, under a new Definitions section between
Subject toandVariable domains— where a paper states a quantity defined by region:The section is named Definitions rather than the conventional where, which in this repo is a keyword and would read as the wrong thing.
CasesNodealready carriesnameandforeach, so the walk needs nothing new from the AST: rendering one records it and returns the indexed symbol.definitions()then prints what the other sections reached — so it runs after them, and to a fixpoint, since an arm may name another cased expression. An expression nobody names prints nothing.Symbols. Cased expressions join the symbol pool, deriving a symbol like any other name, and
--symbolscan rename one. Uncased expressions stay out: they print nothing under their own name, so a table entry for one would never apply — the silent-typo failure the table is strict about.The asymmetry is deliberate and is the one thing worth arguing with.
test_macros_and_named_expressions_are_expanded_awaystill holds for every other named expression; a cased one is the exception. The defence: it is the only kind that cannot inline legibly, and the only kind whose declaration a reader has to see to check the regions.The format seam gains
cases()—\begin{cases}in LaTeX,cases()in Typst, and\crrather than\\in Markdown, whose escape pass eats one of the two backslashes before MathJax sees them. The golden model carries a cased expression and a constraint naming it, so the three.outfiles and the walk's line-coverage guard cover the new arm.An example, and the bug writing it found
examples/commitment.yamlis the formulation #2 factors:ramp_upholds a running unit toramp_limitand a starting one tostart_up_limitin one inequality instead of three.tools/render_tex.pypicks it up like every other model, so the LaTeX gate compiles it, and it gets a page in the example gallery #38 added.Writing it turned up a bug in the documented example:
expression: 1is how a constant case is spelled, YAML reads it as an int, and the annotation took only a string — so the block inexpressions.mddid not load. Nothing caught it because no test loads the page's snippets. A number is now accepted wherever an expression string is declared, and the checked-in JSON schema says so, since amode='before'rewrite is otherwise invisible to it. Booleans still fail:trueis not arithmetic, and an error naming the type reads better than one naming'True'.What is deliberately not here
Macros are a follow-up. Cases there are safe only under a stronger obligation — provable with the names uninterpreted, since a macro has no frame until it is called.
Scope
model.py(ExpressionCase,ExpressionBlockgainingforeach/caseswith the form validator and round-trip serialiser, and the number coercion),validation.py(resolve each case, run the partition check),dimensions.py(the frame checks, beside the ones for variables and constraints),expansion.py(CasesNodewhere the name stood),expression_parser.py(the node),resolution.py/degree.py/boundedness.py(the dispatch arms),typeset/(the format seam, the symbol at the use site,definitions(), the section and the ordering the collection needs,printed_expressionsand the table's accepted names), the regenerated JSON schema,tools/notation.py,expressions.md,notation.md,examples/commitment.yamland its gallery page, the golden fixture and its three outputs, and tests.The gallery page is regenerated rather than hand-edited, and
tests/test_docs.pyis what catches it going stale — which is what that test is for.schema/math-spec.schema.jsonis regenerated bytools/schema.pyand pinned bytests/test_schema.py; it is outside prettier's*.{md,yml,yaml}glob, andmain's copy failsprettier --checkidentically — generator-owned, likeCHANGELOG.md.Checks
Gates reproduced with a 3.13 venv on the versions
pixi.tomlpins plusprettier@3.9.3:ruffformat and check clean,pyrefly0 errors,reuse lintcompliant,typosclean, prettier clean over its own glob,mkdocs build --strictclean, 425 passed — including the Typst compile and the walk's line-coverage guard, which the bare install skips. Every equation quoted above is the renderer's own output, not written by hand.