math-spec#
The language an optimisation model is written in — and the math it means.
Write the math in YAML. Everything decidable without data is decided without data — and the file prints as the math it stands for.
-
Declarative math
One file declares the axes, the data, the decisions and the rules. Readable without knowing any implementation, and self-contained: no Python state changes what it means. It diffs cleanly in review and travels as a research artefact.
-
Decided before the data
Every expression, every
wherestring and even an uncalled macro template is parsed and name-checked at load. A repository of models compiles in CI with nothing bound to any of them. -
Fail early, fail loud
Nothing is guessed and nothing falls back silently. Where a file does not determine the answer, loading fails and the message names the construct and its rewrite.
-
A finite language, with a priced way out
The ceiling is a closure — relational ∩ local — not a feature race. Genuinely unsayable math goes in an
escape:island: visible in the file, billed before it runs. -
The file is the document
LaTeX, Typst or Markdown, printed from the file alone. No data, no solver, no second source of truth — the cheapest review tool there is for does this YAML say what I meant.
-
One rule per question
A rule is language iff two consumers answering it separately would be a bug. That test is what decides who owns a question — the language, or the engine reading it.
flowchart LR
Y["model.yaml"] --> S["schema<br/>closed at every level"]
S --> AST["core AST<br/>two grammars"]
AST --> Q{"inside the<br/>language?"}
Q -->|"no"| ERR["load error<br/>naming the construct + rewrite"]
Q -->|"yes"| M["Spec<br/>what the file says"]
M -->|"to_program"| P["Program<br/>names, dims and operators resolved"]
P --> ENG["a consumer → solver"]
M --> T["to_latex / to_typst / to_markdown"]
classDef spec fill:#f0f7f0,stroke:#3a7d44,stroke-width:2px,color:#111
classDef consumer fill:#eef1fb,stroke:#4a5fc1,stroke-width:2px,color:#111
classDef err fill:#fdf3e7,stroke:#b7791f,color:#111
class S,AST,M,P spec
class ENG,T consumer
class ERR err
The whole thing, in one model#
description: Least-cost dispatch of a generator fleet against an hourly load.
dimensions:
snapshot: { dtype: int, description: dispatch periods }
generator: { 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)
And that file says, exactly this#
Generated from the YAML above — no data, no solver, no second source of truth. Only the notation is a choice, and How shows the one that was made here.
Least-cost dispatch of a generator fleet against an hourly load.
Sets#
| Symbol | Meaning |
|---|---|
| \(\mathcal{S}\) | index \(s\) — snapshot — dispatch periods |
| \(\mathcal{G}\) | index \(g\) — generator — generating units |
Parameters#
| Symbol | Meaning |
|---|---|
| \(\bar p\) | p_max over \(\mathcal{G}\) — installed capacity |
| \(\ell\) | load over \(\mathcal{S}\) — demand to be met |
| \(c\) | cost over \(\mathcal{G}\) — marginal cost |
Variables#
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{S} \times \mathcal{G}\) — output of a generator in a snapshot |
Objective#
Subject to#
power_balance
Variable domains#
p
\noindent Least-cost dispatch of a generator fleet against an hourly load.
\paragraph{Sets}
\begin{description}
\item[{$\mathcal{S}$}] index $s$ --- \texttt{snapshot} --- dispatch periods
\item[{$\mathcal{G}$}] index $g$ --- \texttt{generator} --- generating units
\end{description}
\paragraph{Parameters}
\begin{description}
\item[{$\bar p$}] \texttt{p\_max} over $\mathcal{G}$ --- installed capacity
\item[{$\ell$}] \texttt{load} over $\mathcal{S}$ --- demand to be met
\item[{$c$}] \texttt{cost} over $\mathcal{G}$ --- marginal cost
\end{description}
\paragraph{Variables}
\begin{description}
\item[{$p$}] \texttt{p} over $\mathcal{S} \times \mathcal{G}$ --- output of a generator in a snapshot
\end{description}
\paragraph{Objective}
\begin{align*}
&& \min & \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} p_{s,g} \cdot c_{g}
\end{align*}
\paragraph{Subject to}
\begin{align*}
\text{power\_balance} && \sum_{g \in \mathcal{G}} p_{s,g} & = \ell_{s} && \forall\, s \in \mathcal{S}
\end{align*}
\paragraph{Variable domains}
\begin{align*}
\text{p} && 0 \le p_{s,g} & \le \bar p_{g} && \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0
\end{align*}
import math_spec as ms
symbols = {
'notation': 'latex',
'dimensions': {
'snapshot': {'index': 's', 'set': '\\mathcal{S}'},
'generator': {'index': 'g', 'set': '\\mathcal{G}'},
},
'names': {
'cost': 'c',
'load': '\\ell',
'p_max': '\\bar p',
},
}
spec = ms.to_spec('dispatch.yaml') # read and checked once, then printed three ways
ms.to_latex(spec, symbols=symbols) # amsmath align
ms.to_typst(spec) # compiles without a TeX toolchain
ms.to_markdown(spec) # renders as-is on GitHub
symbols is optional — drop it and the same model prints as
\(\mathit{load}_t\), \(p^{\mathrm{max}}_g\). A dict, a YAML path or a
SymbolTable; a key naming nothing in the model is an error, not a symbol that
silently never applies. Every spelling is printed verbatim — notation says
which language they are, and a render in the other one refuses.
Or from a shell, where the table is that same YAML on disk and --standalone
emits a document that compiles rather than a fragment to \input:
python -m math_spec latex dispatch.yaml --symbols dispatch.symbols.yaml
python -m math_spec typst dispatch.yaml --standalone -o dispatch.typ
The renderer is the typesetter, and it reads the same file every other page here loads.
And a consumer reads it like this#
import math_spec as ms
spec = ms.to_spec('dispatch.yaml') # schema, names, dims, degree — all checked here
sorted(spec.variables) # ['p']
program = ms.to_program(spec) # curves expanded, names typed, operators resolved to nodes
sorted(program.constraints) # ['power_balance']
Neither needs data or a solver: a repository of models can be compiled in CI
with nothing bound to any of them. The two states are the whole seam — Spec
is what the file says, Program is what it means — and a consumer that
builds reads the second.
That seam is one page, and it is the whole of it: what a program gets when it loads a model, and nothing a program does changes what the file means.
Where to next#
-
The language
What a YAML file may contain, and what it means — ten rules, ten declaration keys, one closed set of operators.
-
Every construct, as math
All of it at once, beside the notation the typesetter gives it — so the notation can be read as the one system it has to be.
-
Typeset the math
LaTeX, Typst and Markdown, the options each takes, and how a symbol table turns derived symbols into conventional ones.
-
Reading a loaded model
The contract between the language and anything that reads the AST — a solver backend, a renderer, a second front end.
-
What may enter the language
The test a candidate primitive has to pass, why capability is a second axis, and what has been refused and why.
-
Who owns a rule
A rule is language iff two consumers answering it separately would be a bug — and the sharp edge that keeps that from swallowing everything.
Install it#
git clone https://github.com/energy-models/math-spec
cd math-spec
pixi run pre-commit-install
pixi run test
Or as a dependency, once the project leaves the alpha stream — see installation for every package manager.
Alpha, pre-1.0
Breaking changes land without a deprecation cycle. When a construct is named wrong, a default is wrong, or a permissive input turns out to hide a silent wrong answer, it gets fixed rather than aliased — carrying a compatibility shim for every earlier spelling would defeat the point of a small language.
In practice: pin an exact version if you depend on this, and read the changelog before upgrading. What exists is tested — every construct the language has round-trips through the schema, the parsers and all three typeset formats, and the LaTeX is compiled rather than eyeballed. It is the surface that is not yet frozen, not the behaviour.