mlx-bun 0.0.7 → 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 (138) 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/diffusion-gemma-port.md +602 -0
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/orpo-dynamic-lambda.md +41 -0
  15. package/docs/design/orpo-training.md +35 -5
  16. package/docs/design/parallel-slots.md +12 -1
  17. package/docs/design/parity-tier-dag.md +13 -5
  18. package/docs/design/spec-decode-larger-targets.md +229 -0
  19. package/docs/design/tauri-desktop-app.md +2 -2
  20. package/docs/design/the-dreaming-handoff.md +141 -0
  21. package/docs/design/the-dreaming-master-plan.md +446 -0
  22. package/docs/design/write-pipeline-entity.md +468 -0
  23. package/docs/investigations/curve-bisector-routing.md +1 -1
  24. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  25. package/docs/investigations/dspark-handoff.md +58 -0
  26. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  27. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  28. package/docs/planning/curve-sampler-research-plan.md +331 -0
  29. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  30. package/docs/planning/pi-integration-review.md +178 -0
  31. package/docs/planning/release-notes-v0.0.9.md +122 -0
  32. package/docs/planning/web-ui-pass-plan.md +213 -0
  33. package/docs/planning/website-readme-pass-plan.md +224 -0
  34. package/docs/reference/cli.md +255 -0
  35. package/docs/reference/embedding.md +7 -2
  36. package/docs/reference/library-api.md +22 -3
  37. package/docs/reference/memory.md +5 -5
  38. package/docs/reference/models.md +137 -0
  39. package/docs/reference/orpo-quickstart.md +6 -0
  40. package/docs/reference/server-api.md +187 -9
  41. package/docs/reference/server-config.md +40 -9
  42. package/docs/reference/training.md +25 -11
  43. package/package.json +1 -1
  44. package/src/anthropic.ts +22 -0
  45. package/src/assets/curve-designer.html +1 -1
  46. package/src/chat-template.ts +25 -7
  47. package/src/cli.ts +1029 -86
  48. package/src/config.ts +54 -3
  49. package/src/diffusion/diffusion-generate.ts +436 -0
  50. package/src/download.ts +99 -8
  51. package/src/embed.ts +77 -0
  52. package/src/eval/perplexity.ts +164 -0
  53. package/src/eval/runner.ts +19 -7
  54. package/src/eval/tasks/bfcl.ts +1 -1
  55. package/src/generate.ts +196 -19
  56. package/src/harness-pi.ts +43 -17
  57. package/src/index.ts +2 -0
  58. package/src/lora.ts +2 -2
  59. package/src/memory/article.ts +558 -0
  60. package/src/memory/chunk-validation.ts +150 -0
  61. package/src/memory/chunk.ts +305 -12
  62. package/src/memory/cluster.ts +344 -17
  63. package/src/memory/coverage.ts +134 -0
  64. package/src/memory/crosslink.ts +0 -0
  65. package/src/memory/db.ts +184 -45
  66. package/src/memory/entity.ts +203 -0
  67. package/src/memory/gate.ts +187 -0
  68. package/src/memory/history.ts +152 -0
  69. package/src/memory/ingest.ts +324 -11
  70. package/src/memory/model.ts +321 -0
  71. package/src/memory/normalize.ts +671 -0
  72. package/src/memory/parse.ts +22 -0
  73. package/src/memory/pipeline.ts +242 -30
  74. package/src/memory/prompts.ts +42 -0
  75. package/src/memory/query.ts +334 -0
  76. package/src/memory/reconcile.ts +734 -0
  77. package/src/memory/redirect.ts +108 -0
  78. package/src/memory/reindex.ts +325 -0
  79. package/src/memory/resolve.ts +307 -0
  80. package/src/memory/route.ts +272 -0
  81. package/src/memory/schedule.ts +6 -4
  82. package/src/memory/stages.ts +774 -0
  83. package/src/memory/synthesize.ts +1629 -20
  84. package/src/memory/tools.ts +446 -96
  85. package/src/memory/vault.ts +462 -35
  86. package/src/memory/wikify.ts +1057 -16
  87. package/src/mlx/ffi.ts +11 -0
  88. package/src/mlx/ops.ts +113 -0
  89. package/src/model/compiled-decode.ts +43 -8
  90. package/src/model/diffusion-gemma.ts +1026 -0
  91. package/src/model/factory.ts +35 -8
  92. package/src/model/flash-attention.ts +4 -2
  93. package/src/model/fused-decode-kernel.ts +14 -7
  94. package/src/model/fused-mlp-kernel.ts +169 -0
  95. package/src/model/fused-swiglu-kernel.ts +240 -0
  96. package/src/model/gemma4-base.ts +62 -6
  97. package/src/model/gemma4.ts +33 -2
  98. package/src/model/minicpm5.ts +106 -8
  99. package/src/model/qwen3.ts +276 -0
  100. package/src/model/steel-linear-kernel.ts +96 -0
  101. package/src/model/support.ts +45 -7
  102. package/src/model/universal/archs.ts +332 -0
  103. package/src/model/universal/dense.ts +576 -0
  104. package/src/model/universal/modules.ts +163 -0
  105. package/src/model/universal/rope.ts +366 -0
  106. package/src/pi-provider.ts +1 -1
  107. package/src/pi-terminal.ts +29 -8
  108. package/src/pi-web.ts +19 -2
  109. package/src/quantize/config-writer.ts +1 -1
  110. package/src/registry.ts +215 -7
  111. package/src/responses.ts +23 -0
  112. package/src/sampler.ts +220 -9
  113. package/src/serve/batch-scheduler.ts +272 -80
  114. package/src/serve/generation-gateway.ts +90 -18
  115. package/src/server.ts +643 -50
  116. package/src/spec/drafter.ts +81 -49
  117. package/src/spec/dspark/data-dflash.ts +156 -0
  118. package/src/spec/dspark/data.ts +201 -0
  119. package/src/spec/dspark/generate-dflash.ts +193 -0
  120. package/src/spec/dspark/generate.ts +290 -0
  121. package/src/spec/dspark/loss.ts +213 -0
  122. package/src/spec/dspark/module-dflash.ts +361 -0
  123. package/src/spec/dspark/module.ts +540 -0
  124. package/src/spec/dspark/sample.ts +112 -0
  125. package/src/spec/generate.ts +40 -19
  126. package/src/status-page.html +1 -1
  127. package/src/tokenizer.ts +7 -0
  128. package/src/train/diffusion-lora.ts +216 -0
  129. package/src/train/flash-cce.ts +70 -15
  130. package/src/train/fuse.ts +243 -0
  131. package/src/train/index.ts +3 -0
  132. package/src/train/job.ts +8 -0
  133. package/src/train/loss.ts +289 -30
  134. package/src/train/prefix-shared.ts +33 -5
  135. package/src/train/segmented.ts +67 -11
  136. package/src/train/trainer.ts +92 -28
  137. package/src/vision/diffusion-vision.ts +620 -0
  138. package/src/web/skills/memory/SKILL.md +40 -17
