mlx-bun 0.0.8 → 0.0.10
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.
- package/README.md +112 -20
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/batching-v2-plan.md +515 -0
- package/docs/design/bucketing-stage.md +196 -0
- package/docs/design/compat-cli-surface-design.md +11 -11
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dreaming-nightly-pipeline.md +291 -0
- package/docs/design/dspark-speculative-decoding.md +106 -0
- package/docs/design/generic-model-support.md +381 -0
- package/docs/design/memory-inference-path.md +422 -0
- package/docs/design/memory-synthesis.md +287 -0
- package/docs/design/minicpm5-decode-megakernel.md +484 -0
- package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/orpo-dynamic-lambda.md +41 -0
- package/docs/design/orpo-training.md +35 -5
- package/docs/design/parallel-slots.md +12 -1
- package/docs/design/parity-tier-dag.md +13 -5
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/design/tauri-desktop-app.md +2 -2
- package/docs/design/the-dreaming-handoff.md +141 -0
- package/docs/design/the-dreaming-master-plan.md +446 -0
- package/docs/design/write-pipeline-entity.md +468 -0
- package/docs/investigations/curve-bisector-routing.md +1 -1
- package/docs/investigations/decode-roofline-lookagain.md +348 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -2
- package/docs/planning/curve-sampler-research-plan.md +331 -0
- package/docs/planning/memory-docs-and-dag-plan.md +185 -0
- package/docs/planning/pi-integration-review.md +178 -0
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.9.md +122 -0
- package/docs/planning/web-ui-pass-plan.md +213 -0
- package/docs/planning/website-readme-pass-plan.md +224 -0
- package/docs/reference/cli.md +255 -0
- package/docs/reference/embedding.md +7 -2
- package/docs/reference/library-api.md +22 -3
- package/docs/reference/memory.md +5 -5
- package/docs/reference/models.md +137 -0
- package/docs/reference/orpo-quickstart.md +6 -0
- package/docs/reference/server-api.md +187 -9
- package/docs/reference/server-config.md +43 -9
- package/docs/reference/training.md +31 -12
- package/package.json +1 -1
- package/src/anthropic.ts +22 -0
- package/src/assets/curve-designer.html +1 -1
- package/src/chat-template.ts +25 -7
- package/src/cli.ts +1077 -90
- package/src/config.ts +4 -1
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +15 -6
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +149 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/kv-store.ts +240 -98
- package/src/lora.ts +2 -2
- package/src/memory/article.ts +558 -0
- package/src/memory/chunk-validation.ts +150 -0
- package/src/memory/chunk.ts +305 -12
- package/src/memory/cluster.ts +344 -17
- package/src/memory/coverage.ts +134 -0
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +184 -45
- package/src/memory/entity.ts +203 -0
- package/src/memory/gate.ts +187 -0
- package/src/memory/history.ts +152 -0
- package/src/memory/ingest.ts +324 -11
- package/src/memory/model.ts +321 -0
- package/src/memory/normalize.ts +671 -0
- package/src/memory/parse.ts +22 -0
- package/src/memory/pipeline.ts +242 -30
- package/src/memory/prompts.ts +42 -0
- package/src/memory/query.ts +334 -0
- package/src/memory/reconcile.ts +734 -0
- package/src/memory/redirect.ts +108 -0
- package/src/memory/reindex.ts +325 -0
- package/src/memory/resolve.ts +307 -0
- package/src/memory/route.ts +272 -0
- package/src/memory/schedule.ts +6 -4
- package/src/memory/stages.ts +774 -0
- package/src/memory/synthesize.ts +1629 -20
- package/src/memory/tools.ts +446 -96
- package/src/memory/vault.ts +462 -35
- package/src/memory/wikify.ts +1057 -16
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ffi.ts +3 -0
- package/src/mlx/ops.ts +72 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/factory.ts +29 -8
- package/src/model/flash-attention.ts +4 -2
- package/src/model/fused-decode-kernel.ts +14 -7
- package/src/model/fused-mlp-kernel.ts +169 -0
- package/src/model/fused-swiglu-kernel.ts +240 -0
- package/src/model/gemma4-base.ts +96 -7
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3.ts +276 -0
- package/src/model/qwen3_5.ts +11 -2
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +38 -8
- package/src/model/universal/archs.ts +332 -0
- package/src/model/universal/dense.ts +576 -0
- package/src/model/universal/modules.ts +163 -0
- package/src/model/universal/rope.ts +366 -0
- package/src/pi-provider.ts +1 -1
- package/src/pi-terminal.ts +29 -8
- package/src/pi-web.ts +19 -2
- package/src/prompt-cache.ts +55 -9
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +219 -8
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +307 -83
- package/src/serve/generation-gateway.ts +156 -29
- package/src/server.ts +746 -73
- package/src/spec/drafter.ts +81 -49
- package/src/spec/dspark/data-dflash.ts +156 -0
- package/src/spec/dspark/data.ts +201 -0
- package/src/spec/dspark/generate-dflash.ts +193 -0
- package/src/spec/dspark/generate.ts +290 -0
- package/src/spec/dspark/loss.ts +213 -0
- package/src/spec/dspark/module-dflash.ts +361 -0
- package/src/spec/dspark/module.ts +540 -0
- package/src/spec/dspark/sample.ts +112 -0
- package/src/spec/generate.ts +40 -19
- package/src/ssd-cache.ts +213 -0
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +81 -23
- package/src/train/fuse.ts +243 -0
- package/src/train/index.ts +3 -0
- package/src/train/job.ts +8 -0
- package/src/train/loss.ts +341 -40
- package/src/train/prefix-shared.ts +62 -11
- package/src/train/segmented.ts +215 -55
- package/src/train/trainer.ts +112 -38
- 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
|
+
}
|