@ceccec/millennium-solutions 0.1.1 โ†’ 9.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.zenodo.json +33 -9
  2. package/ACCOUNTING.md +7 -7
  3. package/AXIOMS.md +559 -0
  4. package/CHALLENGES.md +2181 -2479
  5. package/CITATION.cff +1 -1
  6. package/DEPLOY.md +2 -2
  7. package/DEVELOP.md +1 -1
  8. package/LICENSE +6 -5
  9. package/PRIOR-ART.md +149 -0
  10. package/README.md +221 -123
  11. package/SIGNATURE.md +2 -2
  12. package/TRIAL.md +69 -0
  13. package/WHITEPAPER.md +5 -5
  14. package/captain.md +2 -2
  15. package/compare.md +4 -4
  16. package/compute.md +9 -0
  17. package/dashboard.md +1 -1
  18. package/examples.md +5 -5
  19. package/forensic.md +67 -0
  20. package/guide.md +5 -5
  21. package/index.md +209 -139
  22. package/package.json +88 -9
  23. package/paper.md +7183 -0
  24. package/quantum.md +42 -0
  25. package/rights.md +39 -0
  26. package/solutions.md +7 -7
  27. package/speedup.md +4 -4
  28. package/src/0/index.ts +12 -2
  29. package/src/1/index.ts +1 -1
  30. package/src/2/frequency-scales.ts +13 -1
  31. package/src/2/index.ts +1 -1
  32. package/src/3/index.ts +1 -1
  33. package/src/4/index.ts +1 -1
  34. package/src/5/index.ts +1 -1
  35. package/src/6/index.ts +1 -1
  36. package/src/7/index.ts +1 -1
  37. package/src/9/funding.ts +2 -2
  38. package/src/api/gates.ts +117 -0
  39. package/src/api/index.ts +488 -0
  40. package/src/api/lanes.ts +81 -0
  41. package/src/demand/queries.json +1764 -0
  42. package/src/face/index.ts +100 -0
  43. package/src/html/index.ts +10 -0
  44. package/src/latex/index.ts +474 -0
  45. package/src/millennium/index.ts +53 -0
  46. package/src/proof/README.md +1 -1
  47. package/src/proof/address.lean +117 -0
  48. package/src/proof/coin.lean +148 -0
  49. package/src/proof/covered.json +9 -0
  50. package/src/proof/demand.lean +112 -0
  51. package/src/proof/demand2.lean +171 -0
  52. package/src/proof/demand3.lean +117 -0
  53. package/src/proof/discovered.json +10965 -1864
  54. package/src/proof/elementary.lean +393 -0
  55. package/src/proof/energy.lean +215 -0
  56. package/src/proof/families.lean +435 -0
  57. package/src/proof/fixtures/axiom-control.lean +39 -0
  58. package/src/proof/fnv.lean +99 -0
  59. package/src/proof/generated-theorems.json +207 -0
  60. package/src/proof/generated.lean +101 -0
  61. package/src/proof/imagined.lean +492 -0
  62. package/src/proof/index.lean +98 -18
  63. package/src/proof/index.ts +2 -2
  64. package/src/proof/involution.lean +90 -0
  65. package/src/proof/ledgerclaims.lean +84 -0
  66. package/src/proof/light.lean +135 -0
  67. package/src/proof/mechanical.lean +421 -0
  68. package/src/proof/merkaba.lean +89 -0
  69. package/src/proof/merkle.lean +132 -0
  70. package/src/proof/nim.lean +105 -0
  71. package/src/proof/phenomena.lean +90 -0
  72. package/src/proof/priorart.lean +189 -0
  73. package/src/proof/quantum.lean +161 -0
  74. package/src/proof/reach.lean +84 -0
  75. package/src/proof/recovered.lean +56 -0
  76. package/src/proof/reversal.lean +73 -0
  77. package/src/proof/rights.lean +157 -0
  78. package/src/proof/sequences.lean +105 -0
  79. package/src/proof/speed.lean +129 -0
  80. package/src/proof/split.lean +180 -0
  81. package/src/proof/theorems.lean +48 -1
  82. package/src/proof/trial-all.json +15260 -0
  83. package/src/proof/z9.lean +92 -0
  84. package/src/proof/z9plus.lean +208 -0
  85. package/src/proofs.lisp +3 -3
  86. package/src/prove/emit.ts +115 -0
  87. package/src/prove/index.ts +143 -0
  88. package/src/prove/translate.ts +638 -0
  89. package/src/publication/index.ts +363 -0
  90. package/src/quantum/field.ts +73 -0
  91. package/src/quantum/tree.ts +79 -0