package/src/memory/db.ts CHANGED
@@ -1,19 +1,29 @@
1
- // mlx-bun memory — synthesis bookkeeping store (bun:sqlite).
1
+ // mlx-bun memory — "The Dreaming" synthesis store (bun:sqlite).
2
2
  //
3
- // ⚠️ M1 STUB schema-wise the tables are real, but no stage writes them yet.
4
- // The read path (vault.ts / tools.ts) does NOT touch this — it's only used by
5
- // the synthesis pipeline (pipeline.ts), which is itself stubbed.
3
+ // Two kinds of tables live here:
6
4
  //
7
- // Reuses mlx-bun's existing per-domain sqlite pattern (cf. JobStore in
8
- // src/jobs/db.ts, EvalDB in src/evaldb.ts): a small class over bun:sqlite with
9
- // WAL + schema-in-constructor + an additive migrate() hook, living under
10
- // ~/.cache/mlx-bun/ alongside jobs.sqlite / evals.sqlite / registry.sqlite. It's
11
- // derived state — rebuildable from the pi transcripts + the vault articles — so
12
- // it belongs in the rebuildable cache, NOT in the (precious, git-tracked) vault.
5
+ // • DERIVED CACHE — entities, entity_aliases, categories, article_categories,
6
+ // infobox_facts, links. These are a *cache of the markdown vault*: every row
7
+ // can be regenerated by reindexing the articles. reindex makes them match the
8
+ // vault, NEVER the reverse — if a row disagrees with the markdown, the
9
+ // markdown wins and the row is rebuilt.
13
10
  //
14
- // The schema mirrors lucien's incremental design: per-source watermarks (don't
15
- // re-ingest), chunked_at > updated_at (re-chunk only changed convs), and a
16
- // synthesized-chunk ledger (never fold the same chunk into an article twice).
11
+ // • SYNTHESIS LEDGER — messages, chunks, chunk_entities, chunk_sections,
12
+ // synthesized_chunk_sections, watermarks (+ buckets/chunk_buckets +
13
+ // subject_redirects, the fold/capture sub-concept→home-article edges that the
14
+ // reindex projects into entity_aliases). These are
15
+ // the pipeline's bookkeeping: what we ingested, how we cut it, and what has
16
+ // already been folded into the vault. This is the authoritative record of
17
+ // pipeline progress and is not reconstructable from the vault alone.
18
+ //
19
+ // Storage is pointer-based: chunks do NOT copy conversation text. A chunk is a
20
+ // [start,end] position range into its conversation's messages; chunkText()
21
+ // reassembles the slice on demand. Total stored text == the messages, once.
22
+ //
23
+ // Reuses mlx-bun's per-domain sqlite pattern (cf. JobStore in src/jobs/db.ts,
24
+ // EvalDB in src/evaldb.ts): a small class over bun:sqlite with WAL +
25
+ // schema-in-constructor + a migrate() hook, living under ~/.cache/mlx-bun/.
26
+ // It's rebuildable derived state, so it belongs in the cache, NOT the vault.
17
27
 
18
28
  import { Database } from "bun:sqlite";
19
29
  import { mkdirSync } from "node:fs";
@@ -25,68 +35,156 @@ export interface ConversationRow {
25
35
  conv: string;
26
36
  source: string; // "pi-web" | "pi-terminal"
27
37
  title: string;
28
- /** Full normalized transcript JSON ({role, content}[]). */
29
- transcript: string;
30
38
  updated_at: number;
31
39
  chunked_at: number | null;
32
40
  }
