From 3f5474dd694aba27a8839db401d6027f0bda7248 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:05:57 +0000 Subject: [PATCH] docs: an Examples section, each model beside the math it prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site surfaced examples only as rendered math and never as models. The homepage prints `examples/dispatch.yaml`; `operators.md` prints one equation per probe as table cells. A reader could see the equation `sum(by=)` renders as and never see a file that declares one, which is the wrong way round for a language whose pitch is that the file and the math are the same thing. There was no Examples entry in the nav at all. `docs/examples/` is three pages: an index, the dispatch model, and the probes as a group. Each carries hand-written prose about what its model is for, and a generated block — the file verbatim, then the document the typesetter prints from it. `tools/gallery.py` writes those blocks and `tests/test_docs.py` compares the committed pages to it, so math typed into a page is not a claim nothing checks. That is the `test_the_gallery_math_is_current` the markdown format's docstring has been naming all along. The probe page reuses `spec_math.OPERATORS`, which is what keeps it and the operator table naming the same models. Prettier pads the legend tables the renderer emits and the generator writes them unpadded, so each undid the other and the committed file could satisfy neither. The two generated pages join CHANGELOG.md in `.prettierignore`, under the same rule: the generator wins where nobody edits by hand. `index.md` carries no generated block and is not listed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- .prettierignore | 11 + docs/examples/dispatch.md | 88 ++++++ docs/examples/index.md | 23 ++ docs/examples/operators.md | 432 +++++++++++++++++++++++++++ docs/reference/language/operators.md | 5 +- mkdocs.yml | 4 + tests/test_docs.py | 28 ++ tools/gallery.py | 115 +++++++ 8 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 docs/examples/dispatch.md create mode 100644 docs/examples/index.md create mode 100644 docs/examples/operators.md create mode 100644 tests/test_docs.py create mode 100644 tools/gallery.py diff --git a/.prettierignore b/.prettierignore index 98ad7d87..741ead2b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,14 @@ # The generator wins. Nobody edits this file by hand, so the formatting is not # anyone's to prefer. CHANGELOG.md + +# `tools/gallery.py` writes the body of these pages: the model, verbatim, and +# the document the typesetter prints from it. Prettier pads the legend tables +# it emits, the generator writes them unpadded, and each undoes the other — so +# the committed file could never satisfy both, and `tests/test_docs.py` compares +# it to the generator byte for byte. +# +# Same rule as CHANGELOG.md above: the generator wins where nobody edits by +# hand. `index.md` is not listed — it carries no generated block. +docs/examples/dispatch.md +docs/examples/operators.md diff --git a/docs/examples/dispatch.md b/docs/examples/dispatch.md new file mode 100644 index 00000000..997178d0 --- /dev/null +++ b/docs/examples/dispatch.md @@ -0,0 +1,88 @@ + + +# Least-cost dispatch + +The smallest file that is a whole model: generators with a capacity, an hourly +load to meet, and a cost to minimise. It is the model on the +[home page](../index.md) and in the README, and the one the language reference +varies when it needs a base to change one thing in. + +Two things worth reading for. The `where:` on `p` deletes the rows where a +generator has no capacity — [absence](../reference/language/absence.md) is a +declaration, not a runtime check. And `sum(p, over=generator)` names the +dimension it reduces, so the constraint's frame is what remains. + + +```yaml +description: Least-cost dispatch of a generator fleet against an hourly load. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + generator: { values: [wind, solar, gas], description: generating units } + +parameters: + p_max: { dims: [generator], description: installed capacity } + load: { dims: [snapshot], description: demand to be met } + cost: { dims: [generator], description: marginal cost } + +variables: + p: + description: output of a generator in a snapshot + foreach: [snapshot, generator] + where: "p_max > 0" + bounds: { lower: 0, upper: p_max } + +constraints: + power_balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + +objective: + sense: minimize + expression: sum(p * cost) +``` + +Least-cost dispatch of a generator fleet against an hourly load. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $\mathcal{T}$ | index $t$ — `snapshot` — dispatch periods | +| $\mathcal{G}$ | index $g$ — `generator` — generating units | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $p^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | +| $\mathit{load}$ | `load` over $\mathcal{T}$ — demand to be met | +| $\mathit{cost}$ | `cost` over $\mathcal{G}$ — marginal cost | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ — output of a generator in a snapshot | + +#### Objective + +$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g}$$ + +#### Subject to + +**`power_balance`** + +$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathit{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ + +#### Variable domains + +**`p`** + +$$0 \le p_{t,g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace p^{\mathrm{max}}_{g} > 0$$ + + +Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 00000000..64fa49fc --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,23 @@ + + +# Examples + +Whole models, each shown as the file and as the math it prints. The reference +pages take the language a construct at a time; these take it a **model** at a +time, which is the form anyone writing one actually needs. + +Every model here is a real file under `examples/` in the repository, not a +fragment written for the page. They are the same files the test suite loads and +the LaTeX gate compiles, so a model that stopped being valid — or that started +printing different math — fails CI rather than going stale here. + +- [Least-cost dispatch](dispatch.md) — the smallest model that is a model: a + balance, a bound, and a cost to minimise. +- [One construct per model](operators.md) — the operator probes: the smallest + file that declares each built-in, beside the equation it renders. + +The math on these pages is written by the typesetter, from the file above it — +see [Typeset the math](../reference/typeset.md) for how to print your own. diff --git a/docs/examples/operators.md b/docs/examples/operators.md new file mode 100644 index 00000000..0b953aff --- /dev/null +++ b/docs/examples/operators.md @@ -0,0 +1,432 @@ + + +# One construct per model + +The probes: for each built-in [operator](../reference/language/operators.md), +the smallest model that declares it, beside the equation it renders. The +reference page shows the same equations as a table — what each operator _looks +like_, side by side. This page shows the **file** that produced each one. + +They are models rather than fragments on purpose. A probe whose operator +changed shape stops loading, in CI, in the run that would otherwise have +shipped the old math. + + +### `sum(array)` + +`examples/operators/sum_all.yaml` + +```yaml +description: Every dimension at once — `sum(array)` names none of them and takes them all. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + +parameters: + budget: { dims: [] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + fleet_budget: + foreach: [] + expression: sum(p) <= budget + +objective: { sense: minimize, expression: sum(p) } +``` + +$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathit{budget}$ + +### `sum(array, over=dim)` + +`examples/operators/sum.yaml` + +```yaml +description: The plain reduction — `sum(array, over=dim)` collapses one dimension. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + +parameters: + limit: { dims: [snapshot] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + fleet_total: + foreach: [snapshot] + expression: sum(p, over=generator) <= limit + +objective: { sense: minimize, expression: sum(p) } +``` + +$\sum_{g \in \mathcal{G}} p_{t,g} \le \mathit{limit}_{t} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `sum(array, by=lookup)` + +`examples/operators/sum_by.yaml` + +```yaml +description: >- + The membership reduction — `sum(array, by=lookup)` lands the result on the + dimension the lookup maps into, which is what makes topology data rather than + structure. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + bus: { dtype: str } + +lookups: + gen_bus: { over: generator, into: bus } + +parameters: + limit: { dims: [snapshot, bus] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + bus_total: + foreach: [snapshot, bus] + expression: sum(p, by=gen_bus) <= limit + +objective: { sense: minimize, expression: sum(p) } +``` + +$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} \le \mathit{limit}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$ + +### `sum(array, by=[lookup, …])` + +`examples/operators/sum_by_lookups.yaml` + +```yaml +description: >- + Grouping through several maps at once — `sum(array, by=[lookup, …])` lands + the result on every dimension the lookups map into, which is one grouping + rather than a composition of two: the generator dimension is consumed once. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + bus: { dtype: str } + technology: { dtype: str } + +lookups: + gen_bus: { over: generator, into: bus } + gen_tech: { over: generator, into: technology } + +parameters: + limit: { dims: [snapshot, bus, technology] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + bus_technology_total: + foreach: [snapshot, bus, technology] + expression: sum(p, by=[gen_bus, gen_tech]) <= limit + +objective: { sense: minimize, expression: sum(p) } +``` + +$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathit{limit}_{t,b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$ + +### `at(array, by=lookup)` + +`examples/operators/at.yaml` + +```yaml +description: >- + The adjoint of the membership reduction — `at(array, by=lookup)` reads one + coarse value once per fine label pointing at it. + +dimensions: + snapshot: { dtype: int } + period: { dtype: int } + +lookups: + period_of: { over: snapshot, into: period } + +parameters: + cap: { dims: [period] } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + within_cap: + foreach: [snapshot] + expression: p <= at(cap, by=period_of) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le \mathit{cap}_{\mathrm{period\_of}(t)} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=n)` + +`examples/operators/shift.yaml` + +```yaml +description: >- + Translation with no edge policy — the vacated position is absent, so the row + it would have fed is not built. + +dimensions: + snapshot: { dtype: int } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t - 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=n, edge='wrap')` + +`examples/operators/shift_wrap.yaml` + +```yaml +description: >- + Cyclic translation — the horizon closed on itself, so the first position + reads the last and nothing is vacated. + +dimensions: + snapshot: { dtype: int } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1, edge='wrap') + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t \ominus 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=n, edge=v)` + +`examples/operators/shift_edge.yaml` + +```yaml +description: >- + Translation with a value at the edge — the vacated position contributes the + number instead of being absent, so the row survives. + +dimensions: + snapshot: { dtype: int } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1, edge=0) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t \boxminus_{0} 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=p, edge=…)` + +`examples/operators/shift_by_parameter.yaml` + +```yaml +description: >- + Translation by an offset that differs per entity — `by:` names an integer + parameter, so each technology is reached by its own lead time rather than by + one the file had to fix. + +dimensions: + technology: { dtype: str } + month: { dtype: int } + +parameters: + lead: { dims: [technology], dtype: int } + demand: { dims: [technology, month] } + +variables: + order: + foreach: [technology, month] + bounds: { lower: 0 } + +constraints: + arrives_after_its_lead: + foreach: [technology, month] + expression: shift(order, over=month, offset=lead, edge=0) >= demand + +objective: { sense: minimize, expression: sum(order) } +``` + +$\mathit{order}_{t,m \boxminus_{0} \mathit{lead}} \ge \mathit{demand}_{t,m} \qquad \forall\thinspace t \in \mathcal{T},\enspace m \in \mathcal{M}$ + +### `shift(array, over=dim, offset=n, by=lookup)` + +`examples/operators/shift_partitioned.yaml` + +```yaml +description: >- + Translation inside a group — each season closed on itself, so a season's first + snapshot reads that season's last and no level crosses the boundary. + +dimensions: + snapshot: { dtype: int } + season: { dtype: str } + +lookups: + season_of: { over: snapshot, into: season } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before_in_season: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1, edge='wrap', by=season_of) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t \ominus_{\mathrm{season\_of}(t)} 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `sum_back(array, over=dim, within=n)` + +`examples/operators/sum_back.yaml` + +```yaml +description: >- + A trailing window of a fixed width: a unit that started in the last three + hours is still on. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_its_own_time: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=3) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + +### `sum_back(array, over=dim, within=p)` + +`examples/operators/sum_back_by_parameter.yaml` + +```yaml +description: >- + A trailing window whose width is data — `within:` names an integer parameter, + so a unit stays up for its *own* minimum time rather than one the file fixed. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_its_own_time: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=min_up) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathit{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + +### `sum_back(array, over=dim, within=p, edge='wrap')` + +`examples/operators/sum_back_wrap.yaml` + +```yaml +description: >- + A trailing window on a representative period that repeats, so the window at + the first hour reaches back into the last. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_its_own_time: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=min_up, edge='wrap') <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathit{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + + +Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index 7c1a2e75..2a877686 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -339,7 +339,10 @@ The three `shift` rows are the ones to read together: they differ only at the boundary, and that difference is the whole of the identity rule in this position. -The rest of the language is rendered the same way, on one page: [Every construct, as math](../notation.md). +Each row comes from a model of its own; they are on +[One construct per model](../../examples/operators.md). The rest of the +language is rendered the same way, on one page: +[Every construct, as math](../notation.md). diff --git a/mkdocs.yml b/mkdocs.yml index 91d91bc7..9a98cfbd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,10 @@ validation: nav: - Home: index.md - Installation: installation.md + - Examples: + - examples/index.md + - Least-cost dispatch: examples/dispatch.md + - One construct per model: examples/operators.md - Reference: - Language: - reference/language/index.md diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..76675115 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The generated half of the documentation, held to what generates it. + +`docs/examples/` shows each model beside the math the typesetter prints from +it. Written by hand, that math would be a claim nothing checks — on a site +whose subject is the math a file means, and in a repository that owns the +renderer which would have caught it. So the block is generated, and this is +what makes "generated" true of the committed file rather than of a script +nobody runs. +""" + +from __future__ import annotations + +import pytest + +from tools import gallery + + +@pytest.mark.parametrize('page', gallery.pages()) +def test_the_gallery_math_is_current(page: str): + path = gallery.PAGES / page + text = path.read_text() + assert gallery.rendered(page, text) == text, ( + f'docs/examples/{page} no longer matches the model it shows — run `pixi run python -m tools.gallery`' + ) diff --git a/tools/gallery.py b/tools/gallery.py new file mode 100644 index 00000000..f2471f85 --- /dev/null +++ b/tools/gallery.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The example gallery: each model in `examples/`, beside the math it prints. + + pixi run python -m tools.gallery # rewrite the pages' blocks + pixi run python -m tools.gallery --check # fail if one has drifted + +The reference pages show what a *construct* prints; nothing showed a **model**. +A reader could see the equation `sum(by=)` renders as and never see a file that +declares one, which is the wrong way round for a language whose pitch is that +the file and the math are the same thing. + +So each page is one model, in full, followed by the document the typesetter +prints from it. Generated for the reason the other two blocks are: math typed +into a page is math nothing checks, and this project has the renderer that +would have caught it. + +The prose above each block is the page's own — what the model is for, and which +construct it is here to show. Only the fenced model and the math below it are +written from here. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from math_spec.typeset import to_markdown +from tools.spec_math import OPERATORS, rendered_probe + +ROOT = Path(__file__).resolve().parent.parent +PAGES = ROOT / 'docs' / 'examples' +BEGIN, END = '', '' + +#: Page -> the model it shows. One model per page, because a gallery of +#: fragments is what the reference pages already are. +MODELS = { + 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', +} + +#: The probe page shows every model under `examples/operators/`, keyed by the +#: signature it demonstrates — :data:`tools.spec_math.OPERATORS` is that map, +#: and reusing it is what keeps the two pages naming the same probes. +PROBES = ROOT / 'examples' / 'operators' + + +def source(path: Path) -> str: + """The model as written, without the licence header a reader did not ask for.""" + return re.sub(r'\A(#[^\n]*\n)+\n', '', path.read_text()).strip() + + +def model_block(path: Path) -> str: + """One model, then the whole document the typesetter prints from it.""" + return f'```yaml\n{source(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' + + +def probe_block() -> str: + """Every operator probe: the model, then the one equation it renders.""" + parts = [] + for signature, name in OPERATORS.items(): + equation, _ = rendered_probe(name) + parts.append( + f'### `{signature}`\n\n' + f'`examples/operators/{name}.yaml`\n\n' + f'```yaml\n{source(PROBES / f"{name}.yaml")}\n```\n\n' + f'{equation}' + ) + return '\n\n'.join(parts) + + +def block(page: str) -> str: + return probe_block() if page == 'operators.md' else model_block(MODELS[page]) + + +def rendered(page: str, text: str) -> str: + i, j = text.index(BEGIN) + len(BEGIN), text.index(END) + return text[:i] + '\n' + block(page) + '\n' + text[j:] + + +def pages() -> list[str]: + return [*MODELS, 'operators.md'] + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--check', action='store_true', help='fail if a committed block has drifted') + opts = ap.parse_args(argv) + + stale = [] + for page in pages(): + path = PAGES / page + text = path.read_text() + updated = rendered(page, text) + if opts.check: + if updated != text: + stale.append(page) + continue + path.write_text(updated) + print(f'wrote {path.relative_to(ROOT)}') + + if stale: + names = ', '.join(stale) + print(f'{names} stale — run `pixi run python -m tools.gallery`', file=sys.stderr) + return 1 + if opts.check: + print(f'{len(pages())} page(s) match their models') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())