visualproof 0.0.0-stage → 0.1.0

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 (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,107 @@
1
+ // The exported document's order (the owner's decisions of 2026-10-12): the board is the outline of
2
+ // the document. Within a section, everything the board draws in that cluster — its sub-clusters by
3
+ // their circle's centre, its declarations by their point, its notes by their icon — comes in the
4
+ // order of its height on the view's board, the lowest first, ties left to right; the top-level
5
+ // sections are ordered so too, and so is what the document starts with, under no heading: the
6
+ // declarations in no cluster and the notes on the ring or on the canvas outside every cluster. Since
7
+ // the layout puts what is used more lower down (CanvasSpec, "Dependency order"), the default reads
8
+ // foundations first; a reader reorders the document by dragging things on the board. Nothing here
9
+ // reads what depends on what.
10
+ //
11
+ // Where each thing is drawn is the board's (server/boardspots.ts reads it as the page draws it: the
12
+ // view's layout memory, its pins and its hidden levels); this file only orders what it is told, so
13
+ // that the same positions always give the same document. A note attached to a declaration the
14
+ // document prints goes right after it, as its explanation; a note in a cluster goes to the section
15
+ // of the nearest cluster around it that the document prints; any other note, to the start.
16
+ import { boardLayout, subgraph } from "../board.js";
17
+ /** A height on the board, read to a tenth of a unit, which the layout rounds its places to: two
18
+ * things on one row of a cluster's lattice are level, and go left to right. */
19
+ const tenth = (v) => Math.round(v * 10);
20
+ /** The board's order of things at `at` (null where the board does not say: last, as given): the
21
+ * lowest first, ties left to right, then as given. */
22
+ export function boardOrder(xs, at) {
23
+ const keyed = xs.map((x, k) => ({ x, k, p: at(x) }));
24
+ keyed.sort((a, b) => {
25
+ if (!a.p || !b.p)
26
+ return a.p ? -1 : b.p ? 1 : a.k - b.k;
27
+ return tenth(b.p[1]) - tenth(a.p[1]) || tenth(a.p[0]) - tenth(b.p[0]) || a.k - b.k;
28
+ });
29
+ return keyed.map((e) => e.x);
30
+ }
31
+ /** The outline of the document of `plan` (of graph `g`) in the order of the board `spots` describes. */
32
+ export function orderDoc(plan, g, spots) {
33
+ // the declarations the document prints, by name: where an attached note goes
34
+ const printed = new Map();
35
+ const sectionOf = new Map();
36
+ const walk = (s) => {
37
+ for (const k of s.keys)
38
+ sectionOf.set(k, s);
39
+ for (const i of s.decls)
40
+ printed.set(g.nodes[i].name, i);
41
+ s.sections.forEach(walk);
42
+ };
43
+ plan.sections.forEach(walk);
44
+ for (const i of plan.decls)
45
+ printed.set(g.nodes[i].name, i);
46
+ const attached = new Map();
47
+ const inSection = new Map();
48
+ const atStart = [];
49
+ spots.notes.forEach((n, k) => {
50
+ const on = n.on !== undefined ? printed.get(n.on) : undefined;
51
+ if (on !== undefined) {
52
+ const list = attached.get(on) ?? [];
53
+ list.push(k);
54
+ attached.set(on, list);
55
+ return;
56
+ }
57
+ // the nearest cluster around it that the document prints; none, the start
58
+ const s = n.in.map((key) => sectionOf.get(key)).find((x) => x !== undefined);
59
+ if (!s) {
60
+ atStart.push(k);
61
+ return;
62
+ }
63
+ const list = inSection.get(s) ?? [];
64
+ list.push(k);
65
+ inSection.set(s, list);
66
+ });
67
+ for (const [i, ks] of attached)
68
+ attached.set(i, boardOrder(ks, (k) => spots.notes[k].at));
69
+ const declAt = (i) => spots.decls.get(g.nodes[i].name) ?? null;
70
+ // a section's circle; where the board names none (a board laid out otherwise), the middle of what
71
+ // it prints
72
+ const sectionAt = (s) => {
73
+ const c = spots.clusters.get(s.key);
74
+ if (c)
75
+ return c;
76
+ let x = 0, y = 0, n = 0;
77
+ const sum = (t) => {
78
+ for (const i of t.decls) {
79
+ const p = declAt(i);
80
+ if (p)
81
+ [x, y, n] = [x + p[0], y + p[1], n + 1];
82
+ }
83
+ t.sections.forEach(sum);
84
+ };
85
+ sum(s);
86
+ return n ? [x / n, y / n] : null;
87
+ };
88
+ const at = (p) => (p.t === "decl" ? declAt(p.i) : p.t === "note" ? spots.notes[p.k].at : sectionAt(p.s.plan));
89
+ const section = (s) => ({
90
+ plan: s,
91
+ items: boardOrder([...s.decls.map((i) => ({ t: "decl", i })), ...(inSection.get(s) ?? []).map((k) => ({ t: "note", k })), ...s.sections.map((t) => ({ t: "section", s: section(t) }))], at),
92
+ });
93
+ // the start, then the top-level sections: the start has no heading of its own to come back to
94
+ const start = boardOrder([...plan.decls.map((i) => ({ t: "decl", i })), ...atStart.map((k) => ({ t: "note", k }))], at);
95
+ const sections = boardOrder(plan.sections.map((s) => ({ t: "section", s: section(s) })), at);
96
+ return { items: [...start, ...sections], attached };
97
+ }
98
+ /** The board of `set` (per node of `g`) by `mode` with the levels `hidden` hidden as a fresh layout
99
+ * draws it, with nothing pinned and no notes: what a board nobody arranged looks like, laid out on
100
+ * its own. What the builder orders by where it is told nothing of the board (shared/doc/build.ts). */
101
+ export function freshSpots(g, set, mode, hidden) {
102
+ const sub = subgraph(g, set).graph;
103
+ const L = boardLayout(sub, mode, "fresh", null, hidden).layout;
104
+ const decls = new Map();
105
+ sub.nodes.forEach((v, i) => decls.set(v.name, [L.x[i], L.y[i]]));
106
+ return { decls, clusters: new Map(L.clusters.map((c) => [c.key, [c.x, c.y]])), notes: [] };
107
+ }
@@ -0,0 +1,272 @@
1
+ // Proofs are never exported (ExportSpec decision 8): a theorem's source is printed up to its
2
+ // statement, and `:= …` stands for the rest. Where its value starts is read off its text here.
3
+ //
4
+ // The usage pass does not record where a value starts (extractor/Usage.lean tells a value's
5
+ // syntax apart while it walks a command, but writes only the uses it found), so the text is read
6
+ // as the tokenizer reads it (shared/lexer.ts): comments, docstrings, strings and attributes are
7
+ // whole tokens and never searched, and brackets are followed, so that a `:=` inside a binder's
8
+ // default value or an attribute's argument is not the value's. The value starts at the first `:=`
9
+ // outside every bracket that no `let` or `have` in the statement claims, at a line that starts
10
+ // with `|` and has `=>` on it (equations, `theorem f : ∀ n, P n | 0 => …`), or at a `where`. A
11
+ // statement never holds a `by` outside brackets: one met before the value's start means the
12
+ // reading went wrong (a `have` claimed the wrong `:=`), and nothing is found. Where nothing is
13
+ // found, the caller prints the graph's statement instead: a proof is never printed for want of
14
+ // finding where it starts.
15
+ //
16
+ // Proofs inside a definition's value are left out too, and the rest of the definition is always
17
+ // printed (decided 2026-10-08). The usage pass records where they are (RawNode.proofs: `by` blocks
18
+ // whose goal is a proposition, the tactics of `decreasing_by`, term-mode proofs written in the
19
+ // text), and `cutProofs` puts `…` in the place of each. Those places are the compiled text's, so
20
+ // the caller uses them only for a text the pass read. Elsewhere — the file changed since it was
21
+ // compiled, or the pass is off, still running or failed on the module — `cutByText` finds them by
22
+ // reading the text: every `by` block in the value, and the tactics of `decreasing_by`. That reading
23
+ // cannot tell a `by` block that builds data from one that proves, and leaves out both; and it cannot
24
+ // find a proof written as a term, which it keeps.
25
+ import { tokenizeLean } from "../lexer.js";
26
+ const OPEN = "([{⦃⟨⟦⟪⦅⦇⁅";
27
+ const CLOSE = ")]}⦄⟩⟧⟫⦆⦈⁆";
28
+ /** words in a statement's terms that bind with a `:=` of their own */
29
+ const BINDS = new Set(["let", "have", "haveI", "letI"]);
30
+ /** Where the value of a declaration whose text is `text` starts (an offset into `text`), or null
31
+ * when it cannot be told: where a theorem's proof starts. */
32
+ export function valueStart(text) {
33
+ let depth = 0;
34
+ let claimed = 0;
35
+ for (const t of tokenizeLean(text)) {
36
+ if (t.kind === "comment" || t.kind === "doc" || t.kind === "string" || t.kind === "attr")
37
+ continue;
38
+ if (t.kind === "keyword" || t.kind === "ident") {
39
+ if (depth === 0 && BINDS.has(t.text))
40
+ claimed++;
41
+ else if (depth === 0 && t.text === "where")
42
+ return t.at;
43
+ else if (depth === 0 && t.text === "by")
44
+ return null;
45
+ continue;
46
+ }
47
+ if (t.kind !== "text" && t.kind !== "op")
48
+ continue;
49
+ for (let k = 0; k < t.text.length; k++) {
50
+ const c = t.text[k];
51
+ const at = t.at + k;
52
+ if (OPEN.includes(c))
53
+ depth++;
54
+ else if (CLOSE.includes(c))
55
+ depth = Math.max(0, depth - 1);
56
+ else if (depth === 0 && c === ":" && t.text[k + 1] === "=") {
57
+ if (claimed > 0) {
58
+ claimed--;
59
+ k++;
60
+ continue;
61
+ }
62
+ return at;
63
+ }
64
+ else if (depth === 0 && c === "|" && lineStart(text, at) && equation(text, at))
65
+ return at;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ /** Whether only spaces come before `at` on its line. */
71
+ function lineStart(text, at) {
72
+ const nl = text.lastIndexOf("\n", at - 1);
73
+ return nl >= 0 && /^[ \t]*$/.test(text.slice(nl + 1, at));
74
+ }
75
+ /** Whether the line from `at` is an equation's, `| pattern => value`: a `=>` on it. */
76
+ function equation(text, at) {
77
+ const end = text.indexOf("\n", at);
78
+ return text.slice(at, end < 0 ? text.length : end).includes("=>");
79
+ }
80
+ /** A theorem's text without its proof: up to where its value starts, the spaces before it left
81
+ * out; null where that place cannot be found. */
82
+ export function statementOnly(text) {
83
+ const at = valueStart(text);
84
+ return at === null ? null : text.slice(0, at).replace(/\s+$/, "");
85
+ }
86
+ /** The UTF-16 offset of code point `col` of `line` (its end where the line is shorter). */
87
+ function utf16At(line, col) {
88
+ let i = 0;
89
+ for (let cp = 0; cp < col && i < line.length; cp++)
90
+ i += (line.codePointAt(i) ?? 0) > 0xffff ? 2 : 1;
91
+ return i;
92
+ }
93
+ /** The file's lines `start`..`end` (0-based, inclusive) as highlighted code, each proof span in them
94
+ * cut and `…` (an `elided` token) put in its place: `field := by decide` reads `field := …`; a
95
+ * multi-line `by` block is `…` on its first line, the lines after it gone up to its end, where what
96
+ * follows the proof on its last line joins the first; a `by` in the middle of a term is `…` there.
97
+ * Everything else is kept as written, comments included, highlighted as the whole text is (the
98
+ * text is tokenized before it is cut). Spans outside the lines are ignored, one that runs past them
99
+ * is cut to their end. */
100
+ export function cutProofs(lines, start, end, spans) {
101
+ const own = lines.slice(start, end + 1);
102
+ const text = own.join("\n");
103
+ const lineStart = [];
104
+ let at = 0;
105
+ for (const l of own) {
106
+ lineStart.push(at);
107
+ at += l.length + 1;
108
+ }
109
+ const offset = (line, col) => {
110
+ const k = line - 1 - start;
111
+ if (k < 0)
112
+ return 0;
113
+ if (k >= own.length)
114
+ return text.length;
115
+ return lineStart[k] + utf16At(own[k], col);
116
+ };
117
+ return cutAt(text, spans.map((sp) => [offset(sp[0], sp[1]), offset(sp[2], sp[3])]));
118
+ }
119
+ /** A definition's text with the proofs in it left out where reading the text finds them
120
+ * (`textProofs`), cut as `cutProofs` cuts the usage pass's: for a text the pass has not read. */
121
+ export function cutByText(text) {
122
+ return cutAt(text, textProofs(text));
123
+ }
124
+ /** `text` as highlighted code with the spans in it (offsets, end exclusive) cut, `…` in the place of
125
+ * each. */
126
+ function cutAt(text, spans) {
127
+ // the spans sorted and apart (the pass merges them; a span given twice is cut once)
128
+ const cuts = [];
129
+ for (const s of spans
130
+ .map(([a, b]) => [a, b])
131
+ .filter(([a, b]) => a < b)
132
+ .sort((x, y) => x[0] - y[0])) {
133
+ const last = cuts[cuts.length - 1];
134
+ if (last && s[0] <= last[1])
135
+ last[1] = Math.max(last[1], s[1]);
136
+ else
137
+ cuts.push(s);
138
+ }
139
+ // the tokens outside the cuts, an `elided` one at each cut's start
140
+ const kept = [];
141
+ let ci = 0;
142
+ for (const t of tokenizeLean(text)) {
143
+ let p = t.at;
144
+ const e = t.at + t.text.length;
145
+ while (p < e) {
146
+ while (ci < cuts.length && cuts[ci][1] <= p)
147
+ ci++;
148
+ const c = cuts[ci];
149
+ if (!c || e <= c[0]) {
150
+ kept.push({ kind: t.kind, text: text.slice(p, e) });
151
+ break;
152
+ }
153
+ if (p < c[0])
154
+ kept.push({ kind: t.kind, text: text.slice(p, c[0]) });
155
+ if (p <= c[0])
156
+ kept.push({ kind: "elided", text: "…" });
157
+ p = Math.min(e, c[1]);
158
+ }
159
+ }
160
+ // the lines, a new one at each line break that was kept
161
+ const out = [[]];
162
+ for (const t of kept)
163
+ t.text.split("\n").forEach((part, k) => {
164
+ if (k)
165
+ out.push([]);
166
+ if (part)
167
+ out[out.length - 1].push([t.kind, part]);
168
+ });
169
+ return { lines: out, cuts: cuts.length };
170
+ }
171
+ /** The words that end a tactic block where they stand at its depth: a definition's termination
172
+ * clauses, its auxiliary definitions and its derived instances come after its value, and no
173
+ * tactic is one of them. */
174
+ const BLOCK_ENDS = new Set(["termination_by", "decreasing_by", "where", "deriving"]);
175
+ /** The pieces of `text`, in order. */
176
+ function pieces(text) {
177
+ const out = [];
178
+ let line = -1;
179
+ const push = (t, at, end) => {
180
+ const start = text.lastIndexOf("\n", at - 1) + 1;
181
+ out.push({ t, text: text.slice(at, end), at, end, quote: t === "open" && text[at - 1] === "`", col: start === line ? -1 : at - start });
182
+ line = start;
183
+ };
184
+ for (const t of tokenizeLean(text)) {
185
+ if (t.kind === "comment" || t.kind === "doc")
186
+ continue;
187
+ if (t.kind !== "text" && t.kind !== "op") {
188
+ push("word", t.at, t.at + t.text.length);
189
+ continue;
190
+ }
191
+ for (let k = 0; k < t.text.length;) {
192
+ const c = String.fromCodePoint(t.text.codePointAt(k));
193
+ const at = t.at + k;
194
+ k += c.length;
195
+ if (/\s/.test(c))
196
+ continue;
197
+ push(OPEN.includes(c) ? "open" : CLOSE.includes(c) ? "close" : c === "," ? "comma" : "mark", at, at + c.length);
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+ /** Where the proofs in a definition's text are, read off the text alone (offsets, end exclusive):
203
+ * each `by` block in its value, and the tactics of `decreasing_by` (the keyword stays). A `by` in
204
+ * the signature is the statement's and stays, as the usage pass leaves it; where the value's start
205
+ * cannot be read, the whole text is searched. A `by` inside a quotation is a macro's meaning and
206
+ * stays. A `by` block that builds data is cut as well — the text cannot tell its goal — and a
207
+ * proof written as a term is not found at all. */
208
+ export function textProofs(text) {
209
+ const ps = pieces(text);
210
+ const from = valueStart(text) ?? 0;
211
+ const out = [];
212
+ // the brackets open where the reading is, each whether it is a quotation's
213
+ const open = [];
214
+ for (let k = 0; k < ps.length;) {
215
+ const p = ps[k];
216
+ if (p.t === "word" && (p.text === "by" || p.text === "decreasing_by") && p.at >= from && !open.includes(true)) {
217
+ const b = tacticBlock(text, ps, k, open.length > 0);
218
+ const start = p.text === "by" ? p.at : b.first;
219
+ if (start !== null && start < b.end)
220
+ out.push([start, b.end]);
221
+ k = b.next;
222
+ continue;
223
+ }
224
+ if (p.t === "open")
225
+ open.push(p.quote);
226
+ else if (p.t === "close")
227
+ open.pop();
228
+ k++;
229
+ }
230
+ return out;
231
+ }
232
+ /** The tactic block the `by` or `decreasing_by` at `ps[k]` opens, read as Lean lays it out: it runs
233
+ * to the first piece, at its own bracket depth, that closes the bracket it is in, or is a comma in
234
+ * that bracket (`enclosed`), a word of BLOCK_ENDS, a `then` or `else` of no `if` in the block, or
235
+ * the first of a line that starts no further right than the line the block starts on (its tactics'
236
+ * column less one, where they start on a line of their own further left than that). Where its last
237
+ * piece ends, where its first piece after the keyword starts (null where it has none), and the
238
+ * index of the first piece after it. */
239
+ function tacticBlock(text, ps, k, enclosed) {
240
+ const key = ps[k];
241
+ const lineStart = text.lastIndexOf("\n", key.at - 1) + 1;
242
+ let edge = /^[ \t]*/.exec(text.slice(lineStart))[0].length;
243
+ let depth = 0;
244
+ let ifs = 0;
245
+ let end = key.end;
246
+ let first = null;
247
+ let j = k + 1;
248
+ for (; j < ps.length; j++) {
249
+ const p = ps[j];
250
+ if (first === null && p.col >= 0 && p.col <= edge)
251
+ edge = p.col - 1;
252
+ if (depth === 0) {
253
+ if (p.t === "close" || (p.t === "comma" && enclosed) || (p.col >= 0 && p.col <= edge))
254
+ break;
255
+ if (p.t === "word") {
256
+ if (BLOCK_ENDS.has(p.text) || ((p.text === "then" || p.text === "else") && !ifs))
257
+ break;
258
+ if (p.text === "if")
259
+ ifs++;
260
+ else if (p.text === "else")
261
+ ifs--;
262
+ }
263
+ }
264
+ if (p.t === "open")
265
+ depth++;
266
+ else if (p.t === "close")
267
+ depth--;
268
+ first ??= p.at;
269
+ end = p.end;
270
+ }
271
+ return { end, first, next: j };
272
+ }
@@ -0,0 +1,93 @@
1
+ // The exported document's plan (ExportSpec decisions 12 and 13): what a view holds, as the document
2
+ // prints it. Every declaration of the project the board holds is printed, whatever is folded; what
3
+ // the view holds from outside the project is not, and is counted instead. The sections are the
4
+ // view's cluster structure as the board shows it — its clustering, its hidden levels taken out
5
+ // (ViewSpec decision 24; shared/layout.ts, clusterTree) — each cluster that holds something printed
6
+ // a section, nested by its depth.
7
+ //
8
+ // The plan says what is printed, not in which order: the order is the board's (the owner's decisions
9
+ // of 2026-10-12, shared/doc/order.ts), from where the view's board draws each thing, which only the
10
+ // server and a page that lays the board out know. The builder (shared/doc/build.ts) prints from it
11
+ // in that order, and the Export window (web/src/exportmodal.ts) says from it what the document will
12
+ // hold, so that the two agree.
13
+ import { clusterTree } from "../layout.js";
14
+ /** Heading levels: section, subsection, subsubsection, then a paragraph-level heading, which every
15
+ * deeper level takes too. */
16
+ export const HEADING_LEVELS = 4;
17
+ /** The most declarations one document prints: some 400 pages, typeset in well under the time
18
+ * XeLaTeX is given (server/export.ts). A larger view is narrowed first. */
19
+ export const MAX_DOC_DECLS = 2000;
20
+ /** Why a view of `count` declarations of the project makes no document, or null. */
21
+ export function tooMuch(count) {
22
+ if (!count)
23
+ return "This view holds no declaration of the project: there is nothing to print";
24
+ if (count > MAX_DOC_DECLS)
25
+ return `This view holds ${count.toLocaleString("en")} declarations of the project; a document prints at most ${MAX_DOC_DECLS.toLocaleString("en")}. Export a narrower view: a namespace or a folder, a target's Key view (key(T)) or the TCB of a target`;
26
+ return null;
27
+ }
28
+ /** The plan of the document of the board that holds `set` (per node of `g`) clustered by `mode`
29
+ * with the levels `hidden` hidden. A cluster that holds no declaration of its own and one section
30
+ * only is merged into it, its heading `A.B`, so that the document has no heading chains with
31
+ * nothing under them; the board merges such a chain into one circle already where it holds nothing
32
+ * else at all. On a board of a past build, `g` is its past graph (shared/pastgraph.ts), which holds
33
+ * that build's declarations alone. */
34
+ export function planDoc(g, set, mode, hidden) {
35
+ // the board's members in the whole graph's order, as a board's graph has them (shared/board.ts)
36
+ const members = [];
37
+ for (let i = 0; i < g.n; i++)
38
+ if (set[i])
39
+ members.push(i);
40
+ const tree = clusterTree(mode, members.map((i) => g.nodes[i]), hidden);
41
+ const own = tree.clusters.map(() => []);
42
+ const top = [];
43
+ const by = new Map();
44
+ let outside = 0;
45
+ members.forEach((i, k) => {
46
+ if (g.isExt(i)) {
47
+ outside++;
48
+ const lib = g.nodes[i].module.split(".")[0] || g.nodes[i].module;
49
+ by.set(lib, (by.get(lib) ?? 0) + 1);
50
+ return;
51
+ }
52
+ const c = tree.cluster[k];
53
+ (c >= 0 ? own[c] : top).push(i);
54
+ });
55
+ // how much each cluster's subtree prints
56
+ const kids = tree.clusters.map(() => []);
57
+ const roots = [];
58
+ tree.clusters.forEach((c, k) => (c.parent >= 0 ? kids[c.parent] : roots).push(k));
59
+ const printed = new Int32Array(tree.clusters.length);
60
+ for (let k = tree.clusters.length - 1; k >= 0; k--) {
61
+ printed[k] += own[k].length;
62
+ const p = tree.clusters[k].parent;
63
+ if (p >= 0)
64
+ printed[p] += printed[k];
65
+ }
66
+ const kept = (k) => kids[k].filter((j) => printed[j] > 0);
67
+ let sectionCount = 0;
68
+ const section = (k, depth, above) => {
69
+ let title = tree.clusters[k].label;
70
+ const keys = [tree.clusters[k].key];
71
+ // nothing of its own and one section: one heading
72
+ for (let next = kept(k); !own[k].length && next.length === 1; next = kept(k)) {
73
+ k = next[0];
74
+ title = `${title}.${tree.clusters[k].label}`;
75
+ keys.push(tree.clusters[k].key);
76
+ }
77
+ sectionCount++;
78
+ const level = Math.min(depth, HEADING_LEVELS);
79
+ if (depth > HEADING_LEVELS && above !== null)
80
+ title = `${above}.${title}`;
81
+ const s = { key: tree.clusters[k].key, title, level, depth, keys, decls: own[k], sections: [] };
82
+ s.sections = kept(k).map((j) => section(j, depth + 1, depth >= HEADING_LEVELS ? title : null));
83
+ return s;
84
+ };
85
+ const sections = roots.filter((k) => printed[k] > 0).map((k) => section(k, 1, null));
86
+ return {
87
+ decls: top,
88
+ sections,
89
+ count: top.length + own.reduce((a, d) => a + d.length, 0),
90
+ sectionCount,
91
+ outside: { count: outside, by: [...by].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : 1)) },
92
+ };
93
+ }
@@ -0,0 +1,75 @@
1
+ // The exported document's settings (ExportSpec decision 7): every option a user can set, with its
2
+ // default, checked the same way wherever it comes from — the Export modal (web/src/exportmodal.ts),
3
+ // POST /api/export, the agents' endpoints. The modal sets the common ones; the rest keep their
4
+ // defaults. They are the document's style; what it holds is the view's (ExportSpec decision 12), and
5
+ // so are its title and its author line (decisions 14 and 15): no setting gives them.
6
+ // What a renderer does with them is its own business (shared/doc/latex.ts); the style file it loads
7
+ // can go further (ExportSpec.md, "Customising the style").
8
+ export const DEFAULT_SETTINGS = Object.freeze({
9
+ paper: "a4",
10
+ margin: 22,
11
+ fontSize: 11,
12
+ codeSize: 8.5,
13
+ contents: true,
14
+ });
15
+ /** Settings there were until 2026-10-08 (ExportSpec decision 7): the colour scheme, line numbers
16
+ * and the context (none is printed), and the key graph's figure (the document has none); until
17
+ * 2026-10-09, the appendix (`supportIndex`: the document has none), and the title and the author
18
+ * line (`title`, `author`: the view's title and the people who left a trace on it, decisions 14 and
19
+ * 15). A request that sends one is taken as before and the setting ignored, so that a page or an
20
+ * agent made earlier still exports. */
21
+ export const RETIRED_SETTINGS = ["scheme", "lineNumbers", "context", "figure", "supportIndex", "title", "author"];
22
+ /** What each setting is, for the agents' index and the docs. */
23
+ export const SETTINGS_HELP = {
24
+ paper: "a4 | letter",
25
+ margin: "millimetres on every side, 10–40",
26
+ fontSize: "the text's size in points: 10, 11 or 12",
27
+ codeSize: "the code's size in points, 6–12 in steps of 0.5",
28
+ contents: "true: a table of contents after the head card, in two columns (the sections, and the declarations where the document is short), the body following on the same page",
29
+ };
30
+ /** The settings a request asks for, over the defaults; or why they cannot be had. Unknown fields
31
+ * are refused, so that a misspelt option is said rather than ignored; a retired one is ignored. */
32
+ export function readSettings(raw) {
33
+ if (raw === undefined || raw === null)
34
+ return { settings: { ...DEFAULT_SETTINGS } };
35
+ if (typeof raw !== "object" || Array.isArray(raw))
36
+ return { error: "settings is an object of options" };
37
+ const o = raw;
38
+ const s = { ...DEFAULT_SETTINGS };
39
+ for (const [k, v] of Object.entries(o)) {
40
+ if (RETIRED_SETTINGS.includes(k))
41
+ continue;
42
+ if (!Object.hasOwn(DEFAULT_SETTINGS, k))
43
+ return { error: `${k} is not a setting (${Object.keys(DEFAULT_SETTINGS).join(", ")})` };
44
+ if (v === undefined)
45
+ continue;
46
+ switch (k) {
47
+ case "paper":
48
+ if (v !== "a4" && v !== "letter")
49
+ return { error: "paper is a4 or letter" };
50
+ s.paper = v;
51
+ break;
52
+ case "margin":
53
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 10 || v > 40)
54
+ return { error: "margin is in millimetres, 10–40" };
55
+ s.margin = Math.round(v);
56
+ break;
57
+ case "fontSize":
58
+ if (v !== 10 && v !== 11 && v !== 12)
59
+ return { error: "fontSize is 10, 11 or 12" };
60
+ s.fontSize = v;
61
+ break;
62
+ case "codeSize":
63
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 6 || v > 12)
64
+ return { error: "codeSize is in points, 6–12" };
65
+ s.codeSize = Math.round(v * 2) / 2;
66
+ break;
67
+ case "contents":
68
+ if (typeof v !== "boolean")
69
+ return { error: `${k} is true or false` };
70
+ s.contents = v;
71
+ break;
72
+ }
73
+ }
74
+ return { settings: s };
75
+ }
@@ -0,0 +1,102 @@
1
+ // The edges as they travel to a page: the graph's (source, destination, site) triples, packed. A
2
+ // page far from the server waits on every byte, and the triples as 32-bit integers are mostly
3
+ // zeros: grouped by source, with each source's destinations ascending and written as the
4
+ // difference from the one before, almost every number fits in a byte. Gzipped, flt-regular's
5
+ // 31,432 edges take 21 KB instead of 85 KB.
6
+ //
7
+ // The format: "VPE2", then unsigned LEB128 varints — the number of nodes n, then for each node
8
+ // how many edges leave it, then for each edge in that order ((destination − previous destination)
9
+ // << 4) | site, the previous destination starting at 0 for each source. Sites are 4 bits
10
+ // (shared/graph.ts, SITE_*; "VPE1", read still, had 3, before the grammar site). The order of the
11
+ // edges is not kept: the graph does not depend on it.
12
+ const MAGIC = [0x56, 0x50, 0x45, 0x32]; // "VPE2"
13
+ /** bits of a site in each format, by the magic's last byte */
14
+ const SITE_BITS = { 0x31: 3, 0x32: 4 };
15
+ /** Pack the triples of `edges` (a graph of `n` nodes). `format` 1 is for a page from before the
16
+ * grammar site: it takes grammar for a proof, which no TCB follows either. */
17
+ export function packEdges(edges, n, format = 2) {
18
+ const bits = format === 1 ? 3 : 4;
19
+ const unit = 2 ** bits;
20
+ const siteOf = (s) => (format === 1 ? (s & 8 ? (s & 7) | 4 : s) & 7 : s);
21
+ const m = edges.length / 3;
22
+ const count = new Int32Array(n);
23
+ for (let e = 0; e < m; e++)
24
+ count[edges[3 * e]]++;
25
+ const start = new Int32Array(n + 1);
26
+ for (let i = 0; i < n; i++)
27
+ start[i + 1] = start[i] + count[i];
28
+ // each source's destinations, then sorted: (dst << bits | site) sorts by destination
29
+ const keys = new Float64Array(m);
30
+ const fill = start.slice(0, n);
31
+ for (let e = 0; e < m; e++)
32
+ keys[fill[edges[3 * e]]++] = edges[3 * e + 1] * unit + siteOf(edges[3 * e + 2]);
33
+ for (let i = 0; i < n; i++)
34
+ keys.subarray(start[i], start[i + 1]).sort();
35
+ const out = new Uint8Array(4 + 5 * (1 + n + m));
36
+ out.set(MAGIC);
37
+ out[3] = format === 1 ? 0x31 : 0x32;
38
+ let p = 4;
39
+ const put = (v) => {
40
+ while (v >= 128) {
41
+ out[p++] = (v & 127) | 128;
42
+ v = Math.floor(v / 128);
43
+ }
44
+ out[p++] = v;
45
+ };
46
+ put(n);
47
+ for (let i = 0; i < n; i++)
48
+ put(count[i]);
49
+ for (let i = 0; i < n; i++) {
50
+ let prev = 0;
51
+ for (let k = start[i]; k < start[i + 1]; k++) {
52
+ const dst = Math.floor(keys[k] / unit);
53
+ put((dst - prev) * unit + (keys[k] % unit));
54
+ prev = dst;
55
+ }
56
+ }
57
+ return out.subarray(0, p);
58
+ }
59
+ /** Whether `bytes` are packed edges (the magic, of this format or the one before), not the
60
+ * triples themselves. */
61
+ export function isPackedEdges(bytes) {
62
+ return bytes.length >= 4 && MAGIC.slice(0, 3).every((b, k) => bytes[k] === b) && bytes[3] in SITE_BITS;
63
+ }
64
+ /** The triples packed into `bytes`; throws if they are not packed edges or are cut short. */
65
+ export function unpackEdges(bytes) {
66
+ if (!isPackedEdges(bytes))
67
+ throw new Error("not packed edges");
68
+ const unit = 2 ** SITE_BITS[bytes[3]];
69
+ let p = 4;
70
+ const get = () => {
71
+ let v = 0;
72
+ let scale = 1;
73
+ for (;;) {
74
+ if (p >= bytes.length)
75
+ throw new Error("packed edges cut short");
76
+ const b = bytes[p++];
77
+ v += (b & 127) * scale;
78
+ if (b < 128)
79
+ return v;
80
+ scale *= 128;
81
+ }
82
+ };
83
+ const n = get();
84
+ const count = new Int32Array(n);
85
+ let m = 0;
86
+ for (let i = 0; i < n; i++)
87
+ m += count[i] = get();
88
+ const edges = new Int32Array(3 * m);
89
+ let e = 0;
90
+ for (let i = 0; i < n; i++) {
91
+ let prev = 0;
92
+ for (let c = 0; c < count[i]; c++, e++) {
93
+ const v = get();
94
+ const dst = prev + Math.floor(v / unit);
95
+ edges[3 * e] = i;
96
+ edges[3 * e + 1] = dst;
97
+ edges[3 * e + 2] = v % unit;
98
+ prev = dst;
99
+ }
100
+ }
101
+ return edges;
102
+ }