33
41
 
34
42
  export interface ChunkRow {
43
+ /** Deterministic id `${conv}:${start}-${end}` — idempotent re-chunking. */
35
44
  id: string;
36
45
  conv: string;
37
- ordinal: number;
38
- text: string;
39
- bucket: string | null;
46
+ start: number;
47
+ end: number;
48
+ label: string | null;
49
+ }
50
+
51
+ export interface EntityRow {
52
+ name: string;
53
+ article_stem: string | null;
54
+ kind: string | null;
55
+ notable: number;
40
56
  }
41
57
 
42
58
  export interface BucketRow {
43
59
  name: string;
44
- article: string; // target article stem
60
+ article_stem: string;
45
61
  description: string;
46
62
  }
47
63
 
64
+ /** Deterministic chunk id from its conversation + position range. */
65
+ export function chunkId(conv: string, start: number, end: number): string {
66
+ return `${conv}:${start}-${end}`;
67
+ }
68
+
48
69
  const SCHEMA = `
49
70
  CREATE TABLE IF NOT EXISTS conversations (
50
71
  conv TEXT PRIMARY KEY,
51
- source TEXT NOT NULL,
52
- title TEXT NOT NULL DEFAULT '',
53
- transcript TEXT NOT NULL,
54
- updated_at INTEGER NOT NULL,
72
+ source TEXT,
73
+ title TEXT DEFAULT '',
74
+ updated_at INTEGER,
55
75
  chunked_at INTEGER
56
76
  );
77
+ CREATE TABLE IF NOT EXISTS messages (
78
+ conv TEXT REFERENCES conversations(conv) ON DELETE CASCADE,
79
+ position INTEGER,
80
+ role TEXT,
81
+ uuid TEXT,
82
+ text TEXT,
83
+ PRIMARY KEY (conv, position)
84
+ );
57
85
  CREATE TABLE IF NOT EXISTS chunks (
58
- id TEXT PRIMARY KEY,
59
- conv TEXT NOT NULL REFERENCES conversations(conv),
60
- ordinal INTEGER NOT NULL,
61
- text TEXT NOT NULL,
62
- bucket TEXT
86
+ id TEXT PRIMARY KEY,
87
+ conv TEXT REFERENCES conversations(conv) ON DELETE CASCADE,
88
+ start INTEGER,
89
+ end INTEGER,
90
+ label TEXT
63
91
  );
64
92
  CREATE INDEX IF NOT EXISTS idx_chunks_conv ON chunks(conv);
65
- CREATE TABLE IF NOT EXISTS buckets (
66
- name TEXT PRIMARY KEY,
67
- article TEXT NOT NULL,
68
- description TEXT NOT NULL DEFAULT ''
93
+ CREATE TABLE IF NOT EXISTS entities (
94
+ name TEXT PRIMARY KEY,
95
+ article_stem TEXT,
96
+ kind TEXT,
97
+ notable INTEGER DEFAULT 0
98
+ );
99
+ CREATE TABLE IF NOT EXISTS entity_aliases (
100
+ alias TEXT,
101
+ entity_name TEXT REFERENCES entities(name) ON DELETE CASCADE,
102
+ PRIMARY KEY (alias, entity_name)
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_aliases_alias ON entity_aliases(alias);
105
+ CREATE TABLE IF NOT EXISTS chunk_entities (
106
+ chunk_id TEXT REFERENCES chunks(id) ON DELETE CASCADE,
107
+ entity_name TEXT REFERENCES entities(name) ON DELETE CASCADE,
108
+ surface_form TEXT,
109
+ PRIMARY KEY (chunk_id, entity_name)
110
+ );
111
+ CREATE TABLE IF NOT EXISTS categories (
112
+ name TEXT PRIMARY KEY,
113
+ definition TEXT DEFAULT '',
114
+ parent TEXT REFERENCES categories(name) ON DELETE SET NULL
115
+ );
116
+ CREATE TABLE IF NOT EXISTS article_categories (
117
+ article_stem TEXT,
118
+ category TEXT REFERENCES categories(name) ON DELETE CASCADE,
119
+ source TEXT,
120
+ PRIMARY KEY (article_stem, category)
121
+ );
122
+ CREATE INDEX IF NOT EXISTS idx_artcat_category ON article_categories(category);
123
+ CREATE TABLE IF NOT EXISTS infobox_facts (
124
+ article_stem TEXT,
125
+ key TEXT,
126
+ value TEXT,
127
+ kind TEXT,
128
+ entity_link TEXT,
129
+ PRIMARY KEY (article_stem, key, value)
130
+ );
131
+ CREATE INDEX IF NOT EXISTS idx_infobox_key ON infobox_facts(key);
132
+ CREATE INDEX IF NOT EXISTS idx_infobox_link ON infobox_facts(entity_link);
133
+ CREATE TABLE IF NOT EXISTS links (
134
+ src_stem TEXT,
135
+ dst_stem TEXT,
136
+ via TEXT,
137
+ PRIMARY KEY (src_stem, dst_stem, via)
138
+ );
139
+ CREATE INDEX IF NOT EXISTS idx_links_dst ON links(dst_stem);
140
+ CREATE TABLE IF NOT EXISTS chunk_sections (
141
+ chunk_id TEXT REFERENCES chunks(id) ON DELETE CASCADE,
142
+ article_stem TEXT,
143
+ section_anchor TEXT,
144
+ PRIMARY KEY (chunk_id, article_stem, section_anchor)
145
+ );
146
+ CREATE TABLE IF NOT EXISTS synthesized_chunk_sections (
147
+ chunk_id TEXT REFERENCES chunks(id) ON DELETE CASCADE,
148
+ article_stem TEXT,
149
+ section_anchor TEXT,
150
+ synthesized_at INTEGER,
151
+ PRIMARY KEY (chunk_id, article_stem, section_anchor)
152
+ );
153
+ CREATE TABLE IF NOT EXISTS reconciled_articles (
154
+ article_stem TEXT,
155
+ reconciled_at INTEGER,
156
+ refreshed TEXT,
157
+ rewritten TEXT,
158
+ PRIMARY KEY (article_stem, reconciled_at)
69
159
  );
70
- -- Per-source ingest watermark: only process what's new since last run.
71
160
  CREATE TABLE IF NOT EXISTS watermarks (
72
161
  source TEXT PRIMARY KEY,
73
- cursor TEXT NOT NULL,
74
- updated_at INTEGER NOT NULL
162
+ cursor TEXT,
163
+ updated_at INTEGER
164
+ );
165
+ CREATE TABLE IF NOT EXISTS buckets (
166
+ name TEXT PRIMARY KEY,
167
+ article_stem TEXT,
168
+ description TEXT DEFAULT ''
169
+ );
170
+ CREATE TABLE IF NOT EXISTS chunk_buckets (
171
+ chunk_id TEXT REFERENCES chunks(id) ON DELETE CASCADE,
172
+ bucket TEXT REFERENCES buckets(name) ON DELETE CASCADE,
173
+ PRIMARY KEY (chunk_id, bucket)
75
174
  );
76
- -- Idempotency ledger: a (bucket, chunk) pair is folded into an article once.
77
- CREATE TABLE IF NOT EXISTS synthesized_bucket_chunks (
78
- bucket TEXT NOT NULL,
79
- chunk_id TEXT NOT NULL,
80
- PRIMARY KEY (bucket, chunk_id)
175
+ CREATE TABLE IF NOT EXISTS subject_redirects (
176
+ surface TEXT,
177
+ target_stem TEXT,
178
+ kind TEXT,
179
+ PRIMARY KEY (surface, target_stem)
81
180
  );
181
+ CREATE INDEX IF NOT EXISTS idx_redirect_target ON subject_redirects(target_stem);
82
182
  `;
