mlx-bun 0.0.8 → 0.0.9

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 (133) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/docs-reorg-plan.md +1 -1
  6. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  7. package/docs/design/dspark-speculative-decoding.md +106 -0
  8. package/docs/design/generic-model-support.md +381 -0
  9. package/docs/design/memory-inference-path.md +422 -0
  10. package/docs/design/memory-synthesis.md +287 -0
  11. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  12. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  13. package/docs/design/orpo-dynamic-lambda.md +41 -0
  14. package/docs/design/orpo-training.md +35 -5
  15. package/docs/design/parallel-slots.md +12 -1
  16. package/docs/design/parity-tier-dag.md +13 -5
  17. package/docs/design/spec-decode-larger-targets.md +229 -0
  18. package/docs/design/tauri-desktop-app.md +2 -2
  19. package/docs/design/the-dreaming-handoff.md +141 -0
  20. package/docs/design/the-dreaming-master-plan.md +446 -0
  21. package/docs/design/write-pipeline-entity.md +468 -0
  22. package/docs/investigations/curve-bisector-routing.md +1 -1
  23. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  24. package/docs/investigations/dspark-handoff.md +58 -0
  25. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  27. package/docs/planning/curve-sampler-research-plan.md +331 -0
  28. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  29. package/docs/planning/pi-integration-review.md +178 -0
  30. package/docs/planning/release-notes-v0.0.9.md +122 -0
  31. package/docs/planning/web-ui-pass-plan.md +213 -0
  32. package/docs/planning/website-readme-pass-plan.md +224 -0
  33. package/docs/reference/cli.md +255 -0
  34. package/docs/reference/embedding.md +7 -2
  35. package/docs/reference/library-api.md +22 -3
  36. package/docs/reference/memory.md +5 -5
  37. package/docs/reference/models.md +137 -0
  38. package/docs/reference/orpo-quickstart.md +6 -0
  39. package/docs/reference/server-api.md +187 -9
  40. package/docs/reference/server-config.md +40 -9
  41. package/docs/reference/training.md +25 -11
  42. package/package.json +1 -1
  43. package/src/anthropic.ts +22 -0
  44. package/src/assets/curve-designer.html +1 -1
  45. package/src/chat-template.ts +25 -7
  46. package/src/cli.ts +1029 -86
  47. package/src/config.ts +4 -1
  48. package/src/download.ts +99 -8
  49. package/src/embed.ts +77 -0
  50. package/src/eval/perplexity.ts +164 -0
  51. package/src/eval/runner.ts +15 -6
  52. package/src/eval/tasks/bfcl.ts +1 -1
  53. package/src/generate.ts +141 -18
  54. package/src/harness-pi.ts +43 -17
  55. package/src/index.ts +2 -0
  56. package/src/lora.ts +2 -2
  57. package/src/memory/article.ts +558 -0
  58. package/src/memory/chunk-validation.ts +150 -0
  59. package/src/memory/chunk.ts +305 -12
  60. package/src/memory/cluster.ts +344 -17
  61. package/src/memory/coverage.ts +134 -0
  62. package/src/memory/crosslink.ts +0 -0
  63. package/src/memory/db.ts +184 -45
  64. package/src/memory/entity.ts +203 -0
  65. package/src/memory/gate.ts +187 -0
  66. package/src/memory/history.ts +152 -0
  67. package/src/memory/ingest.ts +324 -11
  68. package/src/memory/model.ts +321 -0
  69. package/src/memory/normalize.ts +671 -0
  70. package/src/memory/parse.ts +22 -0
  71. package/src/memory/pipeline.ts +242 -30
  72. package/src/memory/prompts.ts +42 -0
  73. package/src/memory/query.ts +334 -0
  74. package/src/memory/reconcile.ts +734 -0
  75. package/src/memory/redirect.ts +108 -0
  76. package/src/memory/reindex.ts +325 -0
  77. package/src/memory/resolve.ts +307 -0
  78. package/src/memory/route.ts +272 -0
  79. package/src/memory/schedule.ts +6 -4
  80. package/src/memory/stages.ts +774 -0
  81. package/src/memory/synthesize.ts +1629 -20
  82. package/src/memory/tools.ts +446 -96
  83. package/src/memory/vault.ts +462 -35
  84. package/src/memory/wikify.ts +1057 -16
  85. package/src/mlx/ffi.ts +3 -0
  86. package/src/mlx/ops.ts +46 -0
  87. package/src/model/compiled-decode.ts +43 -8
  88. package/src/model/factory.ts +29 -8
  89. package/src/model/flash-attention.ts +4 -2
  90. package/src/model/fused-decode-kernel.ts +14 -7
  91. package/src/model/fused-mlp-kernel.ts +169 -0
  92. package/src/model/fused-swiglu-kernel.ts +240 -0
  93. package/src/model/gemma4-base.ts +62 -6
  94. package/src/model/gemma4.ts +33 -2
  95. package/src/model/minicpm5.ts +106 -8
  96. package/src/model/qwen3.ts +276 -0
  97. package/src/model/steel-linear-kernel.ts +96 -0
  98. package/src/model/support.ts +38 -8
  99. package/src/model/universal/archs.ts +332 -0
  100. package/src/model/universal/dense.ts +576 -0
  101. package/src/model/universal/modules.ts +163 -0
  102. package/src/model/universal/rope.ts +366 -0
  103. package/src/pi-provider.ts +1 -1
  104. package/src/pi-terminal.ts +29 -8
  105. package/src/pi-web.ts +19 -2
  106. package/src/quantize/config-writer.ts +1 -1
  107. package/src/registry.ts +215 -7
  108. package/src/responses.ts +23 -0
  109. package/src/sampler.ts +220 -9
  110. package/src/serve/batch-scheduler.ts +272 -80
  111. package/src/serve/generation-gateway.ts +86 -18
  112. package/src/server.ts +612 -49
  113. package/src/spec/drafter.ts +81 -49
  114. package/src/spec/dspark/data-dflash.ts +156 -0
  115. package/src/spec/dspark/data.ts +201 -0
  116. package/src/spec/dspark/generate-dflash.ts +193 -0
  117. package/src/spec/dspark/generate.ts +290 -0
  118. package/src/spec/dspark/loss.ts +213 -0
  119. package/src/spec/dspark/module-dflash.ts +361 -0
  120. package/src/spec/dspark/module.ts +540 -0
  121. package/src/spec/dspark/sample.ts +112 -0
  122. package/src/spec/generate.ts +40 -19
  123. package/src/status-page.html +1 -1
  124. package/src/tokenizer.ts +7 -0
  125. package/src/train/flash-cce.ts +70 -15
  126. package/src/train/fuse.ts +243 -0
  127. package/src/train/index.ts +3 -0
  128. package/src/train/job.ts +8 -0
  129. package/src/train/loss.ts +289 -30
  130. package/src/train/prefix-shared.ts +33 -5
  131. package/src/train/segmented.ts +67 -11
  132. package/src/train/trainer.ts +92 -28
  133. package/src/web/skills/memory/SKILL.md +40 -17