@@ -0,0 +1,100 @@
1
+ // THE FACE โ€” the shape a repository publishes so a sibling session can check it instead of believing it.
2
+ //
3
+ // A leaf: it imports only the content-address primitives, so scripts/mcp.ts can use it without executing
4
+ // scripts/metrics.ts, whose top level RUNS the gates. Sharing a type by importing the module that produces
5
+ // it would mean every MCP call ran six gates.
6
+ //
7
+ // The whole protocol is two fields. `command` makes a row reproducible rather than merely quotable, and
8
+ // `receipt` is taken over the row's OWN contents so any holder can recompute it from the file alone. What
9
+ // that proves is INTEGRITY โ€” the row says now what it said when sealed โ€” and nothing more. It does not make
10
+ // a figure correct, and it does not let a reader verify another repository's numbers without its source.
11
+ import { toUuid, merkleFold } from '../0/index.ts'
12
+
13
+ export type Metric = { key: string; claim: string; value: string; command: string; receipt: string }
14
+ export type Face = { repo: string; definition: string; protocol?: unknown; generatedFrom?: string; specWitness?: { seed: string; receipt: string }; rows: Metric[]; root: string }
15
+
16
+ /** THE FORMULA, WRITTEN DOWN AS DATA, because leaving it implicit made a sibling look tampered-with and
17
+ * writing it as prose let it go stale without anything noticing.
18
+ *
19
+ * Version 1 was one English sentence. erpax-94 replied with a structured spec โ€” field list, merge function
20
+ * with its separator named by codepoint, the RFC clause for the address, the fold's odd and empty cases โ€”
21
+ * and reading it against mine found two defects that a prose string had hidden:
22
+ *
23
+ * 1. `command` WAS NOT SEALED. The header of this file says `command` is what makes a row reproducible
24
+ * rather than merely quotable, and the receipt covered key, claim and value only. The instruction for
25
+ * how to recompute a figure could be altered and every receipt, and the root, would still verify. A
26
+ * reader following the altered command measures the wrong thing and concludes the row is confirmed.
27
+ * erpax covers it; version 2 covers it.
28
+ *
29
+ * 2. THE ROOT DOES NOT BIND ROW ORDER, and version 1 said it did โ€” "merkleFold(receipts in row order)".
30
+ * merkleFold SORTS its leaves. Measured: reversing every row of this repository's own face leaves the
31
+ * root identical and checkFace returns `intact`. A peer implementing the sentence as written would
32
+ * produce a different root and be reported as another convention, which is the exact accusation this
33
+ * constant exists to prevent โ€” the sentence was the unreliable part.
34
+ *
35
+ * The sort is kept, not fixed. Order-invariance by canonicalisation is the deposit's own established
36
+ * position โ€” quantum.lean proves it as `the_invariance_is_canonicalisation_not_physics` โ€” and rows here
37
+ * are addressed by key, not by position. What changes is that the spec now says so.
38
+ *
39
+ * Structured rather than prose so a receiving session can compare fields instead of diffing English. */
40
+ export const PROTOCOL = {
41
+ id: 'millennium-solutions/metric-face/2',
42
+ covers: ['key', 'claim', 'value', 'command'],
43
+ chained: false,
44
+ receipt: 'toUuid(key + LF + claim + LF + value + LF + command)',
45
+ merge: "toUuid(utf8(a + ':' + b)) โ€” COLON U+003A",
46
+ // NOT SHA-256. I wrote that line today, copying the shape of a sibling's spec without checking my own
47
+ // implementation, and it is false: src/0 hashes with FNV-1a, not SHA-2. erpax-94 then spent an audit
48
+ // trying 242 candidate SEEDS against row 0 โ€” four field orders, eight separators, five wrappers,
49
+ // canonical and as-emitted JSON โ€” and none could ever have matched, because the seed was already correct
50
+ // and the HASH was wrong. Their sha256 of my exact declared seed gives c2cc6344; my receipt begins
51
+ // f5c2a766. They localised it to "a different seed, not a different hash" and were right that we agreed
52
+ // on how bytes become an address; the disagreement was one layer below where either of us looked.
53
+ //
54
+ // This is precisely the defect the PROTOCOL constant exists to prevent โ€” a spec another repo cannot
55
+ // reproduce โ€” committed in the constant itself. It is also, for the second time today, the deposit
56
+ // describing a stronger property than it has: FNV-1a is a non-cryptographic hash, and naming SHA-256
57
+ // implies collision resistance these addresses do not carry.
58
+ address: 'uuidv8 shaped per RFC 9562 ยง5.8 (version nibble 8, variant 10x) over FNV-1a โ€” NOT SHA-2: '
59
+ + 'four 32-bit FNV-1a passes over the UTF-16 code units, each starting at the FNV OFFSET BASIS 0x811c9dc5 '
60
+ + 'XOR the pass seed โ€” seeds 0, 0x9e3779b9, 0x243f6a88, 0xb7e15162, so h begins at (0x811c9dc5 ^ seed) and '
61
+ + 'NOT at the seed itself, which is the one line an implementer cannot guess. '
62
+ + 'with an xor-shift-13 per code unit and a two-round murmur finalizer (0x85ebca6b, 0xc2b2ae35, then >>> 16), '
63
+ + 'concatenated big-endian to 16 octets. Non-cryptographic: integrity against accident, not against an '
64
+ + 'adversary.',
65
+ // ONE ROW, REPRODUCIBLE IN A SINGLE STEP. erpax-94's suggestion, taken: a reader computes this seed and
66
+ // compares with the receipt beside it. If the two disagree, the spec has drifted from the code that wrote
67
+ // the file โ€” which `generatedFrom` cannot tell them, because that says the FILE is current, not that the
68
+ // SPEC is current with the emitter.
69
+ specWitness: 'see the specWitness field on the face itself: the verbatim seed of row 0 and its receipt',
70
+ root: 'merkleFold: leaves sorted lexicographically, then pairwise merge up the tree; an odd element '
71
+ + "carries up unchanged; zero rows fold to toUuid('empty-mind')",
72
+ orderInvariant: true,
73
+ } as const
74
+
75
+ /** A peer may send its protocol as a structured object or, like version 1 of this one, as a sentence. */
76
+ export const protocolId = (p: unknown): string =>
77
+ typeof p === 'string' ? p : (p && typeof p === 'object' && 'id' in p ? String((p as { id: unknown }).id) : '(none declared)')
78
+
79
+ export const receiptOf = (key: string, claim: string, value: string, command = ''): string =>
80
+ toUuid(key + '\n' + claim + '\n' + value + '\n' + command)
81
+
82
+ export const rootOf = (rows: Metric[]): string => merkleFold(rows.map((r) => r.receipt))
83
+
84
+ export type Verdict = 'intact' | 'altered' | 'different-convention'
85
+
86
+ /** Recompute every receipt and the root from the rows themselves. Integrity only โ€” see the header.
87
+ *
88
+ * EVERY ROW FAILING IS NOT TAMPERING. Someone altering a face changes a row or two; a face sealed under
89
+ * another formula fails at every row AND at the root, which is a signature of its own. The two are
90
+ * reported as different verdicts because accusing a peer of tampering is not a thing to get wrong. */
91
+ export function checkFace(f: Face): { verdict: Verdict; ok: boolean; altered: string[]; root: string; failingGates: string[] } {
92
+ const altered = f.rows.filter((r) => receiptOf(r.key, r.claim, r.value, r.command) !== r.receipt).map((r) => r.key)
93
+ const root = rootOf(f.rows)
94
+ const rootMatches = root === f.root
95
+ const wholesale = f.rows.length > 1 && altered.length === f.rows.length && !rootMatches
96
+ const stated = f.protocol !== undefined && protocolId(f.protocol) !== PROTOCOL.id
97
+ const verdict: Verdict = altered.length === 0 && rootMatches ? 'intact'
98
+ : (wholesale || stated) ? 'different-convention' : 'altered'
99
+ return { verdict, ok: verdict === 'intact', altered, root, failingGates: f.rows.filter((r) => r.value === 'FAIL').map((r) => r.key) }
100
+ }
@@ -0,0 +1,10 @@
1
+ // HTML escaping, once.
2
+ //
3
+ // Three byte-identical copies of this lived in scripts/atom-feed.ts, scripts/paper.ts and src/latex โ€” and a
4
+ // fourth, in scripts/rights.ts, escaped `&` and `<` but not `>`. Four copies of a one-line function is four
5
+ // chances for one of them to be the odd one out, which is exactly what had already happened.
6
+ //
7
+ // This is a LEAF: it imports nothing, so src/latex โ€” a pure module with no filesystem dependency โ€” can use
8
+ // it without acquiring one.
9
+ export const escapeHtml = (s: string): string =>
10
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -0,0 +1,474 @@
1
+ // LEAN โ†’ LATEX (and MathML), for the subset this deposit's statements actually use.
2
+ //
3
+ // The paper printed every statement as verbatim Lean. That is honest and it is hard to read: a reader who
4
+ // wants the mathematics has to parse `(List.range' 1 9).all (fun d => (List.range' 1 9).any (fun e =>
5
+ // M9 (d + e) == 0))` in their head. This translates that to โˆ€ d โˆˆ {1,โ€ฆ,9}, โˆƒ e โˆˆ {1,โ€ฆ,9} : M9(d+e) = 0.
6
+ //
7
+ // A TRANSLATION CAN LIE, so this one is built to refuse rather than approximate:
8
+ //
9
+ // ยท It is a real parser, not a chain of regex substitutions. A substitution pass silently produces
10
+ // plausible-looking output for input it did not understand, which is the worst possible failure here โ€”
11
+ // a formula that reads as mathematics and is not the theorem.
12
+ // ยท Parsing is TOTAL or it FAILS. Every token must be consumed and every construct must be one this
13
+ // grammar knows; anything else throws, and the caller prints the verbatim Lean instead. There is no
14
+ // partial rendering.
15
+ // ยท The verbatim Lean stays on the page next to the typeset form. The kernel checked the Lean, not this.
16
+ // The rendering is a reading aid and is labelled as one.
17
+ //
18
+ // The vocabulary was derived by counting what the 460 statements contain, not guessed: `.all` (280),
19
+ // `.contains` (124), `.length` (120), `List.range` (113), `.map` (96), `List.range'` (82), `.eraseDups`
20
+ // (74), `.filter` (36), `.any` (33), `.foldl` (27), and the arithmetic and logical operators.
21
+
22
+ import { escapeHtml } from '../html/index.ts'
23
+
24
+ // โ”€โ”€ tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
25
+ type Tok = { k: 'num' | 'id' | 'op'; v: string }
26
+
27
+ const OPS3 = ['>>>', '<<<']
28
+ const OPS2 = ['==', '!=', '<=', '>=', '=>', '++', '&&', '||', ':=']
29
+ const OPS1 = '+-*/%^<>=()[],.ยฌโˆงโˆจโ†’โ†”โ‰ โ‰คโ‰ฅยท!:;'
30
+
31
+ export function lex(src: string): Tok[] {
32
+ const out: Tok[] = []
33
+ let i = 0
34
+ while (i < src.length) {
35
+ const c = src[i]
36
+ if (/\s/.test(c)) { i++; continue }
37
+ if (/[0-9]/.test(c)) {
38
+ let j = i; while (j < src.length && /[0-9]/.test(src[j])) j++
39
+ out.push({ k: 'num', v: src.slice(i, j) }); i = j; continue
40
+ }
41
+ if (/[A-Za-z_]/.test(c)) {
42
+ // A DOT JOINS THE NAME ONLY FOR A QUALIFIED CONSTANT โ€” one whose namespace is capitalised, as
43
+ // Lean writes them: List.range, Families.gcd'. Treating any dot as part of the name swallowed
44
+ // `axis.contains d` into one identifier and rendered it `axis.contains(d)` instead of `d โˆˆ axis`:
45
+ // a silently plausible formula that was not the theorem, which is the one failure this module
46
+ // exists to prevent. A lowercase receiver ends the identifier, so the dot becomes a method call.
47
+ const qualified = /[A-Z]/.test(c)
48
+ let j = i
49
+ while (j < src.length && (/[A-Za-z0-9_']/.test(src[j]) || src[j] === '!' || src[j] === '?' || (qualified && src[j] === '.' && /[A-Za-z_]/.test(src[j + 1] ?? '')))) j++
50
+ out.push({ k: 'id', v: src.slice(i, j) }); i = j; continue
51
+ }
52
+ const three = src.slice(i, i + 3), two = src.slice(i, i + 2)
53
+ if (OPS3.includes(three)) { out.push({ k: 'op', v: three }); i += 3; continue }
54
+ if (OPS2.includes(two)) { out.push({ k: 'op', v: two }); i += 2; continue }
55
+ if (OPS1.includes(c)) { out.push({ k: 'op', v: c }); i++; continue }
56
+ throw new Error('lex: unexpected character ' + JSON.stringify(c))
57
+ }
58
+ return out
59
+ }
60
+
61
+ // โ”€โ”€ AST โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
62
+ export type Node =
63
+ | { t: 'num'; v: string }
64
+ | { t: 'id'; v: string }
65
+ | { t: 'bin'; op: string; l: Node; r: Node }
66
+ | { t: 'un'; op: string; e: Node }
67
+ | { t: 'app'; f: Node; a: Node }
68
+ | { t: 'dot'; o: Node; name: string }
69
+ | { t: 'lam'; ps: string[]; b: Node }
70
+ | { t: 'list'; xs: Node[] }
71
+ | { t: 'ite'; c: Node; a: Node; b: Node }
72
+ | { t: 'hole'; op: string } // (ยท + ยท) โ€” the operator section
73
+ | { t: 'asc'; e: Node; ty: Node } // (1 : Int) โ€” a type ascription; the value is unchanged
74
+ | { t: 'tuple'; xs: Node[] } // (a, b)
75
+ | { t: 'proj'; o: Node; i: string } // p.1, p.2 โ€” tuple projection
76
+ | { t: 'let'; name: string; e: Node; b: Node } // let x := e; body
77
+
78
+ const KEYWORD = new Set(['then', 'else', 'fun', 'if', 'let'])
79
+
80
+ class P {
81
+ i = 0
82
+ ts: Tok[]
83
+ // An explicit field, not a parameter property: this repo runs .ts through node directly, and node's
84
+ // strip-only TypeScript mode rejects `constructor(private ts: Tok[])`.
85
+ constructor(ts: Tok[]) { this.ts = ts }
86
+ peek(o = 0) { return this.ts[this.i + o] }
87
+ is(v: string, o = 0) { const t = this.peek(o); return !!t && t.v === v }
88
+ eat(v: string) { if (!this.is(v)) throw new Error('expected ' + v + ' got ' + (this.peek()?.v ?? 'EOF')); this.i++ }
89
+ done() { return this.i >= this.ts.length }
90
+
91
+ // Precedence, loosest first. Lean's real table is larger; this covers exactly what the statements use.
92
+ expr(): Node {
93
+ // `let x := e; body` โ€” a local binding. Ten statements use it, and it is written as mathematics the way
94
+ // mathematics writes it: the body, then "where x = e". The binding is NOT substituted into the body;
95
+ // substituting would show the reader an expression the source does not contain.
96
+ if (this.is('let')) {
97
+ this.i++
98
+ const n = this.peek(); if (!n || n.k !== 'id') throw new Error('expected a name after let')
99
+ this.i++; this.eat(':='); const e = this.expr(); this.eat(';')
100
+ return { t: 'let', name: n.v, e, b: this.expr() }
101
+ }
102
+ return this.iff()
103
+ }
104
+ iff(): Node { let l = this.implies(); while (this.is('โ†”')) { this.i++; l = { t: 'bin', op: 'โ†”', l, r: this.implies() } } return l }
105
+ implies(): Node { let l = this.or(); if (this.is('โ†’')) { this.i++; return { t: 'bin', op: 'โ†’', l, r: this.implies() } } return l }
106
+ or(): Node { let l = this.and(); while (this.is('โˆจ') || this.is('||')) { this.i++; l = { t: 'bin', op: 'โˆจ', l, r: this.and() } } return l }
107
+ and(): Node { let l = this.cmp(); while (this.is('โˆง') || this.is('&&')) { this.i++; l = { t: 'bin', op: 'โˆง', l, r: this.cmp() } } return l }
108
+ cmp(): Node {
109
+ let l = this.shift()
110
+ while (['==', '=', '!=', 'โ‰ ', '<=', 'โ‰ค', '>=', 'โ‰ฅ', '<', '>'].some((o) => this.is(o))) {
111
+ const op = this.peek()!.v; this.i++; l = { t: 'bin', op, l, r: this.shift() }
112
+ }
113
+ return l
114
+ }
115
+ shift(): Node { let l = this.add(); while (this.is('>>>') || this.is('<<<')) { const op = this.peek()!.v; this.i++; l = { t: 'bin', op, l, r: this.add() } } return l }
116
+ add(): Node { let l = this.mul(); while (this.is('+') || this.is('-') || this.is('++')) { const op = this.peek()!.v; this.i++; l = { t: 'bin', op, l, r: this.mul() } } return l }
117
+ mul(): Node { let l = this.pow(); while (this.is('*') || this.is('/') || this.is('%')) { const op = this.peek()!.v; this.i++; l = { t: 'bin', op, l, r: this.pow() } } return l }
118
+ pow(): Node { const l = this.unary(); if (this.is('^')) { this.i++; return { t: 'bin', op: '^', l, r: this.pow() } } return l }
119
+ unary(): Node { if (this.is('ยฌ') || this.is('!')) { this.i++; return { t: 'un', op: 'ยฌ', e: this.unary() } } if (this.is('-')) { this.i++; return { t: 'un', op: '-', e: this.unary() } } return this.appl() }
120
+
121
+ /** Application is juxtaposition: `M9 (d + e)`, `List.range 9`, `refl (refl d)`. */
122
+ appl(): Node {
123
+ let f = this.post()
124
+ while (!this.done() && this.startsAtom()) f = { t: 'app', f, a: this.post() }
125
+ return f
126
+ }
127
+ startsAtom(): boolean {
128
+ const t = this.peek()!
129
+ // `then` and `else` lex as identifiers, so juxtaposition happily consumed them as arguments and the
130
+ // whole `if _ then _ else _` failed to parse. They terminate an application instead.
131
+ if (t.k === 'id') return !KEYWORD.has(t.v)
132
+ if (t.k === 'num') return true
133
+ return t.v === '(' || t.v === '['
134
+ }
135
+ post(): Node {
136
+ let e = this.atom()
137
+ while (this.is('.')) {
138
+ this.i++
139
+ const n = this.peek()
140
+ if (!n) throw new Error('expected method name')
141
+ // `p.1` is a projection, not a method โ€” the dot is followed by a numeral.
142
+ if (n.k === 'num') { this.i++; e = { t: 'proj', o: e, i: n.v }; continue }
143
+ if (n.k !== 'id') throw new Error('expected method name')
144
+ this.i++; e = { t: 'dot', o: e, name: n.v }
145
+ }
146
+ return e
147
+ }
148
+ atom(): Node {
149
+ const t = this.peek()
150
+ if (!t) throw new Error('unexpected end of statement')
151
+ if (t.k === 'num') { this.i++; return { t: 'num', v: t.v } }
152
+ if (t.v === 'fun') {
153
+ // SEVERAL PARAMETERS. `fun acc i => โ€ฆ` binds two; reading only the first left the second where the
154
+ // arrow was expected, which is the "expected => got i" this grammar used to fail on. Curried into
155
+ // nested lambdas, which is what Lean means by it.
156
+ this.i++
157
+ const ps: string[] = []
158
+ while (this.peek() && this.peek()!.k === 'id' && !this.is('=>')) { ps.push(this.peek()!.v); this.i++ }
159
+ if (!ps.length) throw new Error('expected lambda parameter')
160
+ this.eat('=>')
161
+ // The parameter LIST is kept, not curried into nested lambdas. Currying is what Lean means, but
162
+ // unparse then writes back an extra `fun` and `=>` per parameter and the round-trip reports a loss
163
+ // that never happened. The tree records what was written.
164
+ return { t: 'lam', ps, b: this.expr() }
165
+ }
166
+ if (t.v === 'if') {
167
+ this.i++; const c = this.expr(); this.eat('then'); const a = this.expr(); this.eat('else'); const b = this.expr()
168
+ return { t: 'ite', c, a, b }
169
+ }
170
+ if (t.k === 'id') { this.i++; return { t: 'id', v: t.v } }
171
+ if (t.v === '(') {
172
+ this.i++
173
+ // the operator section (ยท + ยท) โ€” a lambda written in Lean's dot notation
174
+ if (this.is('ยท')) {
175
+ this.i++; const op = this.peek(); if (!op || op.k !== 'op') throw new Error('expected operator in section')
176
+ this.i++; this.eat('ยท'); this.eat(')')
177
+ return { t: 'hole', op: op.v }
178
+ }
179
+ const e = this.expr()
180
+ // `(1 : Int)` โ€” an ascription names the type and leaves the value alone. The type is KEPT in the tree
181
+ // so unparse can put it back; only the rendering drops it, because a reader of the mathematics wants
182
+ // the value and the round-trip wants every token.
183
+ if (this.is(':')) { this.i++; const ty = this.expr(); this.eat(')'); return { t: 'asc', e, ty } }
184
+ if (this.is(',')) {
185
+ const xs = [e]
186
+ while (this.is(',')) { this.i++; xs.push(this.expr()) }
187
+ this.eat(')'); return { t: 'tuple', xs }
188
+ }
189
+ this.eat(')'); return e
190
+ }
191
+ if (t.v === '[') {
192
+ this.i++
193
+ const xs: Node[] = []
194
+ if (!this.is(']')) { xs.push(this.expr()); while (this.is(',')) { this.i++; xs.push(this.expr()) } }
195
+ this.eat(']')
196
+ return { t: 'list', xs }
197
+ }
198
+ throw new Error('unexpected token ' + JSON.stringify(t.v))
199
+ }
200
+ }
201
+
202
+ /** Parse a statement, or throw. Throwing is the point: the caller prints the Lean source instead. */
203
+ export function parse(src: string): Node {
204
+ const p = new P(lex(src))
205
+ const e = p.expr()
206
+ if (!p.done()) throw new Error('trailing tokens from ' + JSON.stringify(p.peek()!.v))
207
+ return e
208
+ }
209
+
210
+
211
+ /** `.filter f` and `.all f` pass a FUNCTION, not a lambda โ€” `instruments.filter claim`. The set-builder
212
+ * needs a bound variable the source never wrote, so one is supplied. `x` is chosen because these
213
+ * statements bind d, e, k, n, p, u and t, never x; a collision would shadow a real binder. */
214
+ const BOUND = 'x'
215
+ const applied = (f: Node, texOf: (n: Node) => string) => `${texOf(f)}\\mathopen{}\\left(${BOUND}\\right)`
216
+
217
+ // โ”€โ”€ LaTeX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
218
+ export const LATEX_NAME: Record<string, string> = { '==': '=', '=': '=', '!=': '\\neq', 'โ‰ ': '\\neq', '<=': '\\le', 'โ‰ค': '\\le', '>=': '\\ge', 'โ‰ฅ': '\\ge', '<': '<', '>': '>', '+': '+', '-': '-', '*': '\\cdot', '/': '/', '%': '\\bmod', 'โˆง': '\\land', 'โˆจ': '\\lor', 'โ†’': '\\to', 'โ†”': '\\leftrightarrow', '++': '\\mathbin{+\\!\\!+}' }
219
+
220
+ const idTex = (v: string) => (/^[a-zA-Z]$/.test(v) ? v : '\\mathrm{' + v.replace(/_/g, '\\_') + '}')
221
+
222
+ /** The domain a range denotes, as a set. List.range n = [0,โ€ฆ,n-1]; List.range' a n = [a,โ€ฆ,a+n-1]. */
223
+ const num = (n: Node): number | null => (n.t === 'num' ? Number(n.v) : null)
224
+ const rangeTex = (f: Node, args: Node[]): string | null => {
225
+ if (f.t !== 'id') return null
226
+ // With literal bounds the set is written out โ€” {1,โ€ฆ,9}, not {1,โ€ฆ,1+9-1}, which is the same set
227
+ // spelled as the arithmetic that produced it.
228
+ if (f.v === 'List.range' && args.length === 1) {
229
+ const n = num(args[0])
230
+ return n !== null ? `\\{0,\\dots,${n - 1}\\}` : `\\{0,\\dots,${tex(args[0])}-1\\}`
231
+ }
232
+ if (f.v === "List.range'" && args.length === 2) {
233
+ const a = num(args[0]), k = num(args[1])
234
+ return a !== null && k !== null ? `\\{${a},\\dots,${a + k - 1}\\}` : `\\{${tex(args[0])},\\dots,${tex(args[0])}+${tex(args[1])}-1\\}`
235
+ }
236
+ return null
237
+ }
238
+
239
+ /** Flatten an application spine into head + arguments. */
240
+ const spine = (n: Node): { head: Node; args: Node[] } => {
241
+ const args: Node[] = []
242
+ let h = n
243
+ while (h.t === 'app') { args.unshift(h.a); h = h.f }
244
+ return { head: h, args }
245
+ }
246
+
247
+ export function tex(n: Node): string {
248
+ switch (n.t) {
249
+ case 'num': return n.v
250
+ case 'id': return idTex(n.v)
251
+ case 'hole': return `(\\cdot\\,${LATEX_NAME[n.op] ?? n.op}\\,\\cdot)`
252
+ case 'un': return n.op === 'ยฌ' ? `\\lnot ${tex(n.e)}` : `-${tex(n.e)}`
253
+ case 'lam': return `${n.ps.map(idTex).join(',\\,')} \\mapsto ${tex(n.b)}`
254
+ case 'list': return `[${n.xs.map(tex).join(',\\,')}]`
255
+ case 'asc': return tex(n.e)
256
+ case 'tuple': return `\\left(${n.xs.map(tex).join(',\\,')}\\right)`
257
+ case 'proj': return `${tex(n.o)}_{${n.i}}`
258
+ case 'let': return `${tex(n.b)} \\quad \\text{where } ${idTex(n.name)} = ${tex(n.e)}`
259
+ case 'ite': return `\\begin{cases}${tex(n.a)} & \\text{if } ${tex(n.c)}\\\\ ${tex(n.b)} & \\text{otherwise}\\end{cases}`
260
+ case 'bin': {
261
+ const l = tex(n.l), r = tex(n.r)
262
+ if (n.op === '^') return `${l}^{${r}}`
263
+ if (n.op === '/') return `\\frac{${l}}{${r}}`
264
+ if (n.op === '%') return `${l} \\bmod ${r}`
265
+ if (n.op === '>>>') return `${l} \\gg ${r}`
266
+ if (n.op === '<<<') return `${l} \\ll ${r}`
267
+ return `${l} ${LATEX_NAME[n.op] ?? n.op} ${r}`
268
+ }
269
+ case 'dot': return dotTex(n, [])
270
+ case 'app': {
271
+ const { head, args } = spine(n)
272
+ const r = rangeTex(head, args)
273
+ if (r) return r
274
+ if (head.t === 'dot') return dotTex(head, args)
275
+ return `${tex(head)}\\mathopen{}\\left(${args.map(tex).join(',\\,')}\\right)`
276
+ }
277
+ }
278
+ }
279
+
280
+ /** A method call, rendered as the mathematics it means. Unknown methods throw โ€” see the header. */
281
+ function dotTex(d: Extract<Node, { t: 'dot' }>, args: Node[]): string {
282
+ const o = tex(d.o)
283
+ const lam = (a: Node | undefined) => (a && a.t === 'lam' && a.ps.length === 1 ? a : null)
284
+ switch (d.name) {
285
+ case 'all': { const f = lam(args[0])
286
+ if (f) return `\\forall ${idTex(f.ps[0])} \\in ${o},\\; ${tex(f.b)}`
287
+ if (args.length === 1) return `\\forall ${BOUND} \\in ${o},\\; ${applied(args[0], tex)}`
288
+ break }
289
+ case 'any': { const f = lam(args[0])
290
+ if (f) return `\\exists ${idTex(f.ps[0])} \\in ${o} : ${tex(f.b)}`
291
+ if (args.length === 1) return `\\exists ${BOUND} \\in ${o} : ${applied(args[0], tex)}`
292
+ break }
293
+ case 'filter': { const f = lam(args[0])
294
+ if (f) return `\\{\\, ${idTex(f.ps[0])} \\in ${o} \\mid ${tex(f.b)} \\,\\}`
295
+ if (args.length === 1) return `\\{\\, ${BOUND} \\in ${o} \\mid ${applied(args[0], tex)} \\,\\}`
296
+ break }
297
+ case 'map': { const f = lam(args[0]); if (f) return `\\{\\, ${tex(f.b)} \\mid ${idTex(f.ps[0])} \\in ${o} \\,\\}`; if (args.length === 1) return `${tex(args[0])}[${o}]` ; break }
298
+ case 'length': if (!args.length) return `\\left|${o}\\right|`; break
299
+ case 'contains': if (args.length === 1) return `${tex(args[0])} \\in ${o}`; break
300
+ case 'eraseDups': if (!args.length) return `\\operatorname{dedup}\\left(${o}\\right)`; break
301
+ case 'flatMap': { const f = lam(args[0]); if (!f) break
302
+ return `\\bigcup_{${idTex(f.ps[0])} \\in ${o}} ${tex(f.b)}` }
303
+ case 'foldr':
304
+ case 'foldl': {
305
+ // foldl (ยท + ยท) 0 is a sum and nothing else here is; anything other than that exact shape falls
306
+ // through to the throw, rather than being rendered as a sum it is not.
307
+ if (args.length === 2 && args[0].t === 'hole' && args[0].op === '+' && args[1].t === 'num' && args[1].v === '0')
308
+ return `\\sum ${o}`
309
+ // Any other fold is named rather than given a symbol it does not have: an operator and a seed,
310
+ // applied along the list. Inventing โˆ‘-like notation for an arbitrary combiner would say more than
311
+ // the statement does.
312
+ if (args.length === 2) return `\\operatorname{fold}_{${tex(args[0])}}\\left(${o},\\, ${tex(args[1])}\\right)`
313
+ break
314
+ }
315
+ case 'get!': if (args.length === 1) return `${o}_{${tex(args[0])}}`; break
316
+ case 'head?': if (!args.length) return `\\operatorname{head}\\left(${o}\\right)`; break
317
+ case 'getLast?': if (!args.length) return `\\operatorname{last}\\left(${o}\\right)`; break
318
+ // Option's two predicates. `(invOf d).isSome` is how a statement says "an inverse EXISTS", and without
319
+ // notation for it the whole site build died on the one theorem that said so โ€” see the note in
320
+ // src/publication/index.ts. Rendered as the existence it means, not as the method it is spelled with.
321
+ case 'isSome': if (!args.length) return `\\exists\\,${o}`; break
322
+ case 'isNone': if (!args.length) return `\\nexists\\,${o}`; break
323
+ case 'reverse': if (!args.length) return `\\operatorname{reverse}\\left(${o}\\right)`; break
324
+ case 'sum': if (!args.length) return `\\sum ${o}`; break
325
+ }
326
+ throw new Error('no notation for method .' + d.name + ' with ' + args.length + ' argument(s)')
327
+ }
328
+
329
+ /** Lean source โ†’ LaTeX, or null when this grammar does not cover it. Never a partial rendering. */
330
+ export function toLatex(statement: string): string | null {
331
+ try { return tex(parse(statement)) } catch { return null }
332
+ }
333
+
334
+ // โ”€โ”€ MathML โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
335
+ // Emitted from the SAME tree as the LaTeX, so the typeset page and the copyable LaTeX cannot disagree.
336
+ // MathML is rendered natively by browsers, which keeps the page free of a maths library and a CDN.
337
+ const X = escapeHtml
338
+ const mo = (s: string) => `<mo>${X(s)}</mo>`
339
+ const mi = (s: string) => `<mi>${X(s)}</mi>`
340
+ const MOP: Record<string, string> = { '==': '=', '=': '=', '!=': 'โ‰ ', 'โ‰ ': 'โ‰ ', '<=': 'โ‰ค', 'โ‰ค': 'โ‰ค', '>=': 'โ‰ฅ', 'โ‰ฅ': 'โ‰ฅ', 'โˆง': 'โˆง', 'โˆจ': 'โˆจ', 'โ†’': 'โ†’', 'โ†”': 'โ†”', '*': 'โ‹…', '%': 'mod', '++': 'โงบ' }
341
+
342
+ function ml(n: Node): string {
343
+ switch (n.t) {
344
+ case 'num': return `<mn>${X(n.v)}</mn>`
345
+ case 'id': return mi(n.v)
346
+ case 'hole': return `<mrow>${mo('(')}${mo('ยท')}${mo(MOP[n.op] ?? n.op)}${mo('ยท')}${mo(')')}</mrow>`
347
+ case 'un': return `<mrow>${mo(n.op === 'ยฌ' ? 'ยฌ' : 'โˆ’')}${ml(n.e)}</mrow>`
348
+ case 'lam': return `<mrow>${n.ps.map(mi).join(mo(','))}${mo('โ†ฆ')}${ml(n.b)}</mrow>`
349
+ case 'list': return `<mrow>${mo('[')}${n.xs.map(ml).join(mo(','))}${mo(']')}</mrow>`
350
+ case 'asc': return ml(n.e)
351
+ case 'tuple': return `<mrow>${mo('(')}${n.xs.map(ml).join(mo(','))}${mo(')')}</mrow>`
352
+ case 'proj': return `<msub>${ml(n.o)}<mn>${X(n.i)}</mn></msub>`
353
+ case 'let': return `<mrow>${ml(n.b)}${mo('where')}${mi(n.name)}${mo('=')}${ml(n.e)}</mrow>`
354
+ // Bracketed: unbracketed, `โ€ฆ = 1 if isUnit(d) ; otherwise 0 โˆง provenHere = 0` reads as though the
355
+ // conjunct were part of the else-branch.
356
+ case 'ite': return `<mrow>${mo('(')}${ml(n.a)}${mo('if')}${ml(n.c)}${mo(';')}${mo('otherwise')}${ml(n.b)}${mo(')')}</mrow>`
357
+ case 'bin': {
358
+ if (n.op === '^') return `<msup>${ml(n.l)}${ml(n.r)}</msup>`
359
+ if (n.op === '/') return `<mfrac>${ml(n.l)}${ml(n.r)}</mfrac>`
360
+ if (n.op === '>>>') return `<mrow>${ml(n.l)}${mo('โ‰ซ')}${ml(n.r)}</mrow>`
361
+ if (n.op === '<<<') return `<mrow>${ml(n.l)}${mo('โ‰ช')}${ml(n.r)}</mrow>`
362
+ return `<mrow>${ml(n.l)}${mo(MOP[n.op] ?? n.op)}${ml(n.r)}</mrow>`
363
+ }
364
+ case 'dot': return dotMl(n, [])
365
+ case 'app': {
366
+ const { head, args } = spine(n)
367
+ if (head.t === 'id' && (head.v === 'List.range' || head.v === "List.range'")) {
368
+ // The SAME literal-bound simplification the LaTeX emitter applies. It was written only there, so
369
+ // one tree produced {0,โ€ฆ,8} in LaTeX and {0,โ€ฆ,9โˆ’1} in MathML โ€” two renderings of one theorem
370
+ // disagreeing, in the module whose header claims they cannot.
371
+ const a = head.v === 'List.range' ? 0 : num(args[0])
372
+ const k = head.v === 'List.range' ? num(args[0]) : num(args[1])
373
+ const lo = head.v === 'List.range' ? '<mn>0</mn>' : ml(args[0])
374
+ if (a !== null && k !== null)
375
+ return `<mrow>${mo('{')}<mn>${a}</mn>${mo(',')}${mo('โ€ฆ')}${mo(',')}<mn>${a + k - 1}</mn>${mo('}')}</mrow>`
376
+ const hi = head.v === 'List.range'
377
+ ? `<mrow>${ml(args[0])}${mo('โˆ’')}<mn>1</mn></mrow>`
378
+ : `<mrow>${ml(args[0])}${mo('+')}${ml(args[1])}${mo('โˆ’')}<mn>1</mn></mrow>`
379
+ return `<mrow>${mo('{')}${lo}${mo(',')}${mo('โ€ฆ')}${mo(',')}${hi}${mo('}')}</mrow>`
380
+ }
381
+ if (head.t === 'dot') return dotMl(head, args)
382
+ return `<mrow>${ml(head)}${mo('(')}${args.map(ml).join(mo(','))}${mo(')')}</mrow>`
383
+ }
384
+ }
385
+ }
386
+
387
+ function dotMl(d: Extract<Node, { t: 'dot' }>, args: Node[]): string {
388
+ const o = ml(d.o)
389
+ const lam = (a: Node | undefined) => (a && a.t === 'lam' && a.ps.length === 1 ? a : null)
390
+ switch (d.name) {
391
+ case 'all': { const f = lam(args[0])
392
+ if (f) return `<mrow>${mo('โˆ€')}${mi(f.ps[0])}${mo('โˆˆ')}${o}${mo(',')}${ml(f.b)}</mrow>`
393
+ if (args.length === 1) return `<mrow>${mo('โˆ€')}${mi(BOUND)}${mo('โˆˆ')}${o}${mo(',')}${ml(args[0])}${mo('(')}${mi(BOUND)}${mo(')')}</mrow>`
394
+ break }
395
+ case 'any': { const f = lam(args[0])
396
+ if (f) return `<mrow>${mo('โˆƒ')}${mi(f.ps[0])}${mo('โˆˆ')}${o}${mo(':')}${ml(f.b)}</mrow>`
397
+ if (args.length === 1) return `<mrow>${mo('โˆƒ')}${mi(BOUND)}${mo('โˆˆ')}${o}${mo(':')}${ml(args[0])}${mo('(')}${mi(BOUND)}${mo(')')}</mrow>`
398
+ break }
399
+ case 'filter': { const f = lam(args[0])
400
+ if (f) return `<mrow>${mo('{')}${mi(f.ps[0])}${mo('โˆˆ')}${o}${mo('โˆฃ')}${ml(f.b)}${mo('}')}</mrow>`
401
+ if (args.length === 1) return `<mrow>${mo('{')}${mi(BOUND)}${mo('โˆˆ')}${o}${mo('โˆฃ')}${ml(args[0])}${mo('(')}${mi(BOUND)}${mo(')')}${mo('}')}</mrow>`
402
+ break }
403
+ case 'map': { const f = lam(args[0]); if (f) return `<mrow>${mo('{')}${ml(f.b)}${mo('โˆฃ')}${mi(f.ps[0])}${mo('โˆˆ')}${o}${mo('}')}</mrow>`; if (args.length === 1) return `<mrow>${ml(args[0])}${mo('[')}${o}${mo(']')}</mrow>`; break }
404
+ case 'length': if (!args.length) return `<mrow>${mo('|')}${o}${mo('|')}</mrow>`; break
405
+ case 'contains': if (args.length === 1) return `<mrow>${ml(args[0])}${mo('โˆˆ')}${o}</mrow>`; break
406
+ case 'eraseDups': if (!args.length) return `<mrow>${mi('dedup')}${mo('(')}${o}${mo(')')}</mrow>`; break
407
+ case 'flatMap': { const f = lam(args[0]); if (!f) break
408
+ return `<mrow>${mo('โ‹ƒ')}${mi(f.ps[0])}${mo('โˆˆ')}${o}${mo(',')}${ml(f.b)}</mrow>` }
409
+ case 'foldr':
410
+ case 'foldl':
411
+ if (args.length === 2 && args[0].t === 'hole' && args[0].op === '+' && args[1].t === 'num' && args[1].v === '0')
412
+ return `<mrow>${mo('โˆ‘')}${o}</mrow>`
413
+ if (args.length === 2) return `<mrow>${mi('fold')}${mo('(')}${ml(args[0])}${mo(',')}${o}${mo(',')}${ml(args[1])}${mo(')')}</mrow>`
414
+ break
415
+ case 'get!': if (args.length === 1) return `<msub>${o}${ml(args[0])}</msub>`; break
416
+ case 'head?': if (!args.length) return `<mrow>${mi('head')}${mo('(')}${o}${mo(')')}</mrow>`; break
417
+ case 'getLast?': if (!args.length) return `<mrow>${mi('last')}${mo('(')}${o}${mo(')')}</mrow>`; break
418
+ case 'reverse': if (!args.length) return `<mrow>${mi('reverse')}${mo('(')}${o}${mo(')')}</mrow>`; break
419
+ // the Option predicates, in the SAME reading as the LaTeX above โ€” latex-gate exists to catch exactly
420
+ // the case where one emitter learns a notation and the other does not, and it caught this one
421
+ case 'isSome': if (!args.length) return `<mrow>${mo('โˆƒ')}${o}</mrow>`; break
422
+ case 'isNone': if (!args.length) return `<mrow>${mo('โˆ„')}${o}</mrow>`; break
423
+ case 'sum': if (!args.length) return `<mrow>${mo('โˆ‘')}${o}</mrow>`; break
424
+ }
425
+ throw new Error('no notation for method .' + d.name)
426
+ }
427
+
428
+ /** Lean source โ†’ MathML, or null when this grammar does not cover it. */
429
+ export function toMathML(statement: string): string | null {
430
+ try { return `<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">${ml(parse(statement))}</math>` } catch { return null }
431
+ }
432
+
433
+ // โ”€โ”€ round-trip, so a wrong parse cannot pass quietly โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
434
+ // Throwing catches input the grammar does not know. It does NOT catch input the grammar mis-reads: the
435
+ // lexer once folded `axis.contains d` into a single identifier and rendered `axis.contains(d)` โ€” no error,
436
+ // wrong formula. So the tree is written back out as Lean and the token sequence compared with the source.
437
+ // A parse that dropped, duplicated or reordered anything fails here.
438
+ //
439
+ // ITS LIMIT, STATED: parentheses are excluded from the comparison (the tree does not record where the
440
+ // source put redundant ones), so this verifies that the same symbols were read in the same order โ€” it does
441
+ // NOT prove the operators were grouped with the right precedence. Precedence is covered separately by the
442
+ // assertions in scripts/latex-gate.ts, against Lean's own table.
443
+ export function unparse(n: Node): string {
444
+ switch (n.t) {
445
+ case 'num': return n.v
446
+ case 'id': return n.v
447
+ case 'hole': return `(ยท ${n.op} ยท)`
448
+ case 'un': return `(${n.op} ${unparse(n.e)})`
449
+ case 'lam': return `(fun ${n.ps.join(' ')} => ${unparse(n.b)})`
450
+ case 'list': return `[${n.xs.map(unparse).join(', ')}]`
451
+ case 'asc': return `(${unparse(n.e)} : ${unparse(n.ty)})`
452
+ case 'tuple': return `(${n.xs.map(unparse).join(', ')})`
453
+ case 'proj': return `(${unparse(n.o)}).${n.i}`
454
+ case 'let': return `let ${n.name} := ${unparse(n.e)}; ${unparse(n.b)}`
455
+ case 'ite': return `(if ${unparse(n.c)} then ${unparse(n.a)} else ${unparse(n.b)})`
456
+ case 'bin': return `(${unparse(n.l)} ${n.op} ${unparse(n.r)})`
457
+ case 'dot': return `(${unparse(n.o)}).${n.name}`
458
+ case 'app': return `(${unparse(n.f)} ${unparse(n.a)})`
459
+ }
460
+ }
461
+
462
+ // Lean spells several operators two ways and the parser keeps one of them, so the comparison canonicalises
463
+ // both sides. Without this the check reported 72 failures that were only `||` against `โˆจ` โ€” a check that
464
+ // cries wolf gets switched off, which is its own way of proving nothing.
465
+ const CANON: Record<string, string> = { '||': 'โˆจ', '&&': 'โˆง', '==': '=', '!=': 'โ‰ ', '<=': 'โ‰ค', '>=': 'โ‰ฅ', '!': 'ยฌ' }
466
+
467
+ /** The token sequence with grouping removed and spellings canonicalised โ€” what the round-trip compares. */
468
+ export const shape = (src: string): string =>
469
+ lex(src).filter((t) => !'()'.includes(t.v)).map((t) => CANON[t.v] ?? t.v).join(' ')
470
+
471
+ /** True when the parse read exactly the symbols the source contains, in order. */
472
+ export function roundTrips(statement: string): boolean {
473
+ try { return shape(unparse(parse(statement))) === shape(statement) } catch { return false }
474
+ }