83
183
 
84
184
  /**
85
- * Synthesis bookkeeping DB. Same shape as JobStore/EvalDB: open in the
86
- * constructor (WAL on, schema ensured, additive migrate), expose `db`, and a
87
- * `close()`. Only the synthesis pipeline constructs one — opening it is what
88
- * *creates* memory.sqlite, so the read path never does. Stage queries land in
89
- * M1; the tables they expect already exist here.
185
+ * "The Dreaming" synthesis store. Same shape as JobStore/EvalDB: open in the
186
+ * constructor (WAL on, foreign keys on, schema ensured, additive migrate),
187
+ * expose `db`, and a `close()`.
90
188
  */
91
189
  export class MemoryStore {
92
190
  readonly db: Database;
@@ -99,15 +197,56 @@ export class MemoryStore {
99
197
  this.dbPath = dbPath;
100
198
  this.db = new Database(dbPath);
101
199
  this.db.exec("PRAGMA journal_mode = WAL");
200
+ if (this.hasLegacySchema()) this.dropAllTables();
201
+ this.db.exec("PRAGMA foreign_keys = ON");
102
202
  this.db.exec(SCHEMA);
103
203
  this.migrate();
104
204
  }
105
205
 
206
+ /**
207
+ * A DB written by the pre-rewrite (bucket/transcript) schema. There is no
208
+ * production data to preserve, so detecting either marker means: nuke and
209
+ * recreate from scratch. Markers: chunks.text column, or the dropped
210
+ * synthesized_bucket_chunks table.
211
+ */
212
+ private hasLegacySchema(): boolean {
213
+ const cols = this.db.query("PRAGMA table_info(chunks)").all() as { name: string }[];
214
+ if (cols.some((c) => c.name === "text")) return true;
215
+ const legacy = this.db
216
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name='synthesized_bucket_chunks'")
217
+ .get();
218
+ return legacy != null;
219
+ }
220
+
221
+ /** Drop every user table (FK checks off during the teardown). */
222
+ private dropAllTables(): void {
223
+ this.db.exec("PRAGMA foreign_keys = OFF");
224
+ const tables = this.db
225
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
226
+ .all() as { name: string }[];
227
+ for (const { name } of tables) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
228
+ }
229
+
106
230
  /** Additive-only migrations (mirrors JobStore.migrate). Re-opening is a no-op. */
107
231
  private migrate(): void {
108
232
  // No columns to add today; the hook stays so future fields are additive.
109
233
  }
110
234
 
235
+ /**
236
+ * Reassemble a chunk's text from its messages by position range — chunks are
237
+ * pointers, not copies. Returns "" for an unknown chunk id.
238
+ */
239
+ chunkText(id: string): string {
240
+ const chunk = this.db
241
+ .query("SELECT conv, start, end FROM chunks WHERE id = ?")
242
+ .get(id) as { conv: string; start: number; end: number } | null;
243
+ if (!chunk) return "";
244
+ const rows = this.db
245
+ .query("SELECT text FROM messages WHERE conv = ? AND position BETWEEN ? AND ? ORDER BY position")
246
+ .all(chunk.conv, chunk.start, chunk.end) as { text: string }[];
247
+ return rows.map((r) => r.text).join("\n");
248
+ }
249
+
111
250
  close(): void {
112
251
  this.db.close();
113
252
  }
@@ -0,0 +1,203 @@
1
+ // mlx-bun memory — ENTITY-EXTRACT (P5-T3).
2
+ //
3
+ // Reads ONE chunk's reassembled TEXT and asks the local model "what things is
4
+ // this chunk about?", returning canonical wiki-title entity names — the specific
5
+ // things AND the broad domain. This is the REAL routing-thesis test: unlike the
6
+ // P4 label-proxy (which routed on the 4–10-word episodic chunk label), this
7
+ // stage reads the full conversation text, so it sees the entities the label
8
+ // merely gestures at.
9
+ //
10
+ // Design (P5-T3):
11
+ // - ONE chunk per call (NO multi-item JSON, NO batch indices — that
12
+ // batch-index drift is what sank Lucien's batched assign). Output is a plain
13
+ // newline list parsed by parseLines.
14
+ // - `Entities.md` policy (the five kinds, notability checklist, naming rules,
15
+ // emit-both-granularities) is inlined into the prompt via loadMetaPolicy, so
16
+ // editing the vault page re-tunes extraction with no code change.
17
+ // - Deterministic canonicalization (casefold, ws-collapse, strip leading
18
+ // articles/possessives) so a name's spacing/short-form/brand-only variants
19
+ // collapse to one stem. The canonical stem feeds dedup + the entity_aliases index.
20
+ // - Persists chunk_entities(chunk_id, entity_name, surface_form) and upserts a
21
+ // candidate entities row (article_stem NULL — no article exists yet) so the
22
+ // FK on chunk_entities.entity_name is satisfied; the raw model surface is
23
+ // stored as an alias of the canonical name.
24
+ //
25
+ // The base model + policy runs until a `memory-entity` LoRA is trained (P10-T3),
26
+ // which only happens on a measured recall miss here.
27
+
28
+ import type { MemoryStore } from "./db";
29
+ import { callLocal } from "./model";
30
+ import { parseLines } from "./parse";
31
+ import { loadMetaPolicy } from "./prompts";
32
+ import { EntityResolver, goldSeeds } from "./resolve";
33
+
34
+ /** Model-call seam for extraction (defaults to the local `entity` stage). Injected
35
+ * by the pipeline + tests so the deterministic scaffolding runs with no GPU. */
36
+ export type ExtractCall = (prompt: string, opts?: { maxTokens?: number }) => Promise<string>;
37
+
38
+ /** One extracted entity: the canonical wiki-title name + the raw model surface. */
39
+ export interface ExtractedEntity {
40
+ /** Canonical wiki-title name (the model's line, cleaned of markup/trailing
41
+ * punctuation) — the value written to chunk_entities.entity_name. */
42
+ name: string;
43
+ /** The raw surface the model emitted (chunk_entities.surface_form). */
44
+ surface: string;
45
+ /** Deterministic canonical stem (casefold/ws-collapse/strip articles+
46
+ * possessives) used for dedup and alias keying. */
47
+ stem: string;
48
+ }
49
+
50
+ const QUESTION =
51
+ "What things is this chunk about? List canonical wiki-title names, one per " +
52
+ "line; include specific things AND the broad domain. Output NONE if nothing " +
53
+ "nameable or substantive.";
54
+
55
+ /**
56
+ * Deterministic canonical stem of an entity surface: casefold, collapse
57
+ * whitespace/underscores, strip surrounding markdown/quotes, drop a trailing
58
+ * period, then strip ONE leading article (the/a/an) and ONE leading possessive
59
+ * determiner (my/your/his/her/its/our/their), plus a trailing possessive `'s`.
60
+ *
61
+ * This is the dedup key: a name's spacing variants, short forms, brand-only
62
+ * surfaces, and possessive surfaces all collapse toward one stem so variants of
63
+ * the same thing unify.
64
+ */
65
+ export function canonicalize(surface: string): string {
66
+ let t = surface
67
+ .toLowerCase()
68
+ .replace(/[*_`"'“”]+/g, " ") // strip md emphasis / quotes
69
+ .replace(/[_\s]+/g, " ")
70
+ .trim()
71
+ .replace(/[.,;:]+$/, "")
72
+ .trim();
73
+ // strip a leading list marker / numbering the model may emit ("- ", "1. ")
74
+ t = t.replace(/^(?:[-*•]\s+|\d+[.)]\s+)/, "").trim();
75
+ // strip ONE leading article, then ONE leading possessive determiner
76
+ t = t.replace(/^(?:the|a|an)\s+/, "").trim();
77
+ t = t.replace(/^(?:my|your|his|her|its|our|their)\s+/, "").trim();
78
+ // strip a trailing saxon-genitive
79
+ t = t.replace(/['’]s$/, "").trim();
80
+ return t;
81
+ }
82
+
83
+ /** Clean a raw model line into a stored canonical NAME (preserve casing/words,
84
+ * just strip surrounding markup, list markers, and trailing punctuation). */
85
+ function cleanName(line: string): string {
86
+ return line
87
+ .replace(/^(?:[-*•]\s+|\d+[.)]\s+)/, "")
88
+ .replace(/^[*_`"“]+|[*_`"”]+$/g, "")
89
+ .replace(/[.,;:]+$/, "")
90
+ .trim();
91
+ }
92
+
93
+ /**
94
+ * Build the entity-extraction prompt: inlined `Entities.md` policy + the chunk
95
+ * text + the fixed question. The policy block is passed in so callers can cache
96
+ * it across many chunks (loadMetaPolicy does disk IO).
97
+ */
98
+ export function buildEntityPrompt(text: string, policy: string): string {
99
+ return (
100
+ `${policy}\n\n` +
101
+ `--- Conversation chunk ---\n${text}\n--- end chunk ---\n\n` +
102
+ QUESTION
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Core extraction: render the prompt, call the base model (maxTokens 128), parse
108
+ * the newline list, clean + canonicalize each surface, and de-duplicate by stem.
109
+ * Pure w.r.t. the DB. Returns the extracted entities AND the raw newline-line
110
+ * count (pre-dedup) so instruments can report the canonicalizer's parse health.
111
+ */
112
+ export async function extractEntityNamesRaw(
113
+ text: string,
114
+ policy?: string,
115
+ opts?: { maxTokens?: number; call?: ExtractCall },
116
+ ): Promise<{ entities: ExtractedEntity[]; rawLines: number }> {
117
+ if (!text.trim()) return { entities: [], rawLines: 0 };
118
+ const pol = policy ?? loadMetaPolicy(["Entities"]);
119
+ const prompt = buildEntityPrompt(text, pol);
120
+ const call: ExtractCall = opts?.call ?? ((p, o) => callLocal("entity", { user: p }, o));
121
+ const out = await call(prompt, { maxTokens: opts?.maxTokens ?? 128 });
122
+ const lines = parseLines(out);
123
+ const seen = new Set<string>();
124
+ const entities: ExtractedEntity[] = [];
125
+ for (const raw of lines) {
126
+ const surface = cleanName(raw);
127
+ const stem = canonicalize(surface);
128
+ if (!stem || !surface) continue;
129
+ if (seen.has(stem)) continue; // collapse variants of one thing
130
+ seen.add(stem);
131
+ entities.push({ name: surface, surface: raw.trim(), stem });
132
+ }
133
+ return { entities, rawLines: lines.length };
134
+ }
135
+
136
+ /** Convenience wrapper: just the extracted entities (no raw-line count). */
137
+ export async function extractEntityNames(
138
+ text: string,
139
+ policy?: string,
140
+ opts?: { maxTokens?: number; call?: ExtractCall },
141
+ ): Promise<ExtractedEntity[]> {
142
+ return (await extractEntityNamesRaw(text, policy, opts)).entities;
143
+ }
144
+
145
+ /**
146
+ * Full stage: extract the entities a chunk is about and PERSIST them.
147
+ *
148
+ * Each extracted surface is RESOLVED to a canonical entity through the variant
149
+ * resolver (squeeze/alias/token-subset; see resolve.ts) so casing/spacing/brand
150
+ * variants collapse onto ONE entity instead of minting a fresh row each — the
151
+ * dedup fix. The resolver is seeded from the store's existing entities/aliases
152
+ * AND the curated gold (so notable brand-only nicknames fold), and accumulates
153
+ * minted canonicals across chunks when the caller threads one in via `opts`.
154
+ *
155
+ * Upserts the resolved canonical `entities` row (article_stem NULL — no article
156
+ * exists yet; ROUTE/CREATE promote it later) so the chunk_entities FK is
157
+ * satisfied, records the raw surface as an alias of the canonical, and writes one
158
+ * chunk_entities row per distinct resolved entity. Idempotent: re-running on the
159
+ * same chunk INSERT-OR-IGNOREs the same rows.
160
+ *
161
+ * Returns the extracted list (also useful to callers that route immediately).
162
+ */
163
+ export async function extractEntities(
164
+ store: MemoryStore,
165
+ chunkId: string,
166
+ opts?: { maxTokens?: number; policy?: string; resolver?: EntityResolver; call?: ExtractCall },
167
+ ): Promise<ExtractedEntity[]> {
168
+ const text = store.chunkText(chunkId);
169
+ const extracted = await extractEntityNames(text, opts?.policy, opts);
170
+
171
+ // Seed once from the store + gold unless the caller threads a shared resolver
172
+ // (which lets minted canonicals carry across chunks in a single run).
173
+ const resolver = opts?.resolver ?? EntityResolver.fromStore(store);
174
+ if (!opts?.resolver) for (const s of goldSeeds()) resolver.register(s.canonical, s.aliases);
175
+
176
+ const insEntity = store.db.query(
177
+ "INSERT OR IGNORE INTO entities (name, article_stem, kind, notable) VALUES (?, NULL, NULL, 0)",
178
+ );
179
+ const insAlias = store.db.query(
180
+ "INSERT OR IGNORE INTO entity_aliases (alias, entity_name) VALUES (?, ?)",
181
+ );
182
+ const insChunkEntity = store.db.query(
183
+ "INSERT OR IGNORE INTO chunk_entities (chunk_id, entity_name, surface_form) VALUES (?, ?, ?)",
184
+ );
185
+
186
+ // Resolve OUTSIDE the write tx (the resolver index is in-memory, no DB writes),
187
+ // collapsing within-chunk variants onto one canonical before persisting.
188
+ const resolved = new Map<string, string>(); // canonical → first raw surface seen
189
+ for (const e of extracted) {
190
+ const canonical = resolver.resolve(e.name).name;
191
+ if (!resolved.has(canonical)) resolved.set(canonical, e.surface);
192
+ }
193
+
194
+ const tx = store.db.transaction(() => {
195
+ for (const [canonical, surface] of resolved) {
196
+ insEntity.run(canonical);
197
+ insAlias.run(canonicalize(surface), canonical);
198
+ insChunkEntity.run(chunkId, canonical, surface);
199
+ }
200
+ });
201
+ tx();
202
+ return extracted;
203
+ }
@@ -0,0 +1,187 @@
1
+ // mlx-bun memory — the conservative write gate.
2
+ //
3
+ // The Dreaming write pipeline patches articles section-by-section. Before any
4
+ // synthesized edit is committed to the vault, it passes through this gate: a
5
+ // deterministic, GPU-free check that the edit PRESERVED the existing article
6
+ // rather than degrading it. A weak pass is a NO-OP — the caller discards the
7
+ // model's output and leaves the vault byte-for-byte unchanged.
8
+ //
9
+ // This is a faithful port of Lucien's editorial gate (scripts/wikify.ts:
10
+ // verifyEditorialResult / checkFootnoteIntegrity / extractConvHashes). It
11
+ // catches citation loss, structural loss, footnote-bijection breaks, and gross
12
+ // prose deletion. It does NOT catch nuance loss — that is the cloud judge's job.
13
+ // The gate is mechanism, not policy: it can only reject, never rewrite.
14
+
15
+ // ---- citation hashes --------------------------------------------------
16
+
17
+ // Illustrative placeholder hashes the synthesis prompt warns against; never
18
+ // real citations, so excluded from the preservation set.
19
+ const SPEC_HASHES = new Set(["00000000", "00000001"]);
20
+
21
+ /** All real `conv:HASH` citation hashes in `text`, lowercased, minus the
22
+ * documented spec placeholders. A `conv:` hash is exactly 8 lowercase
23
+ * hex-ish chars `[0-9a-z]` (matched case-insensitively, then lowered). */
24
+ export function extractConvHashes(text: string): Set<string> {
25
+ const out = new Set<string>();
26
+ for (const m of text.matchAll(/conv:([0-9a-z]{8})/gi)) {
27
+ const h = m[1]!.toLowerCase();
28
+ if (!SPEC_HASHES.has(h)) out.add(h);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ // ---- footnote integrity ----------------------------------------------
34
+
35
+ export interface CheckResult {
36
+ ok: boolean;
37
+ /** Human-readable violations; empty when `ok`. */
38
+ errors: string[];
39
+ }
40
+
41
+ /**
42
+ * Verify footnote markers and definitions form a clean bijection:
43
+ * - every `[^N]` body marker has a matching `[^N]:` definition and vice versa;
44
+ * - marker/definition numbers are contiguous from 1;
45
+ * - every definition line carries exactly one backticked `conv:HASH`.
46
+ *
47
+ * Only a line-START `[^N]:` is a definition. A body marker glued to a
48
+ * list-introducing colon (`...two types[^2]:`) is still a marker, so we strip
49
+ * definition lines before scanning for markers rather than excluding any
50
+ * `[^N]` followed by `:`.
51
+ */
52
+ export function checkFootnoteIntegrity(text: string): CheckResult {
53
+ const errors: string[] = [];
54
+
55
+ // Definition lines: `[^N]: ...` at line start.
56
+ const defNums = new Set<number>();
57
+ const defLineByNum = new Map<number, string>();
58
+ const defSeen = new Set<number>();
59
+ for (const m of text.matchAll(/^\[\^(\d+)\]:(.*)$/gm)) {
60
+ const n = parseInt(m[1]!, 10);
61
+ if (defSeen.has(n)) errors.push(`definition [^${n}] is duplicated`);
62
+ defSeen.add(n);
63
+ defNums.add(n);
64
+ defLineByNum.set(n, m[2]!);
65
+ }
66
+
67
+ // Body markers: every `[^N]` outside a definition line.
68
+ const bodyText = text
69
+ .split("\n")
70
+ .filter((l) => !/^\[\^\d+\]:/.test(l))
71
+ .join("\n");
72
+ const markerNums = new Set<number>();
73
+ for (const m of bodyText.matchAll(/\[\^(\d+)\]/g)) {
74
+ markerNums.add(parseInt(m[1]!, 10));
75
+ }
76
+
77
+ for (const n of markerNums) {
78
+ if (!defNums.has(n)) errors.push(`marker [^${n}] has no definition`);
79
+ }
80
+ for (const n of defNums) {
81
+ if (!markerNums.has(n)) errors.push(`definition [^${n}] has no marker`);
82
+ }
83
+
84
+ const all = [...new Set([...markerNums, ...defNums])].sort((a, b) => a - b);
85
+ for (let i = 0; i < all.length; i++) {
86
+ if (all[i] !== i + 1) {
87
+ errors.push(`footnote numbers not contiguous from 1 (saw ${all.join(",")})`);
88
+ break;
89
+ }
90
+ }
91
+
92
+ for (const [n, line] of defLineByNum) {
93
+ const backticked = line.match(/`conv:[0-9a-z]{8}`/gi) ?? [];
94
+ if (backticked.length !== 1) {
95
+ errors.push(`[^${n}] definition lacks a backticked \`conv:HASH\` (found ${backticked.length})`);
96
+ }
97
+ }
98
+
99
+ return { ok: errors.length === 0, errors };
100
+ }
101
+
102
+ // ---- the gate ---------------------------------------------------------
103
+
104
+ export interface GateOptions {
105
+ /** Edited word count must be >= floor * before's word count. Default 0.7. */
106
+ floor?: number;
107
+ /** Citation hashes the caller has DELIBERATELY pruned (a factual-error fix that
108
+ * removes a wrong value as if it had never been there). Their loss is permitted
109
+ * rather than vetoed as accidental citation loss. Empty by default. */
110
+ allowDroppedHashes?: Set<string>;
111
+ }
112
+
113
+ export interface GateVerdict {
114
+ /** True when the edit is safe to commit. */
115
+ ok: boolean;
116
+ /** Null when `ok`; otherwise the joined failing reasons, so the caller can
117
+ * log WHY the edit was discarded. */
118
+ reason: string | null;
119
+ }
120
+
121
+ function wordCount(s: string): number {
122
+ const t = s.trim();
123
+ return t ? t.split(/\s+/).length : 0;
124
+ }
125
+
126
+ /** True when `text` contains an H1 title line (`# Title`). */
127
+ function hasH1(text: string): boolean {
128
+ return text.split("\n").some((l) => /^#\s+\S/.test(l.trim()));
129
+ }
130
+
131
+ /** True when `text` contains a `## References` section heading. */
132
+ function hasReferences(text: string): boolean {
133
+ return /^##\s+References\s*$/m.test(text);
134
+ }
135
+
136
+ /**
137
+ * The conservative write gate. Given the (before, after) text of a section or
138
+ * article, decide whether the edit preserved it. On `ok: false` the caller
139
+ * MUST discard `after` and keep `before` — the vault stays unchanged.
140
+ *
141
+ * Enforces (deterministically, no GPU):
142
+ * 1. Citation preservation — every pre-existing `conv:HASH` survives.
143
+ * 2. Footnote bijection — every `[^N]` marker ⇔ one `[^N]:` definition.
144
+ * 3. Prose preservation — the edited word count stays at/above the floor.
145
+ * 4. Structural survival — an H1 and `## References` present before survive.
146
+ *
147
+ * Catches citation/structure/bulk-prose loss; does NOT catch nuance loss.
148
+ */
149
+ export function gateEdit(before: string, after: string, opts: GateOptions = {}): GateVerdict {
150
+ const floor = opts.floor ?? 0.7;
151
+ const reasons: string[] = [];
152
+
153
+ // 1. Citation preservation: every pre-existing hash must still be cited —
154
+ // UNLESS the caller deliberately pruned it (a factual-error fix).
155
+ const beforeHashes = extractConvHashes(before);
156
+ const afterHashes = extractConvHashes(after);
157
+ const allowed = opts.allowDroppedHashes ?? new Set<string>();
158
+ for (const h of beforeHashes) {
159
+ if (!afterHashes.has(h) && !allowed.has(h)) reasons.push(`dropped citation conv:${h}`);
160
+ }
161
+
162
+ // 2. Footnote bijection on the edited text.
163
+ const fn = checkFootnoteIntegrity(after);
164
+ if (!fn.ok) reasons.push(...fn.errors);
165
+
166
+ // 3. Prose preservation: word floor on the touched text.
167
+ const bw = wordCount(before);
168
+ const aw = wordCount(after);
169
+ if (bw > 0 && aw < floor * bw) {
170
+ reasons.push(`word count ${aw} below floor ${Math.ceil(floor * bw)} (before ${bw})`);
171
+ }
172
+
173
+ // 4. Structural survival: an H1 / ## References present before must survive.
174
+ if (hasH1(before) && !hasH1(after)) reasons.push("edit dropped the H1 title");
175
+ if (hasReferences(before) && !hasReferences(after)) {
176
+ reasons.push("edit dropped the ## References section");
177
+ }
178
+
179
+ // 5. See-also singularity: a fence-confused rebuild that buried one ## See also
180
+ // and appended another would grow the section unboundedly. Reject (NO-OP) any
181
+ // result carrying more than ONE ## See also heading, so the section can never
182
+ // grow regardless of how pathological the input was.
183
+ const seeAlsoCount = (after.match(/^##\s+See also\s*$/gim) ?? []).length;
184
+ if (seeAlsoCount > 1) reasons.push(`multiple ## See also sections (${seeAlsoCount})`);
185
+
186
+ return reasons.length ? { ok: false, reason: reasons.join("; ") } : { ok: true, reason: null };
187
+ }