@@ -0,0 +1,671 @@
1
+ // mlx-bun memory — deterministic NORMALIZE pass (P6-T2). No model.
2
+ //
3
+ // After every write, before `commitVault`, an article is run through this pass
4
+ // so the invariants the read path depends on are CONSTITUTIVE, not advisory.
5
+ // Ported from lucien (~/Code/lucien): `normalize-footnotes.ts` (footnote
6
+ // bijection + contiguous renumber) and `normalize-wikilinks.ts` (spaced →
7
+ // underscore link canonicalization), extended with the Dreaming structural
8
+ // guards (infobox key sort, `## References` last / `## See also` before it,
9
+ // `{{stub}}` for thin articles, `title = H1 = stem`).
10
+ //
11
+ // Everything here is PURE (string in, string out) and idempotent: a second pass
12
+ // over a normalized article is a byte-identical no-op. Every step is non-fatal —
13
+ // a step that cannot complete logs a note and the others still run, so a single
14
+ // malformed region never corrupts the vault. `normalizeVault` is the on-disk
15
+ // entry the pipeline calls; the per-article core is `normalizeArticle`.
16
+
17
+ import { readFile, writeFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { isFenceLine, fenceLabel, parseInfobox, repairFences, serializeInfobox } from "./article";
20
+ import { articlesDir, listArticles, resolveWikilinkToStem, vaultRoot } from "./vault";
21
+
22
+ // ---- shared scanners --------------------------------------------------
23
+
24
+ const HEADING_RE = /^(#{1,6})\s+(.+)$/;
25
+ const DEF_LINE = /^[ \t]*\[\^(\d+)\]:/;
26
+ const SERIES_RE = /^\*Part of a series on \[\[([^\]]+)\]\]\.?\*$/;
27
+ /** A merged-away title's one-line pointer at its canonical article. */
28
+ const REDIRECT_RE = /^\*Redirects to \[\[[^\]]+\]\]\.?\*$/m;
29
+ /** The `{{stub}}` template, recognized only as a line of its own. */
30
+ const STUB_LINE_RE = /^\{\{stub\}\}\s*$/;
31
+ /** The `{{Main}}` summary-style pointer (`*Main article: [[Target]]*`). */
32
+ export const MAIN_POINTER_RE = /^\*Main article: \[\[[^\]]+\]\]\*$/;
33
+
34
+ // ---- footnote integrity (local copy of lucien wikify.ts) -------------
35
+
36
+ export interface CheckResult {
37
+ ok: boolean;
38
+ errors: string[];
39
+ }
40
+
41
+ /**
42
+ * Verify footnote markers and definitions are consistent:
43
+ * every `[^N]` body marker has a `[^N]:` definition and vice-versa; marker
44
+ * numbers are contiguous from 1; every definition carries exactly one
45
+ * backticked `conv:HASH`. (Ported from lucien `wikify.ts`.)
46
+ */
47
+ export function checkFootnoteIntegrity(text: string): CheckResult {
48
+ const errors: string[] = [];
49
+
50
+ const defNums = new Set<number>();
51
+ const defLineByNum = new Map<number, string>();
52
+ for (const m of text.matchAll(/^\[\^(\d+)\]:(.*)$/gm)) {
53
+ const n = parseInt(m[1]!, 10);
54
+ defNums.add(n);
55
+ defLineByNum.set(n, m[2]!);
56
+ }
57
+
58
+ // Body markers = every `[^N]` NOT on a `[^N]:` definition line. Only a
59
+ // line-START `[^N]:` is a definition; a marker glued to a list colon
60
+ // (`two types[^2]:`) is still a marker.
61
+ const bodyText = text
62
+ .split("\n")
63
+ .filter((l) => !/^\[\^\d+\]:/.test(l))
64
+ .join("\n");
65
+ const markerNums = new Set<number>();
66
+ for (const m of bodyText.matchAll(/\[\^(\d+)\]/g)) {
67
+ markerNums.add(parseInt(m[1]!, 10));
68
+ }
69
+
70
+ for (const n of markerNums) {
71
+ if (!defNums.has(n)) errors.push(`marker [^${n}] has no definition`);
72
+ }
73
+ for (const n of defNums) {
74
+ if (!markerNums.has(n)) errors.push(`definition [^${n}] has no marker`);
75
+ }
76
+
77
+ const all = [...new Set([...markerNums, ...defNums])].sort((a, b) => a - b);
78
+ for (let i = 0; i < all.length; i++) {
79
+ if (all[i] !== i + 1) {
80
+ errors.push(`footnote numbers not contiguous from 1 (saw ${all.join(",")})`);
81
+ break;
82
+ }
83
+ }
84
+
85
+ for (const [n, line] of defLineByNum) {
86
+ const backticked = line.match(/`conv:[0-9a-z]{8}`/gi) ?? [];
87
+ if (backticked.length !== 1) {
88
+ errors.push(`[^${n}] definition lacks a backticked \`conv:HASH\` (found ${backticked.length})`);
89
+ }
90
+ }
91
+
92
+ return { ok: errors.length === 0, errors };
93
+ }
94
+
95
+ /** True when two `[^N]:` definitions cite the same `conv:HASH` (the duplicate
96
+ * the merge step collapses); ignores defs without a hash. */
97
+ function hasDuplicateConvDefs(text: string): boolean {
98
+ const seen = new Set<string>();
99
+ for (const line of text.split("\n")) {
100
+ const m = line.match(/^[ \t]*\[\^(\d+)\]:(.*)$/);
101
+ if (!m) continue;
102
+ const c = m[2]!.match(/conv:[0-9a-z]{8}/i);
103
+ if (!c) continue;
104
+ const h = c[0]!.toLowerCase();
105
+ if (seen.has(h)) return true;
106
+ seen.add(h);
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // ---- footnote normalization (ported + merge) -------------------------
112
+
113
+ export interface NormalizeFootnoteResult {
114
+ article: string;
115
+ changed: boolean;
116
+ /** Orphan body markers (no definition) that were dropped. */
117
+ droppedMarkers: number[];
118
+ /** Orphan definitions (no body marker) that were dropped. */
119
+ droppedDefs: { num: number; conv: string | null }[];
120
+ /** Definitions merged into an earlier def citing the same `conv:HASH`. */
121
+ mergedDefs: number[];
122
+ /** Auditable summary of what changed, or null on a no-op. */
123
+ talk: string | null;
124
+ }
125
+
126
+ /** Distinct body-marker numbers in order of first appearance (defs masked). */
127
+ function bodyMarkerOrder(text: string): number[] {
128
+ const masked = text
129
+ .split("\n")
130
+ .map((l) => (DEF_LINE.test(l) ? "" : l))
131
+ .join("\n");
132
+ const order: number[] = [];
133
+ const seen = new Set<number>();
134
+ for (const m of masked.matchAll(/\[\^(\d+)\]/g)) {
135
+ const n = parseInt(m[1]!, 10);
136
+ if (!seen.has(n)) {
137
+ seen.add(n);
138
+ order.push(n);
139
+ }
140
+ }
141
+ return order;
142
+ }
143
+
144
+ /**
145
+ * Deterministically repair footnote integrity (ported from lucien
146
+ * `normalize-footnotes.ts`, extended to merge duplicate `conv:HASH` defs):
147
+ * drop orphan markers and orphan defs, collapse two defs that cite the same
148
+ * conversation into the earlier one, then renumber the survivors 1..k in order
149
+ * of first body appearance. A bijective, duplicate-free article is returned
150
+ * byte-identical (no-op), hence idempotent. Throws only if the post-condition
151
+ * (`checkFootnoteIntegrity`) fails — the orchestrator catches that.
152
+ */
153
+ export function normalizeFootnotes(article: string): NormalizeFootnoteResult {
154
+ if (checkFootnoteIntegrity(article).ok && !hasDuplicateConvDefs(article)) {
155
+ return { article, changed: false, droppedMarkers: [], droppedDefs: [], mergedDefs: [], talk: null };
156
+ }
157
+
158
+ const defNums = new Set<number>();
159
+ const defConv = new Map<number, string | null>();
160
+ for (const line of article.split("\n")) {
161
+ const m = line.match(/^[ \t]*\[\^(\d+)\]:(.*)$/);
162
+ if (m) {
163
+ const n = parseInt(m[1]!, 10);
164
+ defNums.add(n);
165
+ const c = m[2]!.match(/conv:[0-9a-z]{8}/i);
166
+ defConv.set(n, c ? c[0]!.toLowerCase() : null);
167
+ }
168
+ }
169
+
170
+ const bodyOrder = bodyMarkerOrder(article);
171
+ const bodySet = new Set(bodyOrder);
172
+
173
+ // Markers that also have a definition — the merge/renumber candidates.
174
+ const candidates = bodyOrder.filter((n) => defNums.has(n));
175
+ const droppedMarkers = bodyOrder.filter((n) => !defNums.has(n));
176
+ const droppedDefs = [...defNums]
177
+ .filter((n) => !bodySet.has(n))
178
+ .sort((a, b) => a - b)
179
+ .map((n) => ({ num: n, conv: defConv.get(n) ?? null }));
180
+
181
+ // Merge: each conv:HASH maps to its first-appearing candidate.
182
+ const canonByConv = new Map<string, number>();
183
+ const canonMap = new Map<number, number>();
184
+ for (const n of candidates) {
185
+ const c = defConv.get(n) ?? null;
186
+ if (c === null) {
187
+ canonMap.set(n, n);
188
+ continue;
189
+ }
190
+ if (!canonByConv.has(c)) canonByConv.set(c, n);
191
+ canonMap.set(n, canonByConv.get(c)!);
192
+ }
193
+ const mergedDefs = candidates.filter((n) => canonMap.get(n) !== n);
194
+
195
+ // Survivors in first-appearance order → contiguous 1..k.
196
+ const survivorOrder: number[] = [];
197
+ const seenSurv = new Set<number>();
198
+ for (const n of candidates) {
199
+ const c = canonMap.get(n)!;
200
+ if (!seenSurv.has(c)) {
201
+ seenSurv.add(c);
202
+ survivorOrder.push(c);
203
+ }
204
+ }
205
+ const remap = new Map<number, number>();
206
+ survivorOrder.forEach((oldN, i) => remap.set(oldN, i + 1));
207
+
208
+ const dropMarkerSet = new Set(droppedMarkers);
209
+ const dropDefSet = new Set<number>([...droppedDefs.map((d) => d.num), ...mergedDefs]);
210
+
211
+ const out = article
212
+ .split("\n")
213
+ .filter((line) => {
214
+ const m = line.match(DEF_LINE);
215
+ return !(m && dropDefSet.has(parseInt(m[1]!, 10)));
216
+ })
217
+ .map((line) =>
218
+ line.replace(/[ \t]?\[\^(\d+)\]/g, (whole, num: string) => {
219
+ const n = parseInt(num, 10);
220
+ if (dropMarkerSet.has(n)) return ""; // orphan marker
221
+ const canon = canonMap.get(n) ?? n;
222
+ const nn = remap.get(canon);
223
+ const lead = /^[ \t]/.test(whole) ? whole[0]! : "";
224
+ return nn ? `${lead}[^${nn}]` : whole;
225
+ }),
226
+ )
227
+ .join("\n");
228
+
229
+ const post = checkFootnoteIntegrity(out);
230
+ if (!post.ok) {
231
+ throw new Error(`normalize-footnotes post-condition failed: ${post.errors.join("; ")}`);
232
+ }
233
+
234
+ const parts: string[] = [];
235
+ if (droppedMarkers.length) {
236
+ parts.push(
237
+ `Dropped ${droppedMarkers.length} orphan citation marker(s) — ` +
238
+ droppedMarkers.map((n) => `[^${n}]`).join(", ") +
239
+ ` — marker emitted with no definition; nothing recoverable, prose left intact.`,
240
+ );
241
+ }
242
+ if (droppedDefs.length) {
243
+ parts.push(
244
+ `Dropped ${droppedDefs.length} unused definition(s): ` +
245
+ droppedDefs.map((d) => `[^${d.num}]${d.conv ? ` (\`${d.conv}\`)` : ""}`).join(", ") +
246
+ ` — no body marker referenced them.`,
247
+ );
248
+ }
249
+ if (mergedDefs.length) {
250
+ parts.push(
251
+ `Merged ${mergedDefs.length} duplicate definition(s) (${mergedDefs
252
+ .map((n) => `[^${n}]`)
253
+ .join(", ")}) into the earlier footnote citing the same conversation.`,
254
+ );
255
+ }
256
+ parts.push("Renumbered surviving footnotes contiguously from 1.");
257
+
258
+ return {
259
+ article: out,
260
+ changed: out !== article,
261
+ droppedMarkers,
262
+ droppedDefs,
263
+ mergedDefs,
264
+ talk: parts.join(" "),
265
+ };
266
+ }
267
+
268
+ // ---- wikilink canonicalization (ported) ------------------------------
269
+
270
+ export interface NormalizeWikilinkResult {
271
+ content: string;
272
+ /** Number of link targets rewritten to their canonical stem. */
273
+ edits: number;
274
+ /** Distinct unresolved (redlink) targets — flagged, not modified. */
275
+ orphans: string[];
276
+ }
277
+
278
+ /**
279
+ * Rewrite spaced/uncanonical wikilink targets to their underscore stem when the
280
+ * stem is a known article (ported from lucien `normalize-wikilinks.ts`, using
281
+ * `resolveWikilinkToStem` for resolution). The alias (`|alias`) and section
282
+ * anchor (`#Section`) are display/match text and are preserved verbatim;
283
+ * `conv:` and `Category:` links are left alone. Unresolved targets are true
284
+ * redlinks — collected for flagging, never rewritten. Idempotent: an already
285
+ * canonical link is untouched. Infobox entity-values are normalized by the same
286
+ * pass (the replace runs over the whole article text, fence included).
287
+ */
288
+ export function normalizeWikilinks(content: string, stems: Set<string>): NormalizeWikilinkResult {
289
+ let edits = 0;
290
+ const orphans = new Set<string>();
291
+
292
+ const out = content.replace(/\[\[([^\]]+?)\]\]/g, (whole, inner: string) => {
293
+ const pipeIdx = inner.indexOf("|");
294
+ const alias = pipeIdx === -1 ? null : inner.slice(pipeIdx + 1);
295
+ const beforeAlias = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);
296
+
297
+ const hashIdx = beforeAlias.indexOf("#");
298
+ const section = hashIdx === -1 ? null : beforeAlias.slice(hashIdx + 1);
299
+ const target = hashIdx === -1 ? beforeAlias : beforeAlias.slice(0, hashIdx);
300
+
301
+ const t = target.trim();
302
+ if (!t || /^conv:/i.test(t) || /^Category:/i.test(t)) return whole;
303
+
304
+ const resolved = resolveWikilinkToStem(t, stems);
305
+ if (resolved === null) {
306
+ orphans.add(t); // true redlink — flag, leave verbatim
307
+ return whole;
308
+ }
309
+ if (resolved === target) return whole; // already canonical
310
+
311
+ let rebuilt = resolved;
312
+ if (section !== null) rebuilt += `#${section}`;
313
+ if (alias !== null) rebuilt += `|${alias}`;
314
+ edits++;
315
+ return `[[${rebuilt}]]`;
316
+ });
317
+
318
+ return { content: out, edits, orphans: [...orphans] };
319
+ }
320
+
321
+ // ---- infobox key sort -------------------------------------------------
322
+
323
+ function findInfoBlock(lines: string[]): { start: number; end: number } | null {
324
+ for (let i = 0; i < lines.length; i++) {
325
+ if (isFenceLine(lines[i]!) && fenceLabel(lines[i]!) === "info") {
326
+ for (let j = i + 1; j < lines.length; j++) {
327
+ if (isFenceLine(lines[j]!)) return { start: i, end: j };
328
+ }
329
+ return null; // unterminated
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
335
+ /** Schema keys lead (`type`, `kind`), `aliases` trails, the rest alphabetical. */
336
+ function infoboxKeyRank(key: string): number {
337
+ if (key === "type") return 0;
338
+ if (key === "kind") return 1;
339
+ if (key === "aliases") return 3;
340
+ return 2;
341
+ }
342
+
343
+ /** Sort an article's infobox fields into a deterministic, idempotent order. */
344
+ export function sortInfoboxKeys(content: string): { content: string; changed: boolean } {
345
+ const box = parseInfobox(content);
346
+ if (!box || box.fields.length === 0) return { content, changed: false };
347
+
348
+ const sorted = box.fields
349
+ .map((f, i) => ({ f, i }))
350
+ .sort((a, b) => {
351
+ const ra = infoboxKeyRank(a.f.key);
352
+ const rb = infoboxKeyRank(b.f.key);
353
+ if (ra !== rb) return ra - rb;
354
+ const c = a.f.key.localeCompare(b.f.key);
355
+ return c !== 0 ? c : a.i - b.i; // stable on equal keys
356
+ })
357
+ .map((x) => x.f);
358
+
359
+ const sameOrder = sorted.every((f, i) => f === box.fields[i]);
360
+ if (sameOrder) return { content, changed: false };
361
+
362
+ const lines = content.split(/\r?\n/);
363
+ const block = findInfoBlock(lines);
364
+ if (!block) return { content, changed: false };
365
+
366
+ const rendered = serializeInfobox({ ...box, fields: sorted }).split("\n");
367
+ const next = [...lines.slice(0, block.start), ...rendered, ...lines.slice(block.end + 1)].join("\n");
368
+ return { content: next, changed: next !== content };
369
+ }
370
+
371
+ // ---- structural guards ------------------------------------------------
372
+
373
+ interface TopSection {
374
+ kind: "body" | "see" | "notes" | "refs";
375
+ lines: string[];
376
+ }
377
+
378
+ function splitTopSections(content: string): { head: string[]; sections: TopSection[] } {
379
+ const lines = content.split(/\r?\n/);
380
+ const head: string[] = [];
381
+ const sections: TopSection[] = [];
382
+ let cur: TopSection | null = null;
383
+ let inFence = false;
384
+ for (const line of lines) {
385
+ if (isFenceLine(line)) {
386
+ inFence = !inFence;
387
+ (cur ? cur.lines : head).push(line);
388
+ continue;
389
+ }
390
+ if (!inFence) {
391
+ const h = HEADING_RE.exec(line);
392
+ if (h && h[1]!.length === 2) {
393
+ const title = h[2]!.trim().toLowerCase();
394
+ const kind: TopSection["kind"] =
395
+ title === "see also" ? "see" : title === "notes" ? "notes" : title === "references" ? "refs" : "body";
396
+ cur = { kind, lines: [line] };
397
+ sections.push(cur);
398
+ continue;
399
+ }
400
+ }
401
+ (cur ? cur.lines : head).push(line);
402
+ }
403
+ return { head, sections };
404
+ }
405
+
406
+ /** Merge several same-kind trailing-meta sections (the duplicate `## References` /
407
+ * `## See also` blocks a fence-confused PATCH/cross-link left behind) into ONE:
408
+ * keep the first heading, then the union of every block's body lines with
409
+ * duplicate lines dropped (first occurrence wins) so repeated See-also bullets
410
+ * collapse and split footnote defs coalesce. Idempotent on a singleton group. */
411
+ function mergeMetaGroup(group: TopSection[]): TopSection {
412
+ if (group.length === 1) return group[0]!;
413
+ const heading = group[0]!.lines[0]!;
414
+ const body: string[] = [];
415
+ const seen = new Set<string>();
416
+ for (const s of group) {
417
+ for (const line of s.lines.slice(1)) {
418
+ const key = line.trim();
419
+ if (key === "") {
420
+ if (body.length && body[body.length - 1] !== "") body.push("");
421
+ continue;
422
+ }
423
+ if (seen.has(key)) continue;
424
+ seen.add(key);
425
+ body.push(line);
426
+ }
427
+ }
428
+ while (body.length && body[body.length - 1] === "") body.pop();
429
+ return { kind: group[0]!.kind, lines: [heading, ...body, ""] };
430
+ }
431
+
432
+ /** Reorder trailing meta sections so body → `See also` → `Notes` → `References`
433
+ * (References always last), MERGING any duplicate same-kind meta blocks into one
434
+ * along the way. Idempotent: an already-correct article (one block per kind, in
435
+ * order) is returned untouched. */
436
+ export function reorderSections(content: string): { content: string; changed: boolean } {
437
+ const { head, sections } = splitTopSections(content);
438
+ if (sections.length === 0) return { content, changed: false };
439
+
440
+ // Body sections stay distinct and in document order; the trailing-meta kinds are
441
+ // singletons by grammar, so collapse any duplicates into one block per kind.
442
+ const ordered: TopSection[] = sections.filter((s) => s.kind === "body");
443
+ for (const kind of ["see", "notes", "refs"] as const) {
444
+ const group = sections.filter((s) => s.kind === kind);
445
+ if (group.length) ordered.push(mergeMetaGroup(group));
446
+ }
447
+
448
+ const same = ordered.length === sections.length && ordered.every((s, i) => s === sections[i]);
449
+ if (same) return { content, changed: false };
450
+
451
+ const next = [...head, ...ordered.flatMap((s) => s.lines)].join("\n");
452
+ return { content: next, changed: next !== content };
453
+ }
454
+
455
+ /** Enforce `title = H1 = stem` (underscores → spaces). Rewrites a divergent H1
456
+ * and prepends one when absent. */
457
+ export function enforceH1Title(content: string, stem: string): { content: string; changed: boolean } {
458
+ const canonical = stem.replace(/_/g, " ");
459
+ const lines = content.split(/\r?\n/);
460
+ let inFence = false;
461
+ for (let i = 0; i < lines.length; i++) {
462
+ if (isFenceLine(lines[i]!)) {
463
+ inFence = !inFence;
464
+ continue;
465
+ }
466
+ if (inFence) continue;
467
+ const h = HEADING_RE.exec(lines[i]!);
468
+ if (h && h[1]!.length === 1) {
469
+ if (h[2]!.trim() === canonical) return { content, changed: false };
470
+ lines[i] = `# ${canonical}`;
471
+ return { content: lines.join("\n"), changed: true };
472
+ }
473
+ }
474
+ // No H1 found — prepend the canonical title.
475
+ return { content: `# ${canonical}\n\n${content}`, changed: true };
476
+ }
477
+
478
+ /** Body-section count and body-prose word count, EXCLUDING the infobox and the
479
+ * `## References` section (the L1 stub threshold inputs). */
480
+ export function articleThinness(content: string): { bodySections: number; bodyWords: number; stub: boolean } {
481
+ const lines = content.split(/\r?\n/);
482
+ let inFence = false;
483
+ let inRefs = false;
484
+ let bodySections = 0;
485
+ let bodyWords = 0;
486
+ for (const line of lines) {
487
+ if (isFenceLine(line)) {
488
+ inFence = !inFence;
489
+ continue;
490
+ }
491
+ if (inFence) continue;
492
+ const h = HEADING_RE.exec(line);
493
+ if (h) {
494
+ if (h[1]!.length === 2) {
495
+ const title = h[2]!.trim().toLowerCase();
496
+ inRefs = title === "references";
497
+ if (title !== "references" && title !== "see also" && title !== "notes") bodySections++;
498
+ }
499
+ continue; // headings are not prose
500
+ }
501
+ if (inRefs) continue;
502
+ const t = line.trim();
503
+ if (!t) continue;
504
+ if (STUB_LINE_RE.test(t)) continue;
505
+ if (/^categories:/i.test(t)) continue;
506
+ if (SERIES_RE.test(t)) continue;
507
+ bodyWords += t.split(/\s+/).length;
508
+ }
509
+ return { bodySections, bodyWords, stub: bodySections < 2 || bodyWords < 40 };
510
+ }
511
+
512
+ /** True when the article carries a `{{stub}}` line. */
513
+ export function hasStubMarker(content: string): boolean {
514
+ return content.split(/\r?\n/).some((l) => STUB_LINE_RE.test(l));
515
+ }
516
+
517
+ /** Mark a thin article `{{stub}}` (after the H1); remove a stale marker once the
518
+ * article has outgrown the threshold. */
519
+ export function stubGuard(content: string): { content: string; changed: boolean } {
520
+ const thin = articleThinness(content).stub;
521
+ const has = hasStubMarker(content);
522
+ if (thin === has) return { content, changed: false };
523
+
524
+ const lines = content.split(/\r?\n/);
525
+ if (!thin && has) {
526
+ const next = lines.filter((l) => !STUB_LINE_RE.test(l)).join("\n");
527
+ return { content: next, changed: next !== content };
528
+ }
529
+ // thin && !has — insert after the H1 (or at the very top if none).
530
+ let inFence = false;
531
+ let h1 = -1;
532
+ for (let i = 0; i < lines.length; i++) {
533
+ if (isFenceLine(lines[i]!)) {
534
+ inFence = !inFence;
535
+ continue;
536
+ }
537
+ if (inFence) continue;
538
+ const h = HEADING_RE.exec(lines[i]!);
539
+ if (h && h[1]!.length === 1) {
540
+ h1 = i;
541
+ break;
542
+ }
543
+ }
544
+ if (h1 < 0) lines.unshift("{{stub}}", "");
545
+ else lines.splice(h1 + 1, 0, "", "{{stub}}");
546
+ return { content: lines.join("\n"), changed: true };
547
+ }
548
+
549
+ // ---- orchestrator -----------------------------------------------------
550
+
551
+ export interface NormalizeArticleResult {
552
+ content: string;
553
+ changed: boolean;
554
+ /** Human-readable record of every normalization applied (non-fatal log). */
555
+ notes: string[];
556
+ /** Whether the normalized article is marked `{{stub}}`. */
557
+ stub: boolean;
558
+ /** Whether the article is a merge redirect (heavy guards are skipped). */
559
+ redirect: boolean;
560
+ }
561
+
562
+ /**
563
+ * Run the full deterministic NORMALIZE pass over one article. Every step is
564
+ * non-fatal: a step that throws (a footnote post-condition it cannot satisfy,
565
+ * say) is logged and skipped while the others still run, so a malformed region
566
+ * never blocks the rest. Idempotent.
567
+ */
568
+ export function normalizeArticle(content: string, opts: { stem: string; stems: Set<string> }): NormalizeArticleResult {
569
+ const notes: string[] = [];
570
+ let out = content;
571
+ const redirect = REDIRECT_RE.test(out);
572
+
573
+ // 0. Fence repair — heal an unbalanced ``` BEFORE any fence-aware step runs.
574
+ // An odd fence count leaves reorder/stub/H1 (and the read path) stuck "in code"
575
+ // for the rest of the file, so trailing ## References / ## See also are never
576
+ // recognized. Closing the dangling fence restores them. Non-fatal, idempotent.
577
+ const fx = repairFences(out);
578
+ if (fx.changed) {
579
+ out = fx.content;
580
+ notes.push("repaired unbalanced code fence");
581
+ }
582
+
583
+ // 1. Footnote bijection + renumber + merge.
584
+ try {
585
+ const fr = normalizeFootnotes(out);
586
+ if (fr.changed) {
587
+ out = fr.article;
588
+ if (fr.droppedMarkers.length) notes.push(`dropped ${fr.droppedMarkers.length} orphan footnote marker(s)`);
589
+ if (fr.droppedDefs.length) notes.push(`dropped ${fr.droppedDefs.length} orphan footnote def(s)`);
590
+ if (fr.mergedDefs.length) notes.push(`merged ${fr.mergedDefs.length} duplicate footnote def(s)`);
591
+ notes.push("renumbered footnotes contiguously");
592
+ }
593
+ } catch (err) {
594
+ notes.push(`footnote normalization skipped: ${(err as Error).message}`);
595
+ }
596
+
597
+ // 2. Wikilink canonicalization (prose + infobox entity-values).
598
+ const wl = normalizeWikilinks(out, opts.stems);
599
+ if (wl.edits > 0) {
600
+ out = wl.content;
601
+ notes.push(`canonicalized ${wl.edits} wikilink(s)`);
602
+ }
603
+ if (wl.orphans.length) notes.push(`unresolved wikilink(s): ${wl.orphans.join(", ")}`);
604
+
605
+ // 3. Infobox key sort.
606
+ const ib = sortInfoboxKeys(out);
607
+ if (ib.changed) {
608
+ out = ib.content;
609
+ notes.push("sorted infobox keys");
610
+ }
611
+
612
+ // 4. Structural guards.
613
+ const h1 = enforceH1Title(out, opts.stem);
614
+ if (h1.changed) {
615
+ out = h1.content;
616
+ notes.push("normalized H1 title to stem");
617
+ }
618
+ if (!redirect) {
619
+ const ro = reorderSections(out);
620
+ if (ro.changed) {
621
+ out = ro.content;
622
+ notes.push("reordered trailing sections (References last)");
623
+ }
624
+ const sg = stubGuard(out);
625
+ if (sg.changed) {
626
+ out = sg.content;
627
+ notes.push(hasStubMarker(out) ? "marked {{stub}} (thin article)" : "removed stale {{stub}}");
628
+ }
629
+ }
630
+
631
+ return { content: out, changed: out !== content, notes, stub: hasStubMarker(out), redirect };
632
+ }
633
+
634
+ // ---- on-disk entry (runs after every write, before commitVault) ------
635
+
636
+ export interface VaultNormalizeSummary {
637
+ /** Stems whose on-disk content changed. */
638
+ normalized: string[];
639
+ /** Per-stem normalization notes (only stems with at least one note). */
640
+ notes: Record<string, string[]>;
641
+ }
642
+
643
+ /**
644
+ * Normalize every article in the vault, writing back the ones that changed.
645
+ * Best-effort and non-fatal per article; the caller commits afterward. The stem
646
+ * set is the whole article list so wikilink resolution sees every neighbor.
647
+ */
648
+ export async function normalizeVault(root = vaultRoot()): Promise<VaultNormalizeSummary> {
649
+ const stems = await listArticles(root);
650
+ const stemSet = new Set(stems);
651
+ const normalized: string[] = [];
652
+ const notes: Record<string, string[]> = {};
653
+
654
+ for (const stem of stems) {
655
+ const path = join(articlesDir(root), `${stem}.md`);
656
+ let content: string;
657
+ try {
658
+ content = await readFile(path, "utf8");
659
+ } catch {
660
+ continue;
661
+ }
662
+ const r = normalizeArticle(content, { stem, stems: stemSet });
663
+ if (r.notes.length) notes[stem] = r.notes;
664
+ if (r.changed) {
665
+ await writeFile(path, r.content);
666
+ normalized.push(stem);
667
+ }
668
+ }
669
+
670
+ return { normalized, notes };
671
+ }
@@ -0,0 +1,22 @@
1
+ // mlx-bun memory — parse contracts for model outputs.
2
+ //
3
+ // The synthesis pipeline drives the model with small, structured prompts and
4
+ // needs deterministic parsing of free-text completions back into typed values.
5
+ // Two shapes cover the cases: a yes/no binary gate and a newline list.
6
+
7
+ /** Binary gate: true iff the output starts with `y`/`Y` (everything else → false). */
8
+ export function parseBinary(out: string): boolean {
9
+ return /^\s*y/i.test(out);
10
+ }
11
+
12
+ /**
13
+ * List output: split on newlines, trim each line, and drop empties as well as
14
+ * any line that is exactly "NONE" (case-insensitive) — the model's sentinel for
15
+ * "no items".
16
+ */
17
+ export function parseLines(out: string): string[] {
18
+ return out
19
+ .split("\n")
20
+ .map((line) => line.trim())
21
+ .filter((line) => line.length > 0 && line.toLowerCase() !== "none");
22
+ }