Document notation: the Phase 0 tables, as data
The signed-off notation for ESM source documents (.md with LaTeX math, .typ with
Typst math). This module is DATA ONLY: the operator tiers, the precedence levels, the
name rules, where each schema field lives in a document, and the accepted variants.
The printers, parsers and round-trip theorems of the later phases are stated against
these tables, so a notation change is a change here first.
“Lossless” for documents means equality after the canonical form (key order, number
spelling, unit spelling). Numbers follow CONFORMANCE §5.5.3.1: a literal’s type is
fixed by its value, so an integral Int64-representable 1.0 IS the integer 1. The canonical
form gains spelling rules for the redundant spellings listed in spellingRules; each
gets a meaning-preservation proof when it lands.
Decisions of record (2026-11-13):
- Redundant spellings are normalized in the canonical form, not kept apart in notation.
negandpoware deprecated: loaders warn and rewrite them to unary-and^; the §5.4 algebraic canonicalizer emits-xinstead ofneg(x).- Chemical-formula-shaped names print as formulas everywhere, with no package.
- Reactions print with the rate over the arrow, one line per reaction,
idas its label. There is no reaction-table form, on output or input. - Template arguments are positional when the parameter list is known, named otherwise.
- Index subscripts are italic expressions; the subscript part of a name is upright;
an ambiguous digit subscript prints the index in brackets (
k[1]). - Expression-valued fields inside blocks are math strings; an equation’s
_commentis a comment line inside its math. - Models and reaction systems keep separate directives.
Operators (spec §4.2)
inductive Tier where
| native
| call
| escape
| deprecated
deriving DecidableEq, Repr
How an operator is written.
native: conventional notation.call:\operatorname{name}(…)in LaTeX,op("name")(…)in Typst. Every open (rewrite-target) operator and every template application also uses this form, with node fields such asdimas named arguments after a semicolon.escape: the generic node form,\esm{op}{fields}{args}/esm.node("op", fields)[args], whose arguments are still ordinary math.
A JSON string among a node’s fields prints in LaTeX as \text{"…"} (decided 2026-11-13, so
that KaTeX in strict mode renders it): printable ASCII as itself, _, {, } as \_,
\{, \}, and every other character as {\char NNN} (three decimal digits below 1000,
seven above), so no $ or line break reaches the LaTeX. The earlier spelling, the string
double-quoted with JSON escapes in math mode, is still read. In Typst the string is a code
string, #"…", which Typst sets whatever it holds.
deprecated: read on input with a warning, rewritten, never printed.
structure OpRow where
op : String
/-- Which nodes with this `op` the row covers; empty when it covers all of them. -/
shape : String := ""
tier : Tier
latex : String
typst : String
deriving Repr
One core operator, or one shape of a core operator when its notation depends on
its fields (D with wrt: "t" vs a spatial wrt, faq with the plain semirings vs
the rest, a small vs a large array const).
def coreOps : List OpRow
Every evaluable-core operator of spec §4.2, by notation tier.
def coreOpNames : List String
The evaluable-core operator names (spec §4.2). scripts/formal-audit.py notation
checks this list against the operator tables of spec §4.2, so an operator added to the
spec fails that audit until it is listed here, and then coreOps_covers
fails until it
has a row.
def annotatedNodeFields : List String
Any node carrying id or expect_cadence prints in its normal notation wrapped
in an annotation (the escape tier’s wrapper), whatever its operator.
theorem coreOps_covers : ∀ o ∈ coreOpNames, ∃ r ∈ coreOps, r.op = o
Every core operator has at least one notation row.
theorem coreOps_sound : ∀ r ∈ coreOps, r.op ∈ coreOpNames
Every row is for a core operator.
def deprecatedOps : List String
A deprecated operator has exactly one row, and it is never printed.
Precedence (spec §4.1.1), loosest first
Fractions, roots, subscripts, cases and big operators are atoms. A right operand at
the same level is parenthesized unless it is the same n-ary +, *, and or or.
A negated sum or difference keeps its parentheses.
def precedence : List PrecLevel
"unary -" names negation, to keep it apart from binary -.
Names
inductive NameShape where
/-- One letter, or a Greek letter name: `T`, `lambda`. Prints italic. -/
| single
/-- A single letter or Greek name, `_`, then the rest: `T_air`, `k_1`. The rest is
an upright subscript. -/
| subscripted
/-- A sequence of at least two element-symbol and count tokens: `NO2`, `CH2O`.
Prints upright with subscripted counts. -/
| formula
/-- Any other multi-letter name: `half_life`, `RT`, `k1`. Prints italic as one word
(ISO 80000-2). -/
| word
/-- The literal name in full, `\mathit{…}` / `italic("…")`, accepted on input for ANY
name and read as that name. It is how an author resolves a juxtaposition the parser
rejects: with `R`, `T` and `RT` all declared, bare `RT` is refused, and the author
writes `\mathit{RT}` for the name or `R T` for the product. For a `word` name it is the
same text as the printed form; for the other shapes it differs (`T_air` prints
`T_{\mathrm{air}}`, and `\mathit{T\_air}` reads back as the same name). -/
| explicit
deriving DecidableEq, Repr
The shapes a declared name can take. The printer decides the shape from the name
alone. single, subscripted, formula and word partition the names and each keeps
every character of the name, so two distinct names never print alike (a Phase 2 lemma);
the printer never needs explicit.
def indexCollisionForm : String
A scoped reference (Chem.O3) prints each segment by its own rule, joined by ..
Index subscripts are italic expressions (A_{i, j}); the subscript of a name is upright
text. A digit subscript that could be either (k_{1} for the name k_1 or index 1 of
an arrayed k) prints the index in brackets, k[1].
Literals and units
def scientificLatex : String
Numbers print in the CONFORMANCE §5.5.3.1 canonical spelling: an integral
Int64-representable value is an integer with no point (1.0 prints as 1); any other
finite value prints its shortest round-trip digits, positional for decimal exponents
-4 to 5 and scientific otherwise. In math, scientific notation is exactly
<decimal> \times 10^{<integer>}, read back as one literal (with any spacing, and from an
integer mantissa too); a real product with a power of ten prints with \cdot. A negative literal prints as -3; unary minus on a
positive literal prints as -(3). A const with units prints as the number, a thin
space, then the canonical unit spelling upright: LaTeX 3\,\mathrm{m/s}, Typst one
upright("sym")^(exp) per unit factor, factors separated by thin (Typst sets adjacent
upright texts with no space), positive exponents first. No unit package is used.
The forms, their parsers and round-trip theorems are Esm.Units.Typeset
.
Reactions
mhchem \ce{…} in Markdown, typsium #ce[…] in Typst, the rate over the arrow. The
printer writes a restricted spelling both packages read correctly: every
stoichiometric coefficient and every non-formula species name is quoted as math, and
an empty side is the empty-set symbol. Names inside the quoted math follow the name rules
(the rate k_1 is k_{\mathrm{1}}, the word name SO4_aer is \mathit{SO4\_aer}); a
formula-shaped species such as NO2 is left to the package.
The line (Esm.Document.Source.Reaction
, decided 2026-11-13 with the native surfaces):
- Markdown:
$$\ce{…}$$ {#id key=value …}on one line. To remark-math this is a paragraph holding inline math with a two-dollar fence (not display math, which needs$$on lines of its own), and a renderer that does not know the label shows{#id …}as text. The fence is two dollars because single$…$would close at the first$of a quoted piece. The arrow label is braced,->[{$k$}], since mhchem ends the label at the first](an indexk[1]or a\left[). The label holds theidand the reaction’s other fields as attributes, in the declaration attribute syntax. - Typst:
#ce[…] <id>; a reaction with other fields is#reaction(key: value, …)[#ce[…]] <id>. - A coefficient of 1 is left out. A term without
stoichiometry, a side[], a reaction withoutid,substrates,productsorrate, or an id outside letters, digits,_,-,.and:keeps the wholereactionsfield a path block. - The id is part of the reaction line, not a skipped label: removing it changes the parse.
- What the packages would misread keeps the field a block: an escaped brace
\{or\}anywhere inside\ce{…}(mhchem matches braces by character), and//or/*anywhere inside#ce[…](a Typst comment would swallow the rest of the line).
Document skeleton
Every schema field has exactly one home. A field with no row falls back to a block keyed by its JSON path, so a field added to the schema later round-trips with no grammar change.
Templates and rules
Esm.Document.Source.Skeleton
(Tm), decided 2026-11-13 with the native surfaces:
- A template is a display whose math is its header, then
\coloneqq(:=in Typst), then its body. The header is\operatorname{name}(p, q): the name as in a template application (\operatorname{}withescT), the parameters as names under the empty symbol table, separated by,,()when there are none. Typst:op("name")(p, q), the name a quoted string. - A rule is
\text{rule } \operatorname{name}: \; lhs \Rightarrow rhsin a Markdown display; in Typst#rule("name", $lhs$, $rhs$, key: value, …). Its parameters are not in the header:paramsis an attribute likewhereandpriority. - The lexer reads the header (and splits a rule at its first
\Rightarrow); only the body and the left-hand side are math, read under the component’s symbol table (or, at the top level of a library file, the document’s index sets and templates) with the parameters declared. The symbol table takes each template’s parameter list from its header, never from its math. - Every other field is an attribute. Markdown: the display sits in a container directive,
:::template{description="…"}or:::rule{params='["u"]' priority=1}, then the display, then a line:::(attributes after the closing$$would keep remark-math from closing the display). Typst: named arguments (a template with fields is#template(…)[$ … $]). - A template or rule prints natively only when its header reads back as written (checked
by printing it and reading it back) and its math has no
$or line break; otherwise the wholeexpression_templatesfield is one path block. A display whose math would read as a template header is never printed as an equation (the equations field falls back).
def skippedConstructs : List String
Skipped by the parser: prose outside these constructs, Typst <labels> and @refs,
Markdown {#id} after an equation. The Phase 3 theorem says inserting or removing them
never changes the parse. A reaction’s id label ({#id} after its $$\ce{…}$$, <id>
after its #ce[…]) is not skipped: it is part of the reaction line.
Accepted variants
Spellings the parser reads and normalizes; the printer never writes them. Each math
variant has a Phase 2 lemma in Esm.Document.Math.Variants
(the variant parses to the
same JSON as the canonical spelling) and hand-written checks in
Esm.Document.Math.HandWritten
. The document-layer rows have theirs in the Phase 3
document layer: the declarations table in Esm.Document.Source.Markdown
(lex_table),
and aligned blocks, continuation rows and row breaks nested in a delimiter in
Esm.Document.Source.MultiLine
(cookRows_layout; ckMd_aligned in
Esm.Document.Source.MarkdownDoc
for a Markdown aligned display); the reaction rows in
Esm.Document.Source.Reaction
(pTerm_coef, pSpecies_math), and the container row in
Esm.Document.Source.Markdown
(contBody_many). Unquoted upright(e) lexes to the token of
upright("e") (variant_upright_e); named template arguments in any order read sorted
(variant_named_args); a native operator in the call form reads as its native node
(variant_native_call). Each gets a Phase 4 fixture. rejected rows pin spellings the
parser refuses (the reaction table: rateTable in Esm.Document.Source.Markdown
).
Canonical-form spelling rules
Redundant spellings the canonical form merges (landing with the canonical unit spelling).
esm-lean canon (spellRules in Adapter/Doc.lean) applies the neg and pow rules and
respells every units and default_units string (Esm.Units.canon
, under which "",
"1" and "dimensionless" are all "1"); a const node whose units spell "1" drops
them, so one whose value is a number is that bare number, whatever other fields it has
(ruling 2026-11-14; a declaration keeps units: "1").
Only the unit respelling is proved: Esm.Units.canon
keeps the unit a string resolves to
(resolve_canon, resolve_of_canon in Esm.Units.Canonical
). The other rules, and the
walk that decides which fields are units strings and which objects are name-keyed maps or
free-form (key lists copied from esm-schema.json), are adapter code with no proof that they
preserve meaning. They are pinned instead by the golden files every binding and esm-lean
are checked against: tests/conformance/canonical_document/ and
tests/conformance/document_format/.