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.
- package/README.md +112 -20
- 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/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/spec-decode-larger-targets.md +229 -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 +262 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +92 -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.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 +40 -9
- package/docs/reference/training.md +25 -11
- 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 +1029 -86
- 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 +141 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- 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/ffi.ts +3 -0
- package/src/mlx/ops.ts +46 -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 +62 -6
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3.ts +276 -0
- 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/quantize/config-writer.ts +1 -1
- package/src/registry.ts +215 -7
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +272 -80
- package/src/serve/generation-gateway.ts +86 -18
- package/src/server.ts +612 -49
- 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/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +70 -15
- 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 +289 -30
- package/src/train/prefix-shared.ts +33 -5
- package/src/train/segmented.ts +67 -11
- package/src/train/trainer.ts +92 -28
- package/src/web/skills/memory/SKILL.md +40 -17
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// mlx-bun memory — the read-side navigation index + primitives.
|
|
2
|
+
//
|
|
3
|
+
// The read path FINDS an article deterministically — by category membership, by
|
|
4
|
+
// name/alias, or by following the [[wikilink]] graph — then hands the agent off
|
|
5
|
+
// to the read tools (TOC → section). It NEVER ranks by a vector and NEVER filters
|
|
6
|
+
// on an infobox field: the infobox is CONTENT the model reads, not a query
|
|
7
|
+
// target. There is deliberately no `byInfoboxField`, no numeric facet, no
|
|
8
|
+
// `gte/lte` here. Every lookup below touches only indexed names/categories/links;
|
|
9
|
+
// the embedding tripwire (src/embed.ts) MUST stay at zero across this module.
|
|
10
|
+
//
|
|
11
|
+
// `buildMemoryIndex` mirrors the same facts the reindex derives into the DB
|
|
12
|
+
// (aliases, categories, series, kind/type, infobox, lead, link graph) into plain
|
|
13
|
+
// in-memory maps, so the primitives are pure functions over a snapshot with no
|
|
14
|
+
// DB round-trip. The build is mtime-incremental: each file is parsed at most once
|
|
15
|
+
// per (path, mtime), so a rebuild after touching one article re-parses only that
|
|
16
|
+
// file and is otherwise byte-identical to the prior build.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
deriveCategories,
|
|
23
|
+
infoboxAliases,
|
|
24
|
+
infoboxFieldValues,
|
|
25
|
+
parseInfobox,
|
|
26
|
+
parseLead,
|
|
27
|
+
parseSeriesBanner,
|
|
28
|
+
type EntityKind,
|
|
29
|
+
type Infobox,
|
|
30
|
+
} from "./article";
|
|
31
|
+
import { EntityResolver, nearNameMatch } from "./resolve";
|
|
32
|
+
import { articlesDir, extractWikilinkTargets, resolveWikilinkToStem, vaultRoot } from "./vault";
|
|
33
|
+
|
|
34
|
+
// ---- normalization (mirrors reindex.ts so resolveName agrees exactly) -
|
|
35
|
+
|
|
36
|
+
/** Casefold + collapse whitespace/underscores so title, stem, and alias forms of
|
|
37
|
+
* the same surface text key the alias index identically. */
|
|
38
|
+
function normalizeAlias(s: string): string {
|
|
39
|
+
return s.trim().toLowerCase().replace(/_/g, " ").replace(/\s+/g, " ").trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/;
|
|
43
|
+
const WIKILINK_RE = /\[\[([^\]]+)\]\]/;
|
|
44
|
+
|
|
45
|
+
/** The inner target of a `[[wikilink]]` value, before any `|alias`/`#anchor`. */
|
|
46
|
+
function wikilinkInner(value: string): string {
|
|
47
|
+
const m = WIKILINK_RE.exec(value);
|
|
48
|
+
if (!m) return "";
|
|
49
|
+
return m[1]!.split("|")[0]!.split("#")[0]!.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- per-article parse + the index ------------------------------------
|
|
53
|
+
|
|
54
|
+
/** One article's structured facts, parsed once and cached by (path, mtime). */
|
|
55
|
+
interface IndexedArticle {
|
|
56
|
+
stem: string;
|
|
57
|
+
infobox: Infobox | null;
|
|
58
|
+
lead: string | null;
|
|
59
|
+
categories: string[];
|
|
60
|
+
series: string | null;
|
|
61
|
+
kind: EntityKind;
|
|
62
|
+
type: string | null;
|
|
63
|
+
/** Normalized alias surfaces (stem, spaced stem, infobox aliases). */
|
|
64
|
+
aliases: string[];
|
|
65
|
+
/** Resolved outbound link targets (existing article stems only, deduped). */
|
|
66
|
+
outbound: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MemoryIndex {
|
|
70
|
+
root: string;
|
|
71
|
+
/** normalized alias → stem (first writer in sorted-stem order wins). */
|
|
72
|
+
aliasToStem: Map<string, string>;
|
|
73
|
+
/** category name → member stems (sorted). */
|
|
74
|
+
categoryToStems: Map<string, string[]>;
|
|
75
|
+
/** series name → member stems (sorted). */
|
|
76
|
+
seriesToStems: Map<string, string[]>;
|
|
77
|
+
/** entity kind → stems (sorted). */
|
|
78
|
+
kindToStems: Map<string, string[]>;
|
|
79
|
+
/** emergent `type:` label → stems (sorted). */
|
|
80
|
+
typeToStems: Map<string, string[]>;
|
|
81
|
+
/** stem → parsed infobox (null when the article has none). */
|
|
82
|
+
infoboxByStem: Map<string, Infobox | null>;
|
|
83
|
+
/** stem → lead abstract (null when absent). */
|
|
84
|
+
leadByStem: Map<string, string | null>;
|
|
85
|
+
/** stem → resolved outbound article stems (the link graph). */
|
|
86
|
+
outboundByStem: Map<string, string[]>;
|
|
87
|
+
/** Stems (re)parsed on the most recent build (mtime miss). */
|
|
88
|
+
parsed: string[];
|
|
89
|
+
/** Stems served from the parse cache on the most recent build (mtime hit). */
|
|
90
|
+
reused: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface CacheEntry {
|
|
94
|
+
mtimeMs: number;
|
|
95
|
+
article: IndexedArticle;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Module-level parse cache keyed by absolute file path. Memoizes per-file
|
|
99
|
+
* PARSING only (keyed by mtime); the aggregate maps are rebuilt every call so
|
|
100
|
+
* they always reflect the current file set. */
|
|
101
|
+
const ARTICLE_CACHE = new Map<string, CacheEntry>();
|
|
102
|
+
|
|
103
|
+
/** Drop the parse cache. A cold rebuild after this yields maps byte-identical to
|
|
104
|
+
* a warm one (the cache is a pure speedup, never a source of truth). */
|
|
105
|
+
export function resetMemoryIndexCache(): void {
|
|
106
|
+
ARTICLE_CACHE.clear();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Sorted article stems under `<root>/articles/` (`*.md`, no hidden files). */
|
|
110
|
+
function listArticleStems(root: string): string[] {
|
|
111
|
+
let names: string[];
|
|
112
|
+
try {
|
|
113
|
+
names = readdirSync(articlesDir(root));
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
return names
|
|
118
|
+
.filter((n) => n.endsWith(".md") && !n.startsWith("."))
|
|
119
|
+
.map((n) => n.slice(0, -3))
|
|
120
|
+
.sort((a, b) => a.localeCompare(b));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Parse one article's markdown into its indexed facts (no I/O). */
|
|
124
|
+
function parseArticle(stem: string, md: string, stems: Set<string>): IndexedArticle {
|
|
125
|
+
const box = parseInfobox(md);
|
|
126
|
+
|
|
127
|
+
const aliasSet = new Set<string>();
|
|
128
|
+
for (const a of [stem, stem.replace(/_/g, " "), ...(box ? infoboxAliases(box) : [])]) {
|
|
129
|
+
const norm = normalizeAlias(a);
|
|
130
|
+
if (norm) aliasSet.add(norm);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Outbound graph edges: prose [[links]] + infobox entity-link values + series
|
|
134
|
+
// banner, each resolved to an EXISTING article stem (red links are dropped,
|
|
135
|
+
// matching getArticleLinks). Self-edges excluded.
|
|
136
|
+
const outbound = new Set<string>();
|
|
137
|
+
const addEdge = (raw: string): void => {
|
|
138
|
+
const r = resolveWikilinkToStem(raw, stems);
|
|
139
|
+
if (r && r !== stem) outbound.add(r);
|
|
140
|
+
};
|
|
141
|
+
for (const t of extractWikilinkTargets(md)) addEdge(t);
|
|
142
|
+
if (box) {
|
|
143
|
+
for (const field of box.fields) {
|
|
144
|
+
for (const value of infoboxFieldValues(field)) {
|
|
145
|
+
if (WIKILINK_RE.test(value)) addEdge(wikilinkInner(value));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const series = parseSeriesBanner(md);
|
|
150
|
+
if (series) addEdge(series);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
stem,
|
|
154
|
+
infobox: box,
|
|
155
|
+
lead: parseLead(md),
|
|
156
|
+
categories: deriveCategories(md, stem).categories,
|
|
157
|
+
series,
|
|
158
|
+
kind: box ? box.entityKind : "thing",
|
|
159
|
+
type: box ? box.type : null,
|
|
160
|
+
aliases: [...aliasSet].sort((a, b) => a.localeCompare(b)),
|
|
161
|
+
outbound: [...outbound].sort((a, b) => a.localeCompare(b)),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the read-side navigation index for a vault. mtime-incremental: each
|
|
167
|
+
* article is parsed at most once per (path, mtime), so a rebuild after touching
|
|
168
|
+
* one file re-parses ONLY that file. Deterministic: stems are visited in sorted
|
|
169
|
+
* order and every map array is sorted, so two builds of the same vault produce
|
|
170
|
+
* byte-identical maps. Makes ZERO embedding calls.
|
|
171
|
+
*/
|
|
172
|
+
export function buildMemoryIndex(root: string = vaultRoot()): MemoryIndex {
|
|
173
|
+
const stems = listArticleStems(root);
|
|
174
|
+
const stemSet = new Set(stems);
|
|
175
|
+
const dir = articlesDir(root);
|
|
176
|
+
|
|
177
|
+
const parsed: string[] = [];
|
|
178
|
+
const reused: string[] = [];
|
|
179
|
+
const articles: IndexedArticle[] = [];
|
|
180
|
+
|
|
181
|
+
for (const stem of stems) {
|
|
182
|
+
const path = join(dir, `${stem}.md`);
|
|
183
|
+
let mtimeMs: number;
|
|
184
|
+
try {
|
|
185
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
186
|
+
} catch {
|
|
187
|
+
continue; // raced/deleted between listing and stat
|
|
188
|
+
}
|
|
189
|
+
const cached = ARTICLE_CACHE.get(path);
|
|
190
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
191
|
+
reused.push(stem);
|
|
192
|
+
articles.push(cached.article);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
let md: string;
|
|
196
|
+
try {
|
|
197
|
+
md = readFileSync(path, "utf8");
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const article = parseArticle(stem, md, stemSet);
|
|
202
|
+
ARTICLE_CACHE.set(path, { mtimeMs, article });
|
|
203
|
+
parsed.push(stem);
|
|
204
|
+
articles.push(article);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const index: MemoryIndex = {
|
|
208
|
+
root,
|
|
209
|
+
aliasToStem: new Map(),
|
|
210
|
+
categoryToStems: new Map(),
|
|
211
|
+
seriesToStems: new Map(),
|
|
212
|
+
kindToStems: new Map(),
|
|
213
|
+
typeToStems: new Map(),
|
|
214
|
+
infoboxByStem: new Map(),
|
|
215
|
+
leadByStem: new Map(),
|
|
216
|
+
outboundByStem: new Map(),
|
|
217
|
+
parsed,
|
|
218
|
+
reused,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const pushInto = (map: Map<string, string[]>, key: string, stem: string): void => {
|
|
222
|
+
const list = map.get(key);
|
|
223
|
+
if (list) list.push(stem);
|
|
224
|
+
else map.set(key, [stem]);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
for (const a of articles) {
|
|
228
|
+
for (const alias of a.aliases) {
|
|
229
|
+
if (!index.aliasToStem.has(alias)) index.aliasToStem.set(alias, a.stem); // first (sorted) writer wins
|
|
230
|
+
}
|
|
231
|
+
for (const cat of a.categories) pushInto(index.categoryToStems, cat, a.stem);
|
|
232
|
+
if (a.series) pushInto(index.seriesToStems, a.series, a.stem);
|
|
233
|
+
pushInto(index.kindToStems, a.kind, a.stem);
|
|
234
|
+
if (a.type) pushInto(index.typeToStems, a.type, a.stem);
|
|
235
|
+
index.infoboxByStem.set(a.stem, a.infobox);
|
|
236
|
+
index.leadByStem.set(a.stem, a.lead);
|
|
237
|
+
index.outboundByStem.set(a.stem, a.outbound);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return index;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---- navigation primitives (find + resolve + links) -------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Article stems that DECLARED `[[Category:<category>]]` — the only "articles
|
|
247
|
+
* about X" mechanism (zero substring false positives). Returns a sorted copy.
|
|
248
|
+
*/
|
|
249
|
+
export function articlesInCategory(index: MemoryIndex, category: string): string[] {
|
|
250
|
+
return [...(index.categoryToStems.get(category) ?? [])];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Lazily-built near-name resolver per index (token-subset fuzzy over the alias
|
|
254
|
+
* map). Keyed by index identity; a fresh `buildMemoryIndex` yields a new object,
|
|
255
|
+
* so stale resolvers fall out of the WeakMap with their index. */
|
|
256
|
+
const NEAR_RESOLVER = new WeakMap<MemoryIndex, EntityResolver>();
|
|
257
|
+
|
|
258
|
+
function nearResolver(index: MemoryIndex): EntityResolver {
|
|
259
|
+
let r = NEAR_RESOLVER.get(index);
|
|
260
|
+
if (!r) {
|
|
261
|
+
r = EntityResolver.fromAliasMap(index.aliasToStem);
|
|
262
|
+
NEAR_RESOLVER.set(index, r);
|
|
263
|
+
}
|
|
264
|
+
return r;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolve an entity name or alias to its article stem via the alias index.
|
|
269
|
+
* Normalizes the query the same way aliases are stored (casefold, ws-collapse)
|
|
270
|
+
* and also retries with a leading article word ("the"/"a"/"an") stripped, so
|
|
271
|
+
* a "<the brand model>" surface and a "<model>" short form both reach the same stem.
|
|
272
|
+
*
|
|
273
|
+
* On an exact-alias miss, falls back to a conservative NEAR-NAME match
|
|
274
|
+
* ({@link nearNameMatch}) so a discussed sub-concept that is plainly the same
|
|
275
|
+
* subject as an existing article — the article title minus a generic suffix
|
|
276
|
+
* ("<subject>" for an "<subject> Theory" article) — still resolves to it, while a
|
|
277
|
+
* distinct subject or a purely-generic query does not. No model, no embedding.
|
|
278
|
+
*/
|
|
279
|
+
export function resolveName(index: MemoryIndex, nameOrAlias: string): string | null {
|
|
280
|
+
const q = normalizeAlias(nameOrAlias);
|
|
281
|
+
for (const c of [q, q.replace(LEADING_ARTICLE_RE, "")]) {
|
|
282
|
+
if (!c) continue;
|
|
283
|
+
const stem = index.aliasToStem.get(c);
|
|
284
|
+
if (stem) return stem;
|
|
285
|
+
}
|
|
286
|
+
return nearNameMatch(nearResolver(index), q);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface Neighbors {
|
|
290
|
+
outbound: string[];
|
|
291
|
+
inbound: string[];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The article's resolved [[wikilink]] neighbors: `outbound` it points at,
|
|
296
|
+
* `inbound` that point at it — including infobox entity-link edges (e.g. an
|
|
297
|
+
* entity's `<entity_key>: [[Linked Entity]]`), which is the navigation hop the read
|
|
298
|
+
* path rides. Both lists are sorted; computed entirely from the in-memory graph.
|
|
299
|
+
*/
|
|
300
|
+
export function neighbors(index: MemoryIndex, stem: string): Neighbors {
|
|
301
|
+
const outbound = [...(index.outboundByStem.get(stem) ?? [])];
|
|
302
|
+
const inbound: string[] = [];
|
|
303
|
+
for (const [from, targets] of index.outboundByStem) {
|
|
304
|
+
if (from !== stem && targets.includes(stem)) inbound.push(from);
|
|
305
|
+
}
|
|
306
|
+
inbound.sort((a, b) => a.localeCompare(b));
|
|
307
|
+
return { outbound, inbound };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- deterministic snapshot (for tests / cache-equivalence checks) ----
|
|
311
|
+
|
|
312
|
+
/** A stable JSON serialization of the index maps (sorted keys), so a warm and a
|
|
313
|
+
* cold rebuild can be asserted byte-identical. Excludes the build accounting
|
|
314
|
+
* (`parsed`/`reused`), which is per-build by design. */
|
|
315
|
+
export function serializeMemoryIndex(index: MemoryIndex): string {
|
|
316
|
+
const dumpStringMap = (m: Map<string, string>): [string, string][] =>
|
|
317
|
+
[...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
318
|
+
const dumpListMap = (m: Map<string, string[]>): [string, string[]][] =>
|
|
319
|
+
[...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
320
|
+
const dumpInfobox = (m: Map<string, Infobox | null>): [string, Infobox | null][] =>
|
|
321
|
+
[...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
322
|
+
const dumpLeadMap = (m: Map<string, string | null>): [string, string | null][] =>
|
|
323
|
+
[...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
324
|
+
return JSON.stringify({
|
|
325
|
+
aliasToStem: dumpStringMap(index.aliasToStem),
|
|
326
|
+
categoryToStems: dumpListMap(index.categoryToStems),
|
|
327
|
+
seriesToStems: dumpListMap(index.seriesToStems),
|
|
328
|
+
kindToStems: dumpListMap(index.kindToStems),
|
|
329
|
+
typeToStems: dumpListMap(index.typeToStems),
|
|
330
|
+
infoboxByStem: dumpInfobox(index.infoboxByStem),
|
|
331
|
+
leadByStem: dumpLeadMap(index.leadByStem),
|
|
332
|
+
outboundByStem: dumpListMap(index.outboundByStem),
|
|
333
|
+
});
|
|
334
|
+
}
|