From 5c6ad5e786414c2e40eec4c38d48d7e9b8a88b59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:45:05 +0000 Subject: [PATCH 1/2] feat: a cased expression prints once, as a definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/examples/commitment.md | 11 +++- docs/reference/language/expressions.md | 14 +++-- docs/reference/notation.md | 41 ++++++------ src/math_spec/typeset/__init__.py | 10 ++- src/math_spec/typeset/format.py | 6 +- src/math_spec/typeset/symbols.py | 18 +++++- src/math_spec/typeset/walk.py | 46 ++++++++++++-- tests/typeset/golden/latex.out | 7 ++- tests/typeset/golden/markdown.out | 8 ++- tests/typeset/golden/typst.out | 6 +- tests/typeset/test_typeset.py | 87 +++++++++++++++++++++++++- tools/notation.py | 31 ++++----- 12 files changed, 227 insertions(+), 58 deletions(-) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index bb7eae55..24a485f0 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -15,6 +15,9 @@ a quantity: exactly one arm applies at every coordinate, so `ramp_up` can use it the way it uses a parameter. A gap or an overlap is a load error naming a witness for it. +It prints the way a paper writes it: `ramp_up` names the quantity, and the +block itself prints once below, under **Definitions**. + ```yaml description: >- @@ -142,7 +145,13 @@ $$p_{t,g} \ge \mathit{status}_{t,g} \cdot p^{\mathrm{min}}_{g} \qquad \forall\th **`ramp_up`** -$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathit{ramp\_limit}_{g} \cdot \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} + \mathit{start\_up\_limit}_{g} \cdot \left( 1 - \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathit{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathit{start\_up\_limit}_{g} \cdot \left( 1 - \mathit{previous\_status}_{t,g} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Definitions + +**`previous_status`** + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### Variable domains diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index aaa4cbba..7ed45604 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -403,11 +403,17 @@ and each `when` must sit **inside** that frame; neither may widen it. [`examples/commitment.yaml`](../../examples/commitment.md) is the whole model this comes from, beside the math it prints. -**A reference carries the regions with it.** `no_restart` above names the -quantity once, so the inequality is written once and the case conditions ride -along into what it prints: +**A reference names the quantity; the block prints once.** A cased expression +is the one kind that does not read well inlined — three arms are three rows +tall, so whatever follows in the equation sits beside the middle one. So a use +prints the symbol, -$$\mathit{status}_{t,g} - \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{status}_{t,g} - \mathit{previous\_status}_{t,g} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +and the block prints under **Definitions**, which is where a paper states a +quantity defined by region: + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ ## Macros diff --git a/docs/reference/notation.md b/docs/reference/notation.md index c2b0ac13..6f14ebd9 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -121,23 +121,6 @@ $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ $\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. -### Named expressions - -A named expression is substituted where its name is used, so it prints nothing under its own name — its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value defined by region is a construct, and the regions read beside the declaration rather than at the use site. - -```yaml -expressions: - startup_cost: # a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate - foreach: [snapshot, generator] - cases: - opening: - when: "position(snapshot) == 0" - expression: cost * p_max - later: - when: "position(snapshot) != 0" - expression: cost -``` - ### The objective #### `objective` @@ -437,7 +420,7 @@ started: expression: slack >= on * startup_cost ``` -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `always` @@ -465,6 +448,28 @@ never: $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +### Definitions + +A named expression is substituted where its name is used, so it normally prints nothing under its own name. A cased one is the exception: its value is defined by region, which is a definition of its own, and the equations using it name it rather than repeating the block. + +#### `startup_cost` + +a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate + +```yaml +startup_cost: + foreach: [snapshot, generator] + cases: + opening: + when: "position(snapshot) == 0" + expression: cost * p_max + later: + when: "position(snapshot) != 0" + expression: cost +``` + +$$\mathit{startup\_cost}_{t,g} = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + ### Variable domains #### `p` diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index e8f2f004..04a99b2c 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -102,10 +102,14 @@ def typeset( table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) walk = Walk(schema, Namespace.of(schema), Symbols(schema, fmt, table.checked_against(schema)), fmt) + # Order matters: `definitions()` prints what the other sections reached, + # so they run first and their output is placed around it. + objective, constraints, variables = walk.objective(), walk.constraints(), walk.variables() sections = [ - ('Objective', walk.objective()), - ('Subject to', walk.constraints()), - ('Variable domains', walk.variables()), + ('Objective', objective), + ('Subject to', constraints), + ('Definitions', walk.definitions()), + ('Variable domains', variables), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines] diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index 207d58c5..b54ba81f 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -196,8 +196,10 @@ def summation(self, domain: str, body: str) -> str: ... def cases(self, arms: list[tuple[str, str]]) -> str: """A value defined by region: ``(value, condition)`` per arm. - The arms partition the frame, so there is no otherwise-arm and no - order-dependence — a format may print them in any order it likes. + The arms partition the frame, so every one carries a condition and + there is no otherwise-arm to print last. They arrive in the order the + file declares them and print in it — nothing depends on the order, but + a reader comparing the page to the file does. """ ... diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 9ae0766d..896530d6 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -61,6 +61,17 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format) -> str return _word(name, fmt) +def printed_expressions(schema: Buildable) -> frozenset[str]: + """The named expressions that reach the page under their own name. + + A named expression is substituted where it is used, so it normally prints + nothing a symbol could stand for. A **cased** one is the exception: its + value is defined by region, which reads as a definition of its own and is + referred to by name from the equations that use it. + """ + return frozenset(name for name, block in schema.expressions.items() if block.cases) + + class Symbols: r"""How every declared name prints: overrides first, derivation for the rest. @@ -82,11 +93,12 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - declared = frozenset({*schema.dimensions, *schema.parameters, *schema.variables}) + printed = printed_expressions(schema) + declared = frozenset({*schema.dimensions, *schema.parameters, *schema.variables, *printed}) self.name: dict[str, str] = { name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt) - for name in (*schema.parameters, *schema.variables) + for name in (*schema.parameters, *schema.variables, *printed) } spoken_for = {s for s in self.name.values() if len(s) == 1} @@ -204,7 +216,7 @@ def load(cls, source: str | Path | Mapping[str, Any]) -> SymbolTable: def checked_against(self, schema: Buildable) -> SymbolTable: """Reject entries naming nothing in *schema*, with the near miss.""" dims = set(schema.dimensions) - everything = dims | set(schema.parameters) | set(schema.variables) + everything = dims | set(schema.parameters) | set(schema.variables) | printed_expressions(schema) errors = [ *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims), *(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything), diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index 6b3488bc..f5459e7d 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -228,6 +228,9 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.policies: set[str] = set() self.positions: set[str] = set() self.numeric_coordinates: set[str] = set() + #: Cased expressions met while rendering, in first-use order. Each one + #: prints once, as a definition of its own; see :meth:`definitions`. + self.defined: dict[str, CasesNode] = {} def op(self, name: str) -> str: return self.format.operators[name] @@ -290,10 +293,11 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: return self._call(node, ctx) if isinstance(node, CasesNode): - # An atom: a cases block is self-delimiting, so it never needs a - # bracket around it however it sits in the surrounding arithmetic. - arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] - return self.format.cases(arms), _ATOM + # The symbol, not the cases: the block prints once as a definition + # of its own, and a use of it reads like any other quantity. See + # :meth:`definitions` for why. + self.defined.setdefault(node.name, node) + return ctx.indexed(self.symbols.name[node.name], list(node.foreach)), _ATOM if isinstance(node, (NameNode, NameListNode, KeywordNode, DimensionNode, LookupNode, EdgeNode)): msg = f'{type(node).__name__} reached the typesetter; resolve the expression first.' @@ -605,6 +609,40 @@ def constraints(self) -> list[Line]: ) return lines + def definitions(self) -> list[Line]: + """One line per cased expression the equations used, defining it. + + Inlining a cases 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 its middle arm and + reads as part of that arm's condition. Worse, a quantity written once + in the file would be written once per use on the page — the opposite of + what naming it was for. + + So a use prints the symbol and the block prints here, which is how a + paper states a quantity defined by region. Run this **after** the + sections that use it: what lands here is what they reached, and an arm + may itself name another cased expression, so the loop runs to a + fixpoint rather than over one pass. + """ + lines: list[Line] = [] + done: set[str] = set() + while pending := [name for name in self.defined if name not in done]: + for name in pending: + done.add(name) + node = self.defined[name] + ctx = self.context(ceiling=2) + arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] + lines.append( + Line( + label=name, + left=ctx.indexed(self.symbols.name[name], list(node.foreach)), + right=f'{self.op("equal")} {self.format.cases(arms)}', + condition=self.quantifier(list(node.foreach), ''), + ) + ) + return lines + def variables(self) -> list[Line]: """One line per variable, and one more for a set the variable carries. diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index 2113c087..0bdeef72 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -86,11 +86,16 @@ \text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right) \\ \text{last} && \mathit{on}_{t,g} & = 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathit{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ -\text{started} && \mathit{slack}_{t} & \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{started} && \mathit{slack}_{t} & \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \end{align} +\paragraph{Definitions} +\begin{align} +\text{startup\_cost} && \mathit{startup\_cost}_{t,g} & = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\end{align} + \paragraph{Variable domains} \begin{align} \text{p} && p^{\mathrm{min}}_{g} \le p_{t,g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( p^{\mathrm{max}}_{g} > 0 \wedge \neg \mathit{is\_flexible}_{g} \vee p^{\mathrm{min}}_{g} > 0 \right) \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index e9b1b40c..a605ff9f 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -151,7 +151,7 @@ $$\mathit{slack}_{t} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mat **`started`** -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`always`** @@ -161,6 +161,12 @@ $$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +#### Definitions + +**`startup_cost`** + +$$\mathit{startup\_cost}_{t,g} = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### Variable domains **`p`** diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index 3b06bb0e..5307114a 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -75,10 +75,14 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")_(upright("season_of")(t))(t) = 0) \ upright("last") & italic("on")_(t,g) & = 0 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = abs(cal(T)) - 1 or upright("pos")_(upright("season_of")(t))(t) = abs(cal(T)_(upright("season_of")(t))) - 1) \ upright("northern") & italic("slack")_(t) & <= italic("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ - upright("started") & italic("slack")_(t) & >= italic("on")_(t,g) dot cases(italic("cost")_(g) dot p^(upright("max"))_(g) & upright("if ") upright("pos")(t) = 0, italic("cost")_(g) & upright("if ") upright("pos")(t) != 0) & forall t in cal(T), g in cal(G) \ + upright("started") & italic("slack")_(t) & >= italic("on")_(t,g) dot italic("startup_cost")_(t,g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ +== Definitions +#set math.equation(numbering: "(1)") +$ upright("startup_cost") & italic("startup_cost")_(t,g) & = cases(italic("cost")_(g) dot p^(upright("max"))_(g) & upright("if ") upright("pos")(t) = 0, italic("cost")_(g) & upright("if ") upright("pos")(t) != 0) & forall t in cal(T), g in cal(G) $ + == Variable domains #set math.equation(numbering: "(1)") $ upright("p") & p^(upright("min"))_(g) <= p_(t,g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon (p^(upright("max"))_(g) > 0 and not italic("is_flexible")_(g) or p^(upright("min"))_(g) > 0) \ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 56ffdfc6..bd845ce4 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -443,7 +443,10 @@ def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(fmt: F @EVERY_FORMAT def test_macros_and_named_expressions_are_expanded_away(fmt: Format): - """What prints is the math a backend builds, not the sugar it was spelled with.""" + """What prints is the math a backend builds, not the sugar it was spelled with. + + A cased expression is the one exception, and the test below it says why. + """ model = override( DISPATCH, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.power_balance.expression': 'supply == load'}, @@ -451,6 +454,73 @@ def test_macros_and_named_expressions_are_expanded_away(fmt: Format): assert 'supply' not in typeset(model, fmt, legend=False) +#: The dispatch model, with a quantity defined by region and a constraint using +#: it. `first` is a column and `later` a scalar, so the arms alone would not +#: give the quantity its shape — the declared `foreach` does. +CASED = override( + DISPATCH, + **{ + 'expressions.headroom': { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, + 'later': {'when': 'position(snapshot) != 0', 'expression': 0}, + }, + }, + 'constraints.spare': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= headroom'}, + }, +) + + +@EVERY_FORMAT +def test_a_cased_expression_is_the_exception_that_keeps_its_name(fmt: Format): + """It prints once, as a definition, and its uses name it. + + The other way round — the block inlined at each use — is what the AST does + and the wrong thing to print twice over: a quantity written once in the + file would be written once per use on the page, and a block three arms tall + puts whatever follows it beside its middle arm. + """ + rendered = typeset(CASED, fmt, legend=False) + assert rendered.count(fmt.italic('headroom')) == 2, 'one use and one definition, no more' + assert _section(rendered, fmt) == ['Objective', 'Subject to', 'Definitions', 'Variable domains'] + + +@EVERY_FORMAT +def test_a_definition_is_printed_only_where_something_reached_it(fmt: Format): + """An expression nobody names is sugar nobody unwrapped — it prints nothing.""" + unused = override(CASED, **{'constraints.spare.expression': 'p <= p_max'}) + rendered = typeset(unused, fmt, legend=False) + assert 'headroom' not in rendered + assert 'Definitions' not in rendered + + +@EVERY_FORMAT +def test_a_definition_naming_another_one_prints_both(fmt: Format): + """The arms are walked too, so the collection runs to a fixpoint.""" + nested = override( + CASED, + **{ + 'expressions.opening_cost': { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'headroom * cost'}, + 'later': {'when': 'position(snapshot) != 0', 'expression': 0}, + }, + }, + 'constraints.spare.expression': 'p <= opening_cost', + }, + ) + rendered = typeset(nested, fmt, legend=False) + assert fmt.italic('headroom') in rendered, 'the inner definition was reached through an arm' + assert rendered.count(fmt.italic('opening_cost')) == 2 + + +def _section(rendered: str, fmt: Format) -> list[str]: + """The section titles *fmt* printed, in order.""" + return [title for title in ('Objective', 'Subject to', 'Definitions', 'Variable domains') if title in rendered] + + @EVERY_FORMAT def test_an_invalid_model_fails_the_same_way_check_does(fmt: Format): broken = override(DISPATCH, **{'objective.expression': 'p * nonexistent'}) @@ -978,6 +1048,21 @@ def test_the_table_overrides_and_the_rest_is_still_derived(): assert r'u \in \mathcal{U}' in tex +def test_the_table_may_rename_a_cased_expression_but_not_a_plain_one(): + """It names what prints, and a cased expression is the only expression that does. + + An entry that never applies is the failure mode the table is strict about: + a reader writes a spelling, sees the old symbol, and has nothing to tell + them why. + """ + tex = to_latex(CASED, symbols={'notation': 'latex', 'names': {'headroom': r'\bar h'}}, legend=False) + assert r'\bar h_{t,g}' in tex + + plain = override(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) + with pytest.raises(SchemaError, match='is not declared by the model'): + to_latex(plain, symbols={'notation': 'latex', 'names': {'supply': 's'}}, legend=False) + + DESCRIBED = override( DISPATCH, **{ diff --git a/tools/notation.py b/tools/notation.py index d8e39997..7770b5b3 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -66,6 +66,7 @@ SECTIONS = { 'objective': 'The objective', 'constraints': 'Constraints', + 'expressions': 'Definitions', 'variables': 'Variable domains', 'piecewise': 'Curves, as what they expand to', 'sos': 'Sets carried to the solver', @@ -189,26 +190,21 @@ def legend(rendered: str) -> str: def preamble(text: str) -> str: """The fixture's ``dimensions``/``lookups``/``parameters`` blocks, verbatim.""" - return '\n'.join(_block(text, name) for name in DECLARED).strip() - - -def _block(text: str, name: str) -> str: - """One top-level block of the fixture, from its key to the next one.""" - body = text[text.index(f'\n{name}:') + 1 :] - end = re.search(r'\n(?=\w)', body) - return body[: end.start()] if end else body - - -#: What the page says about the one block that declares math and prints none. -NAMED = ( - 'A named expression is substituted where its name is used, so it prints nothing under its own name \N{EM DASH} ' - 'its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value ' - 'defined by region is a construct, and the regions read beside the declaration rather than at the use site.' -) + blocks = [] + for name in DECLARED: + body = text[text.index(f'\n{name}:') + 1 :] + end = re.search(r'\n(?=\w)', body) + blocks.append(body[: end.start()] if end else body) + return '\n'.join(blocks).strip() #: What each section says about itself, where the section needs saying. NOTES = { + 'expressions': ( + 'A named expression is substituted where its name is used, so it normally prints nothing under its own ' + 'name. A cased one is the exception: its value is defined by region, which is a definition of its own, and ' + 'the equations using it name it rather than repeating the block.' + ), 'piecewise': ( 'A curve is sugar: what prints is the formulation it expands to, which is the math the solver ' 'receives. One row per `method:`, each from the model named under it, so the symbols in this ' @@ -226,9 +222,6 @@ def block() -> str: 'print is the legend every model opens with.', f'```yaml\n{preamble(MODEL.read_text())}\n```', legend(rendered), - '### Named expressions', - NAMED, - f'```yaml\n{_block(MODEL.read_text(), "expressions").strip()}\n```', ] printed = equations(rendered) for section, title in SECTIONS.items(): From ea759fba6cd1cc884ea540e62fa37a77d8769742 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:57:46 +0000 Subject: [PATCH 2/2] refactor: the definitions pass reads as the ordering it depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- src/math_spec/typeset/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index 04a99b2c..90a4966c 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -102,13 +102,15 @@ def typeset( table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) walk = Walk(schema, Namespace.of(schema), Symbols(schema, fmt, table.checked_against(schema)), fmt) - # Order matters: `definitions()` prints what the other sections reached, - # so they run first and their output is placed around it. + # `definitions()` prints what the other sections reached, so it runs after + # them — a statement apart rather than a call inside the list, where the + # order it depends on would be invisible. objective, constraints, variables = walk.objective(), walk.constraints(), walk.variables() + definitions = walk.definitions() sections = [ ('Objective', objective), ('Subject to', constraints), - ('Definitions', walk.definitions()), + ('Definitions', definitions), ('Variable domains', variables), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines]