@hviana/sema 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/AGENTS.md +470 -0
  2. package/AUTHORS.md +12 -0
  3. package/COMMERCIAL-LICENSE.md +19 -0
  4. package/CONTRIBUTING.md +15 -0
  5. package/HOW_IT_WORKS.md +4210 -0
  6. package/LICENSE.md +117 -0
  7. package/README.md +290 -0
  8. package/TRADEMARKS.md +19 -0
  9. package/example/demo.ts +46 -0
  10. package/example/train_base.ts +2675 -0
  11. package/index.html +893 -0
  12. package/package.json +25 -0
  13. package/src/alphabet.ts +34 -0
  14. package/src/alu/README.md +332 -0
  15. package/src/alu/src/alu.ts +541 -0
  16. package/src/alu/src/expr.ts +339 -0
  17. package/src/alu/src/index.ts +115 -0
  18. package/src/alu/src/kernel-arith.ts +377 -0
  19. package/src/alu/src/kernel-bits.ts +199 -0
  20. package/src/alu/src/kernel-logic.ts +102 -0
  21. package/src/alu/src/kernel-nd.ts +235 -0
  22. package/src/alu/src/kernel-numeric.ts +466 -0
  23. package/src/alu/src/operation.ts +344 -0
  24. package/src/alu/src/parser.ts +574 -0
  25. package/src/alu/src/resonance.ts +161 -0
  26. package/src/alu/src/text.ts +83 -0
  27. package/src/alu/src/value.ts +327 -0
  28. package/src/alu/test/alu.test.ts +1004 -0
  29. package/src/bytes.ts +62 -0
  30. package/src/config.ts +227 -0
  31. package/src/derive/README.md +295 -0
  32. package/src/derive/src/deduction.ts +263 -0
  33. package/src/derive/src/index.ts +24 -0
  34. package/src/derive/src/priority-queue.ts +70 -0
  35. package/src/derive/src/rewrite.ts +127 -0
  36. package/src/derive/src/trie.ts +242 -0
  37. package/src/derive/test/derive.test.ts +137 -0
  38. package/src/extension.ts +42 -0
  39. package/src/geometry.ts +511 -0
  40. package/src/index.ts +38 -0
  41. package/src/ingest-cache.ts +224 -0
  42. package/src/mind/articulation.ts +137 -0
  43. package/src/mind/attention.ts +1061 -0
  44. package/src/mind/canonical.ts +107 -0
  45. package/src/mind/graph-search.ts +1100 -0
  46. package/src/mind/index.ts +18 -0
  47. package/src/mind/junction.ts +347 -0
  48. package/src/mind/learning.ts +246 -0
  49. package/src/mind/match.ts +507 -0
  50. package/src/mind/mechanisms/alu.ts +33 -0
  51. package/src/mind/mechanisms/cast.ts +568 -0
  52. package/src/mind/mechanisms/confluence.ts +268 -0
  53. package/src/mind/mechanisms/cover.ts +248 -0
  54. package/src/mind/mechanisms/extraction.ts +422 -0
  55. package/src/mind/mechanisms/recall.ts +241 -0
  56. package/src/mind/mind.ts +483 -0
  57. package/src/mind/pipeline-mechanism.ts +326 -0
  58. package/src/mind/pipeline.ts +300 -0
  59. package/src/mind/primitives.ts +201 -0
  60. package/src/mind/rationale.ts +275 -0
  61. package/src/mind/reasoning.ts +198 -0
  62. package/src/mind/recognition.ts +234 -0
  63. package/src/mind/resonance.ts +0 -0
  64. package/src/mind/trace.ts +108 -0
  65. package/src/mind/traverse.ts +556 -0
  66. package/src/mind/types.ts +281 -0
  67. package/src/rabitq-hnsw/README.md +303 -0
  68. package/src/rabitq-hnsw/src/database.ts +492 -0
  69. package/src/rabitq-hnsw/src/heap.ts +90 -0
  70. package/src/rabitq-hnsw/src/hnsw.ts +514 -0
  71. package/src/rabitq-hnsw/src/index.ts +15 -0
  72. package/src/rabitq-hnsw/src/prng.ts +39 -0
  73. package/src/rabitq-hnsw/src/rabitq.ts +308 -0
  74. package/src/rabitq-hnsw/src/store.ts +994 -0
  75. package/src/rabitq-hnsw/test/hnsw.test.ts +1213 -0
  76. package/src/store-sqlite.ts +928 -0
  77. package/src/store.ts +2148 -0
  78. package/src/vec.ts +124 -0
  79. package/test/00-extract.test.mjs +151 -0
  80. package/test/01-floor.test.mjs +20 -0
  81. package/test/02-roundtrip.test.mjs +83 -0
  82. package/test/03-recall.test.mjs +98 -0
  83. package/test/04-think.test.mjs +627 -0
  84. package/test/05-concepts.test.mjs +84 -0
  85. package/test/08-storage.test.mjs +948 -0
  86. package/test/09-edges.test.mjs +65 -0
  87. package/test/11-universality.test.mjs +180 -0
  88. package/test/13-conversation.test.mjs +228 -0
  89. package/test/14-scaling.test.mjs +503 -0
  90. package/test/15-decomposition-gap.test.mjs +0 -0
  91. package/test/16-bridge.test.mjs +250 -0
  92. package/test/17-intelligence.test.mjs +240 -0
  93. package/test/18-alu.test.mjs +209 -0
  94. package/test/19-nd.test.mjs +254 -0
  95. package/test/20-stability.test.mjs +489 -0
  96. package/test/21-partial.test.mjs +158 -0
  97. package/test/22-multihop.test.mjs +130 -0
  98. package/test/23-rationale.test.mjs +316 -0
  99. package/test/24-generalization.test.mjs +416 -0
  100. package/test/25-embedding.test.mjs +75 -0
  101. package/test/26-cooperative.test.mjs +89 -0
  102. package/test/27-saturation-drop.test.mjs +637 -0
  103. package/test/28-unknowable.test.mjs +88 -0
  104. package/test/29-counterfactual.test.mjs +476 -0
  105. package/test/30-conflict-resolution.test.mjs +166 -0
  106. package/test/31-audit.test.mjs +288 -0
  107. package/test/32-confluence.test.mjs +173 -0
  108. package/test/33-multi-candidate.test.mjs +222 -0
  109. package/test/34-cross-region.test.mjs +252 -0
  110. package/tsconfig.json +16 -0
package/src/store.ts ADDED
@@ -0,0 +1,2148 @@
1
+ // store.ts — the memory as a content-addressed node graph (a Merkle DAG).
2
+ //
3
+ // There is no "whole", no "leaf vs interior" privilege. There is one kind of
4
+ // thing — a NODE — named by its content. A node is either a leaf (a single
5
+ // byte, with an implicit negative id) or a branch (an ordered list of child
6
+ // node ids). Identical nodes are stored once (hash-consing); a node that was the
7
+ // root of one deposit becomes, by reference, an interior child of a larger
8
+ // deposit that contains it. Storage is therefore O(distinct subtrees), and every
9
+ // span the encoder ever produced is individually addressable.
10
+ //
11
+ // Node ids are signed integers. Single-byte leaves occupy the negative range
12
+ // −256…−1 (derived from the byte value, never stored in a DB row). Branches
13
+ // are dense, monotonically-increasing positive integers (0,1,2,…), never
14
+ // deleted, so the count of minted branch ids is the next id. Integer ids are the
15
+ // most compact key possible: SQLite stores them inline in the rowid B-tree (no
16
+ // secondary index), child lists pack to 4-byte signed integers, and the vector
17
+ // index keys on them as a 4-byte int32.
18
+ //
19
+ // On top of the DAG sit two relations:
20
+ // • the gist index — each node's fold vector, for SOFT (resonant) lookup;
21
+ // • continuation edges — learned "what follows what", the associations recall
22
+ // traverses. Both are keyed by node id, so both deduplicate for free.
23
+ //
24
+ // AbstractStore is the template-method base class that contains ALL domain
25
+ // logic — caching, dedup/merge decisions, geometric
26
+ // halo scheduling, buffer management — and calls down to a handful of
27
+ // protected abstract methods that a concrete persistence adapter implements.
28
+ // SQliteStore (in store-sqlite.ts) extends it and provides only the bare
29
+ // essentials required for SQLite + VectorDatabase communication.
30
+
31
+ import { addInto, copy, dot, normalize, Vec } from "./vec.js";
32
+ import { DEFAULT_CONFIG, type StoreConfig } from "./config.js";
33
+ import { identityBar } from "./geometry.js";
34
+
35
+ /** A node id: a dense, non-negative integer assigned in creation order. */
36
+ export type NodeId = number;
37
+
38
+ /** A node in the graph. Exactly one of `leaf` / `kids` is set. */
39
+ export interface NodeRec {
40
+ id: NodeId;
41
+ leaf: Uint8Array | null;
42
+ kids: NodeId[] | null;
43
+ }
44
+
45
+ /** Entry cap for the two ANN read caches ({@link AbstractStore._resonateCache}
46
+ * / `_resonateHaloCache`). They are only dropped on index writes, so a long
47
+ * query-only session otherwise grows them without bound — a slow memory/GC
48
+ * degradation that takes hours to manifest. At the cap the cache is cleared
49
+ * wholesale (repeat-query hits re-warm it; a miss only re-runs the ANN). */
50
+ const RESONATE_CACHE_MAX = 4096;
51
+
52
+ /** Longest dedup-cache KEY worth retaining, in string length. Branch keys
53
+ * are `kids.join(",")`; structural branches stay tiny (≤ maxGroup kids), but
54
+ * the whole-stream flat branches deposit interns (one leaf id PER INPUT
55
+ * BYTE) produce keys of ~4–5 chars per content byte — one per deposit. The
56
+ * dedup caches are bounded by ENTRY count, so retaining those keys grows
57
+ * memory with total ingested content. Past this bound the cache is
58
+ * bypassed: the durable content-addressed probe still answers (dedup never
59
+ * depends on the cache), at one indexed SQLite lookup per re-encounter —
60
+ * and true input-level repeats are already absorbed by the ingest cache. */
61
+ const DEDUP_KEY_MAX = 4096;
62
+
63
+ /** Safety depth bound for one {@link Store.chainRun} read. Semantics never
64
+ * depend on it (a truncated run ends on a still-transparent node and the
65
+ * climber continues from there); it only bounds a single read's size. */
66
+ const CHAIN_DEPTH_CAP = 65_536;
67
+
68
+ /** A soft-resonance hit. */
69
+ export interface Hit {
70
+ id: NodeId;
71
+ /** Estimated cosine (1 − RaBitQ estimated distance) — an APPROXIMATION,
72
+ * never an exact rerank. Identity decisions must use content-addressed
73
+ * resolve(), never `score >= 1`. */
74
+ score: number;
75
+ /** This node's halo, when a producer chooses to attach it. NOT populated
76
+ * by {@link Store.resonate}/{@link Store.resonateHalo}: no consumer read
77
+ * it there, and eagerly fetching k halo rows per query was pure waste.
78
+ * Call {@link Store.halo} on the hits that actually need it. */
79
+ halo?: Vec;
80
+ }
81
+
82
+ /** The content key of a Float32Array — one latin1 char per byte, an exact
83
+ * encoding of the 32-bit floats. Content-addressed, not object-identity,
84
+ * so a fresh Float32Array carrying the same values still hits. Vectors are
85
+ * D·4 bytes (max ~4096), well under the argument limit; the key is far
86
+ * cheaper than the ANN query it deduplicates. */
87
+ function vecKey(v: Vec): string {
88
+ const bytes = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
89
+ // Chunked fromCharCode: one spread of D·4 args (16K at D=4096) flirts with
90
+ // engine argument limits and is slower than a few bounded calls.
91
+ let s = "";
92
+ for (let i = 0; i < bytes.length; i += 8192) {
93
+ s += String.fromCharCode(...bytes.subarray(i, i + 8192));
94
+ }
95
+ return s;
96
+ }
97
+
98
+ /** Eviction strategy for a {@link BoundedMap}.
99
+ *
100
+ * - `"lru"`: evict the least-recently-used entry. Best when every entry
101
+ * saves roughly the same work (dedup, leaf vectors, node records).
102
+ * - `"smallest"`: among the oldest few LRU candidates, evict the smallest.
103
+ * This protects expensive-to-reconstruct entries (large branches) at the
104
+ * expense of cheap ones (small leaves) — useful for reconstruction caches
105
+ * where rebuild cost varies by orders of magnitude. */
106
+ export type Evict = "lru" | "smallest";
107
+
108
+ /** Bounded map with LRU eviction and byte accounting. On get the entry
109
+ * moves to the most-recent end; on set the least-recently-used entries are
110
+ * evicted until total bytes ≤ `maxBytes`. The optional `sizeOf` callback
111
+ * measures each value in bytes (defaults to 1, so `maxBytes` = max entries
112
+ * for uniform caches). A miss only costs a little extra work later, never
113
+ * correctness. */
114
+ export class BoundedMap<K, V> {
115
+ private m = new Map<K, V>();
116
+ private _bytes = 0;
117
+ // Persistent eviction cursor over the Map's insertion order. A fresh
118
+ // `keys()` iterator per eviction re-skips the growing prefix of holes that
119
+ // deletions leave in V8's ordered backing store (compacted only on rehash),
120
+ // making each eviction O(size) once the map first fills — the training-rate
121
+ // cliff at scale. A persistent iterator passes each hole exactly once
122
+ // (V8 map iterators stay valid under mutation): amortized O(1).
123
+ private _cursor: IterableIterator<K> | null = null;
124
+ // "smallest" mode: oldest-entry candidates carried between evictions, fed
125
+ // from the cursor, so the LRU window never rescans from the front.
126
+ private _candidates: K[] = [];
127
+ constructor(
128
+ readonly maxBytes: number,
129
+ private readonly sizeOf: (v: V) => number = () => 1,
130
+ private readonly evict: Evict = "lru",
131
+ ) {}
132
+ /** Next key in insertion (≈ LRU) order, resuming where the last call left
133
+ * off; wraps to the front when exhausted. Undefined only when empty. */
134
+ private nextOldest(): K | undefined {
135
+ for (let wrapped = false;;) {
136
+ if (this._cursor === null) this._cursor = this.m.keys();
137
+ const n = this._cursor.next();
138
+ if (!n.done) return n.value;
139
+ this._cursor = null;
140
+ if (this.m.size === 0 || wrapped) return undefined;
141
+ wrapped = true;
142
+ }
143
+ }
144
+ get(k: K): V | undefined {
145
+ const v = this.m.get(k);
146
+ if (v !== undefined) {
147
+ this.m.delete(k);
148
+ this.m.set(k, v);
149
+ }
150
+ return v;
151
+ }
152
+ /** Membership without touching LRU order — a pure peek, for callers that only
153
+ * need "is this key present?" and must not promote it to most-recent. */
154
+ has(k: K): boolean {
155
+ return this.m.has(k);
156
+ }
157
+ set(k: K, v: V): void {
158
+ const old = this.m.get(k);
159
+ if (old !== undefined) {
160
+ this._bytes -= this.sizeOf(old);
161
+ this.m.delete(k);
162
+ }
163
+ this.m.set(k, v);
164
+ this._bytes += this.sizeOf(v);
165
+ while (this._bytes > this.maxBytes && this.m.size > 0) {
166
+ if (this.evict === "smallest") {
167
+ // Among the oldest LRU candidates, evict the cheapest to rebuild.
168
+ // Window grows logarithmically with cache size — wide enough to
169
+ // find a cheap victim without scanning the whole map. Candidates
170
+ // are pulled from the persistent cursor and carried between
171
+ // evictions, so the window never rescans the map from the front.
172
+ const WINDOW = Math.ceil(Math.log2(this.m.size + 1));
173
+ this._candidates = this._candidates.filter((k) => this.m.has(k));
174
+ while (this._candidates.length < WINDOW) {
175
+ const k = this.nextOldest();
176
+ if (k === undefined) break;
177
+ if (!this._candidates.includes(k)) this._candidates.push(k);
178
+ }
179
+ let bestI = -1;
180
+ let bestSz = Infinity;
181
+ for (let i = 0; i < this._candidates.length; i++) {
182
+ const sz = this.sizeOf(this.m.get(this._candidates[i])!);
183
+ if (sz < bestSz) {
184
+ bestSz = sz;
185
+ bestI = i;
186
+ }
187
+ }
188
+ if (bestI < 0) break;
189
+ const bestK = this._candidates[bestI];
190
+ this._candidates.splice(bestI, 1);
191
+ this._bytes -= bestSz;
192
+ this.m.delete(bestK);
193
+ } else {
194
+ const lru = this.nextOldest();
195
+ if (lru === undefined) break;
196
+ const lruv = this.m.get(lru);
197
+ if (lruv === undefined) continue;
198
+ this._bytes -= this.sizeOf(lruv);
199
+ this.m.delete(lru);
200
+ }
201
+ }
202
+ }
203
+ get size(): number {
204
+ return this.m.size;
205
+ }
206
+ get bytes(): number {
207
+ return this._bytes;
208
+ }
209
+ /** Remove one entry (point invalidation), with byte accounting. */
210
+ delete(k: K): void {
211
+ const v = this.m.get(k);
212
+ if (v === undefined) return;
213
+ this._bytes -= this.sizeOf(v);
214
+ this.m.delete(k);
215
+ }
216
+ /** Drop every entry (bulk invalidation) — O(1) amortised via fresh maps. */
217
+ clear(): void {
218
+ if (this.m.size === 0) return;
219
+ this.m = new Map();
220
+ this._bytes = 0;
221
+ this._cursor = null;
222
+ this._candidates = [];
223
+ }
224
+ }
225
+
226
+ export interface Store {
227
+ readonly D: number;
228
+
229
+ // ── the DAG (hash-consed) ──────────────────────────────────────────────
230
+ /** Insert a leaf, returning its content id. Idempotent. */
231
+ putLeaf(bytes: Uint8Array, gist: Vec): Promise<NodeId>;
232
+ /** Insert a branch over child ids, returning its content id. Idempotent. */
233
+ putBranch(kids: NodeId[], gist: Vec): Promise<NodeId>;
234
+ /** Whether a node with this id already exists. O(1). */
235
+ has(id: NodeId): boolean;
236
+ /** The node record, or null. */
237
+ get(id: NodeId): NodeRec | null;
238
+ /** The bytes a node spans, reconstructed by traversal (cached). */
239
+ bytes(id: NodeId): Uint8Array;
240
+ /** First `maxLen` bytes of a node, stopping early — far cheaper than
241
+ * `bytes()` for large branches when only a prefix is needed. */
242
+ bytesPrefix(id: NodeId, maxLen: number): Uint8Array;
243
+ /** The CONTENT LENGTH of a node in bytes — under the linear river fold
244
+ * this IS the node's gist magnitude, squared: seat permutations
245
+ * decorrelate siblings, so ‖gist‖ = √(content bytes) up to decorrelation
246
+ * noise. The magnitude is therefore never persisted beside the vector
247
+ * (that would duplicate what the content already determines; the ANN
248
+ * index keeps unit directions), it is READ here in O(1) amortized — the
249
+ * store-side half of angle+magnitude semantics. Mechanisms use it to
250
+ * convert a scale-free cosine into the absolute quantities the linear
251
+ * geometry carries: shared bytes ≈ cos·√(lenA·lenB), fraction of A
252
+ * explained ≈ cos·√(lenB/lenA). `cap`, when given, SATURATES the walk:
253
+ * the return value is exact below the cap and merely ≥ cap otherwise —
254
+ * for decisions that stop caring past a bound (a fraction that caps at 1,
255
+ * a weight that is sub-noise beyond a ratio), so one huge conversation
256
+ * root never costs a full subtree walk. Only complete walks are
257
+ * memoized. */
258
+ contentLen(id: NodeId, cap?: number): number;
259
+ findLeaf(bytes: Uint8Array): NodeId | null;
260
+ findBranch(kids: NodeId[]): NodeId | null;
261
+ /** The branch nodes that list `id` among their children — the reverse of
262
+ * `get(id).kids`. Lets the structural DAG be climbed upward, from a
263
+ * recognised fragment to the larger learned forms that contain it. */
264
+ parents(id: NodeId): NodeId[];
265
+ /** The first `limit` structural parents of `id` — the CAPPED read for the
266
+ * climb. A heavily shared subtree's parent set grows with the corpus;
267
+ * reading `bound + 1` decides "hub or not" exactly (a result of length
268
+ * bound + 1 means MORE than bound) while the read stays bounded by the
269
+ * bound, never by the fan-in. */
270
+ parentsFirst(id: NodeId, limit: number): NodeId[];
271
+ /** Whether `id` has ANY structural parent — one LIMITed point probe. */
272
+ hasParents(id: NodeId): boolean;
273
+ /** The TRANSPARENT CHAIN from `id` upward: `run[0] === id`, and while the
274
+ * current node is transparent — no continuation edge in or out and exactly
275
+ * ONE structural parent — the run steps to that parent. The last element
276
+ * is the first NON-transparent ancestor (or a parentless top). Transparent
277
+ * nodes are invisible to the edge climb (they contribute no roots, no
278
+ * contexts and no lateral branching), so a climber may hop a whole run in
279
+ * one read where a node-at-a-time ascent pays three probes per node — the
280
+ * dominant cost of climbing deep single-structure scaffolding. Results
281
+ * are cached for the store's LIFETIME (reads are pure between writes) and
282
+ * the cache is dropped whenever a write could break transparency: a node
283
+ * gaining a structural parent (fresh mint) or a continuation edge (link).
284
+ * The run may be truncated at an internal safety depth; a truncated run's
285
+ * last element is then still transparent, and a climber that treats the
286
+ * terminal generically simply continues from it — semantics never depend
287
+ * on completeness. */
288
+ chainRun(id: NodeId): readonly NodeId[];
289
+ /** Record a CONTAINMENT edge: `parent`'s byte content contains `child`'s,
290
+ * even though `child` is not among `parent`'s kids (a sub-span flat branch
291
+ * inside a leaf-parent chunk). Kept apart from {@link parents} — the
292
+ * structural climb is untouched; {@link containers} serves the climbing
293
+ * surface of orphan flat branches durably, where a session-local side
294
+ * table would be lost on restart or an ingest-cache replay. Idempotent. */
295
+ addContainer(child: NodeId, parent: NodeId): void;
296
+ /** The chunks recorded by {@link addContainer} as containing `child`. */
297
+ containers(child: NodeId): NodeId[];
298
+ /** Whether `child` has ANY containment parent — an EXISTS probe that never
299
+ * unpacks the (occurrence-proportional) packed parents blob. */
300
+ hasContainers(child: NodeId): boolean;
301
+ /** A PAGE of containment parents — `limit` entries starting at `offset`
302
+ * (the buffered adds follow the stored ones), so a consumer can STREAM a
303
+ * common window's corpus-sized parent list and stop the moment its
304
+ * question is decided, with per-page work bounded by `limit`. */
305
+ containersSlice(child: NodeId, offset: number, limit: number): NodeId[];
306
+ nodeCount(): number;
307
+
308
+ // ── soft resonance over node gists ─────────────────────────────────────
309
+ /** The k nodes whose gist resonates most with v. */
310
+ resonate(v: Vec, k: number): Promise<Hit[]>;
311
+ /** Mark a node as a RESONANCE TARGET — promote its gist into the content
312
+ * index so {@link resonate} can find it. A node's gist is captured at intern
313
+ * but indexed LAZILY (only targets are indexed; the ~99.5% intermediate DAG
314
+ * is not — that is the store's compression). A node becomes a target
315
+ * implicitly via {@link link}/{@link pourHalo}; this is the EXPLICIT hook for
316
+ * the one target those do not cover — a DEPOSIT ROOT, the node `express`,
317
+ * `bridge`, and whole-query recall resonate the input to. Idempotent; a
318
+ * no-op once a node is indexed or if its captured gist has been evicted. */
319
+ indexTarget(id: NodeId): void;
320
+
321
+ /** Remove content-index entries for nodes that are structurally isolated
322
+ * (fewer than `minParents` structural parents) and are not roots of any
323
+ * experience (no edges, no halo). These nodes are unique to one tree and
324
+ * bridge nothing between experiences — their index slots are wasted.
325
+ *
326
+ * This is a POST-HOC batch operation, not on the training hot path. Run it
327
+ * at checkpoints or after training to reclaim index space. The underlying
328
+ * vector DB is compacted after deletion to physically free the space.
329
+ *
330
+ * INCREMENTAL: keep decisions are monotone (parents, edges and halos only
331
+ * ever grow), so entries examined by a previous pass are settled; each
332
+ * pass scans only entries indexed since the last one (a durable watermark
333
+ * over the index's monotone internal ids), making the checkpoint-cadence
334
+ * call cheap on a large trained store.
335
+ *
336
+ * @param minParents keep only nodes with ≥ this many structural parents
337
+ * (default 2 — keep nodes that bridge ≥2 experiences)
338
+ * @returns number of entries removed */
339
+ compactContentIndex(minParents?: number): Promise<number>;
340
+
341
+ /** Re-index structurally-important nodes whose gists were evicted from the
342
+ * pending cache before they could be indexed — the inverse of {@link
343
+ * compactContentIndex}. Walks the edge/halo-bearing id set (a repairable
344
+ * node must carry edges or a halo, so that set IS the candidate set —
345
+ * corpus-of-experiences-sized, not node-count-sized); for each candidate
346
+ * that (a) has ≥ `minParents` structural parents and (b) is NOT already
347
+ * in the content index, regenerates its gist via `regenerateGist` and adds
348
+ * it. Intended as a post-training batch operation, not on the hot path.
349
+ *
350
+ * `regenerateGist` receives a node id and must return its perceived gist
351
+ * vector (or `null` to skip). The store has no access to the
352
+ * {@link Space}/{@link Alphabet} needed for perception — the caller (the
353
+ * {@link Mind}) wires those in.
354
+ *
355
+ * @param regenerateGist async callback that regenerates a node's gist
356
+ * @param minParents only repair nodes with ≥ this many parents
357
+ * (default 2 — structural bridges)
358
+ * @returns number of nodes added to the index */
359
+ repairContentIndex(
360
+ regenerateGist: (id: NodeId) => Promise<Vec | null>,
361
+ minParents?: number,
362
+ ): Promise<number>;
363
+
364
+ // ── continuation edges (associations) ──────────────────────────────────
365
+ /** Learn that `to` follows `from`. Idempotent. */
366
+ link(from: NodeId, to: NodeId): Promise<void>;
367
+ /** What follows `id` (in insertion order). */
368
+ next(id: NodeId): NodeId[];
369
+ /** Whether ANY edge leaves `id` — the EXISTENCE probe for the forward
370
+ * relation. Decision points that only ask "does this lead anywhere?"
371
+ * must use this instead of materialising {@link next} (a context's edge
372
+ * list is a range read; a hub's is large) — the same
373
+ * count-instead-of-materialise principle {@link prevCount} serves for the
374
+ * reverse relation. One indexed point probe. */
375
+ hasNext(id: NodeId): boolean;
376
+ /** What `id` follows (for reverse recall). */
377
+ prev(id: NodeId): NodeId[];
378
+ /** The first `limit` continuations of `id`, in the SAME order {@link next}
379
+ * returns (insertion order) — the CAPPED read for consumers bounded by the
380
+ * hub convention. A LIMITed statement, never a full materialisation: no
381
+ * read through this may grow with the corpus. */
382
+ nextFirst(id: NodeId, limit: number): NodeId[];
383
+ /** The first `limit` predecessors of `id`, in the SAME order {@link prev}
384
+ * returns (NEWEST-first — prev is seq-descending; see the adapter) — the
385
+ * capped read for reverse fan-ins, which are corpus-sized on a common
386
+ * continuation. */
387
+ prevFirst(id: NodeId, limit: number): NodeId[];
388
+ /** How many nodes `id` follows — the reverse-edge SUPPORT count. Decision
389
+ * points that only weigh evidence (chooseNext) must use this instead of
390
+ * materialising {@link prev}, whose list is corpus-sized for a common
391
+ * continuation ("Yes."). One indexed COUNT, never a row materialisation. */
392
+ prevCount(id: NodeId): number;
393
+ /** How many DISTINCT nodes bear a continuation edge — the number of learnt
394
+ * contexts (the document count for inverse-document-frequency weighting in
395
+ * the consensus climb). O(1) from an index; a coarse count is fine. */
396
+ edgeSourceCount(): number;
397
+
398
+ // ── company / concept halos (distributional synonymy) ──────────────────
399
+ /** Pour a partner's signature into a node's halo. */
400
+ pourHalo(id: NodeId, add: Vec): Promise<void>;
401
+ /** Nodes whose halo resonates with `v` (the concept's siblings). */
402
+ resonateHalo(v: Vec, k: number): Promise<Hit[]>;
403
+ /** A node's halo, or null if it has none / too little mass. */
404
+ halo(id: NodeId): Vec | null;
405
+ /** Whether {@link halo} would return non-null — the EXISTENCE probe.
406
+ * {@link halo} decodes and normalizes the full D-element quantized row on
407
+ * every call; existence-only consumers (recognition's admission predicate,
408
+ * the search's fuse guard) must ask this instead: one row read, a mass
409
+ * compare, no decode. */
410
+ hasHalo(id: NodeId): boolean;
411
+ /** How many episode signatures were poured into `id`'s halo — the DIRECT
412
+ * measure of distributional evidence (each training pair pours once, so
413
+ * repetition counts, unlike {@link prevCount}, which counts DISTINCT
414
+ * contexts). Consulted at disambiguation decision points as the tie-break
415
+ * behind distinct-context support: diversity of evidence outranks sheer
416
+ * repetition, but repetition outranks insertion-order accident. 0 when
417
+ * the node has no halo row. */
418
+ haloMass(id: NodeId): number;
419
+
420
+ // ── lifecycle ──────────────────────────────────────────────────────────
421
+ size(): Promise<number>;
422
+ saveSnapshot(bytes: Uint8Array): Promise<void>;
423
+ loadSnapshot(): Promise<Uint8Array | null>;
424
+ /** Free-form provenance metadata (e.g. training dataset name). */
425
+ setMeta(key: string, val: string): Promise<void>;
426
+ getMeta(key: string): Promise<string | null>;
427
+ deleteMeta(key: string): Promise<void>;
428
+ /** Commit any deferred writes — makes metadata, edges, and node writes
429
+ * durable immediately. Safe to call frequently; a no-op if nothing is
430
+ * pending. */
431
+ commit(): void;
432
+
433
+ close(): Promise<void>;
434
+ }
435
+
436
+ // ── Serialisation utilities (pure functions, no DB dependency) ───────────
437
+
438
+ const _ZERO = new Uint8Array(0);
439
+
440
+ /** Hand control back to the event loop for exactly one turn.
441
+ *
442
+ * `await` on an already-resolved promise only queues a MICROTASK, and the engine
443
+ * drains the entire microtask queue before it runs a single MACROTASK — so
444
+ * micro-awaiting alone never lets a timer, an I/O completion, or any other queued
445
+ * macrotask run. A macrotask primitive (`setImmediate`, else `setTimeout`) does:
446
+ * it parks the continuation behind the loop's next iteration, after pending I/O.
447
+ *
448
+ * Deliberately NOT unref'd: a caller is awaiting this promise, so the one
449
+ * scheduling turn is real pending work. An unref'd wake-up let the process
450
+ * exit mid-`ingest` whenever the event loop had nothing else alive (a bare
451
+ * top-level deposit loop) — the await simply never resolved. */
452
+ function yieldToEventLoop(): Promise<void> {
453
+ return new Promise<void>((resolve) => {
454
+ const g = globalThis as unknown as {
455
+ setImmediate?: (cb: () => void) => unknown;
456
+ setTimeout: (cb: () => void, ms: number) => unknown;
457
+ };
458
+ if (g.setImmediate) g.setImmediate(resolve);
459
+ else g.setTimeout(resolve, 0);
460
+ });
461
+ }
462
+
463
+ /** Concatenate an array of byte arrays. */
464
+ function concat(parts: Uint8Array[]): Uint8Array {
465
+ let n = 0;
466
+ for (const p of parts) n += p.length;
467
+ const out = new Uint8Array(n);
468
+ let o = 0;
469
+ for (const p of parts) {
470
+ out.set(p, o);
471
+ o += p.length;
472
+ }
473
+ return out;
474
+ }
475
+
476
+ /** The byte string a FLAT branch spans — a branch whose kids are ALL implicit
477
+ * single-byte leaves (ids −256…−1) is fully determined by its bytes, one per
478
+ * kid. Returns null when any kid is a real node. Such branches (the sliding
479
+ * sub-span windows, the leaf-parent chunks, the per-deposit flat root spans —
480
+ * the bulk of what perception explodes text into) are stored as their BYTES
481
+ * (1 byte per kid) in the leaf column, with a zero-length kids blob marking
482
+ * "derive the kid list from the bytes" — 4× smaller than int32-packed ids,
483
+ * and content-addressed through the same leaf index instead of a second
484
+ * blob-duplicating kids index. */
485
+ function flatKidsBytes(kids: NodeId[]): Uint8Array | null {
486
+ const out = new Uint8Array(kids.length);
487
+ for (let i = 0; i < kids.length; i++) {
488
+ const k = kids[i];
489
+ if (k < -256 || k >= 0) return null;
490
+ out[i] = -(k + 1);
491
+ }
492
+ return out;
493
+ }
494
+
495
+ /** The implicit kid list of a flat branch — the inverse of
496
+ * {@link flatKidsBytes}. */
497
+ export function flatBytesKids(bytes: Uint8Array): NodeId[] {
498
+ const out = new Array<NodeId>(bytes.length);
499
+ for (let i = 0; i < bytes.length; i++) out[i] = -(bytes[i] + 1);
500
+ return out;
501
+ }
502
+
503
+ /** Pack a child-id list as little-endian int32s — 4 bytes per child, far more
504
+ * compact than a space-joined decimal string and trivial to read back. */
505
+ export function packKids(kids: NodeId[]): Uint8Array {
506
+ const out = new Int32Array(kids.length);
507
+ for (let i = 0; i < kids.length; i++) out[i] = kids[i];
508
+ return new Uint8Array(out.buffer);
509
+ }
510
+ export function unpackKids(blob: Uint8Array): NodeId[] {
511
+ // The BLOB may be a view at a non-multiple-of-4 byteOffset, so copy before
512
+ // reinterpreting as int32.
513
+ const i32 = new Int32Array(blob.slice().buffer);
514
+ return Array.from(i32);
515
+ }
516
+
517
+ /** 32-bit FNV-1a of a byte blob — the integer content hash `idx_node_h` keys
518
+ * on. Collisions are resolved by verifying the stored blob, never trusted. */
519
+ function hashOf(bytes: Uint8Array): number {
520
+ let h = 0x811c9dc5 >>> 0;
521
+ for (let i = 0; i < bytes.length; i++) {
522
+ h ^= bytes[i];
523
+ h = Math.imul(h, 0x01000193) >>> 0;
524
+ }
525
+ return h >>> 0;
526
+ }
527
+
528
+ /** Fast content key (FNV-1a + length). No array spread onto the call stack. */
529
+ function keyOf(bytes: Uint8Array): string {
530
+ return hashOf(bytes).toString(16) + ":" + bytes.length;
531
+ }
532
+
533
+ /** Re-index a halo only when its mass is small or a power of two — O(log mass)
534
+ * writes per node instead of O(mass). */
535
+ function geometricMass(mass: number): boolean {
536
+ return mass <= 4 || (mass & (mass - 1)) === 0;
537
+ }
538
+
539
+ // Halo accumulators are stored 2-bit quantized: the exact float32 L2 norm
540
+ // followed by D two-bit codes (sign + magnitude class) — 4 + D/4 bytes, 16×
541
+ // smaller than float32 and 4× smaller than int8, which made halo rows the
542
+ // single largest table in an episodically-trained store.
543
+ //
544
+ // Two bits is the COARSEST grain that preserves what halos are read for. A
545
+ // halo is a superposition of high-dimensional random signatures, so its
546
+ // elements are Gaussian; the four levels below are the Lloyd-Max optimal
547
+ // quantizer for a unit Gaussian (decision threshold ±0.9816σ, reconstruction
548
+ // ±0.4528σ / ±1.5104σ), whose output keeps ≥0.88 correlation with the exact
549
+ // accumulator. Every consumer is a thresholded resonance — direct cosines
550
+ // against the concept midpoint, or queries into the halo VectorDatabase whose
551
+ // stored codes are 1-bit RaBitQ anyway — and a 0.88-correlated direction moves
552
+ // none of those comparisons across the midpoint. One bit is NOT enough: a pure
553
+ // sign grid compresses cosine c to (2/π)·asin(c), which drags mid-band concept
554
+ // resonance below the midpoint and severs legitimate cross-name transfer.
555
+ // σ is derived from the stored norm (σ = norm/√D), so the codec carries no
556
+ // per-corpus state; the decoded vector is rescaled to the exact norm, keeping
557
+ // incremental pours magnitude-true.
558
+ const HALO_Q_THRESHOLD = 0.9816; // Lloyd-Max decision point, unit Gaussian
559
+ const HALO_Q_LO = 0.4528; // reconstruction level, inner cell
560
+ const HALO_Q_HI = 1.5104; // reconstruction level, outer cell
561
+ function haloEncode(v: Vec): Uint8Array {
562
+ const D = v.length;
563
+ const out = new Uint8Array(4 + ((D + 3) >> 2));
564
+ let n2 = 0;
565
+ for (let i = 0; i < D; i++) n2 += v[i] * v[i];
566
+ const norm = Math.sqrt(n2);
567
+ new DataView(out.buffer).setFloat32(0, norm, true);
568
+ const bar = HALO_Q_THRESHOLD * (norm / Math.sqrt(D));
569
+ for (let i = 0; i < D; i++) {
570
+ const x = v[i];
571
+ // bit0: sign, bit1: |x| beyond the Gaussian decision threshold.
572
+ const code = (x > 0 ? 1 : 0) | ((x > bar || -x > bar) ? 2 : 0);
573
+ out[4 + (i >> 2)] |= code << ((i & 3) << 1);
574
+ }
575
+ return out;
576
+ }
577
+ function haloDecode(blob: Uint8Array, D: number): Vec {
578
+ const norm = new DataView(blob.buffer, blob.byteOffset, 4).getFloat32(
579
+ 0,
580
+ true,
581
+ );
582
+ const out = new Float32Array(D);
583
+ if (norm === 0) return out;
584
+ const sigma = norm / Math.sqrt(D);
585
+ let n2 = 0;
586
+ for (let i = 0; i < D; i++) {
587
+ const code = (blob[4 + (i >> 2)] >> ((i & 3) << 1)) & 3;
588
+ const mag = (code & 2 ? HALO_Q_HI : HALO_Q_LO) * sigma;
589
+ const x = code & 1 ? mag : -mag;
590
+ out[i] = x;
591
+ n2 += x * x;
592
+ }
593
+ // Rescale to the exact stored norm so accumulation stays magnitude-true.
594
+ const s = norm / Math.sqrt(n2);
595
+ for (let i = 0; i < D; i++) out[i] *= s;
596
+ return out;
597
+ }
598
+
599
+ // ── AbstractStore: template-method base with all domain logic ────────────
600
+
601
+ /**
602
+ * Template-method base class that contains ALL domain logic for the content-
603
+ * addressed DAG store — caching, dedup/merge decisions, structural-compaction
604
+ * geometric halo scheduling, buffer management. A concrete persistence adapter
605
+ * (e.g. {@link SQliteStore}) extends it and implements only the ~35 one-liner
606
+ * protected abstract methods that talk to the actual storage backend.
607
+ */
608
+ export abstract class AbstractStore implements Store {
609
+ // ── Abstract methods — the persistence-adapter contract ────────────────
610
+
611
+ /** Open the storage backend. Must set `this._nextId`, `this._D`, and
612
+ * `this._maxGroup` from the stored state (or defaults for a fresh store). */
613
+ protected abstract _dbOpen(): Promise<void>;
614
+ /** Close the storage backend and release all resources. */
615
+ protected abstract _dbClose(): void;
616
+
617
+ // -- Transaction --
618
+ protected abstract _dbBeginTx(): void;
619
+ protected abstract _dbCommitTx(): void;
620
+
621
+ // -- Node CRUD --
622
+ protected abstract _dbInsertNode(
623
+ id: NodeId,
624
+ leaf: Uint8Array | null,
625
+ kids: Uint8Array | null,
626
+ h: number,
627
+ ): void;
628
+ protected abstract _dbGetNode(id: NodeId): NodeRec | null;
629
+ protected abstract _dbFindLeaf(
630
+ h: number,
631
+ bytes: Uint8Array,
632
+ ): NodeId | null;
633
+ protected abstract _dbFindBranchByLeaf(
634
+ h: number,
635
+ bytes: Uint8Array,
636
+ ): NodeId | null;
637
+ protected abstract _dbFindBranchByKids(
638
+ h: number,
639
+ packed: Uint8Array,
640
+ ): NodeId | null;
641
+
642
+ // -- Kid (structural parent) edges --
643
+ protected abstract _dbInsertKid(child: NodeId, parent: NodeId): void;
644
+ protected abstract _dbGetParents(id: NodeId): NodeId[];
645
+ /** LIMITed variant of the parent read — same statement, `LIMIT ?`. Must
646
+ * NOT be implemented by materialising and slicing. */
647
+ protected abstract _dbGetParentsFirst(id: NodeId, limit: number): NodeId[];
648
+
649
+ // -- Containment --
650
+ protected abstract _dbGetContainParents(child: NodeId): NodeId[];
651
+ /** Whether a containment row exists for `child` — no blob unpack. */
652
+ protected abstract _dbContainExists(child: NodeId): boolean;
653
+ /** A page of stored containment parents — partial blob unpack. */
654
+ protected abstract _dbGetContainParentsSlice(
655
+ child: NodeId,
656
+ offset: number,
657
+ limit: number,
658
+ ): NodeId[];
659
+ /** The stored containment-parent COUNT — blob length / 4, no unpack. */
660
+ protected abstract _dbGetContainCount(child: NodeId): number;
661
+ /** Append containment parents for `child`. MUST NOT re-write the child's
662
+ * whole stored list per call — a hot child's fan-in is corpus-sized, and a
663
+ * full read-modify-write per flush is quadratic over training. Durable
664
+ * dedup may be DEFERRED (e.g. to a geometric merge schedule); readers
665
+ * dedup, so transient duplicates only cost bytes, never correctness. */
666
+ protected abstract _dbAppendContain(
667
+ child: NodeId,
668
+ parents: NodeId[],
669
+ ): void;
670
+
671
+ // -- Continuation edges --
672
+ protected abstract _dbInsertEdge(src: NodeId, dst: NodeId): void;
673
+ protected abstract _dbGetNextEdges(id: NodeId): NodeId[];
674
+ protected abstract _dbGetPrevEdges(id: NodeId): NodeId[];
675
+ /** LIMITed variants of the two edge reads — same ORDER BY, `LIMIT ?`.
676
+ * These exist so a capped consumer's cost is bounded by the cap, not by
677
+ * the fan-out; an adapter must NOT implement them by materialising and
678
+ * slicing. */
679
+ protected abstract _dbGetNextEdgesFirst(
680
+ id: NodeId,
681
+ limit: number,
682
+ ): NodeId[];
683
+ protected abstract _dbGetPrevEdgesFirst(
684
+ id: NodeId,
685
+ limit: number,
686
+ ): NodeId[];
687
+ /** Whether ANY edge already leaves `src` — one indexed point probe, used to
688
+ * maintain the distinct-source count incrementally. */
689
+ protected abstract _dbEdgeSrcExists(src: NodeId): boolean;
690
+ protected abstract _dbEdgeDistinctSrcCount(): number;
691
+
692
+ // -- Halos (durable 2-bit quantized rows) --
693
+ protected abstract _dbGetHalo(
694
+ id: NodeId,
695
+ ): { vec: Uint8Array; mass: number } | null;
696
+ protected abstract _dbUpsertHalo(
697
+ id: NodeId,
698
+ encodedVec: Uint8Array,
699
+ mass: number,
700
+ ): void;
701
+
702
+ // -- Meta --
703
+ protected abstract _dbGetMeta(key: string): string | null;
704
+ protected abstract _dbSetMeta(key: string, val: string): void;
705
+ protected abstract _dbDeleteMeta(key: string): void;
706
+
707
+ // -- Snapshot --
708
+ protected abstract _dbSaveSnapshot(bytes: Uint8Array): void;
709
+ protected abstract _dbLoadSnapshot(): Uint8Array | null;
710
+
711
+ // -- Vector DB: content (gist) index --
712
+ protected abstract _vecContentUpsert(
713
+ entries: Array<{ id: NodeId; vector: Float32Array; ef?: number }>,
714
+ ): void;
715
+ protected abstract _vecContentQuery(
716
+ v: Float32Array,
717
+ k: number,
718
+ ef: number,
719
+ ): Array<{ id: number; distance: number }>;
720
+ /** Whether `id` is already a live entry of the content index — one point
721
+ * query, used to recognise durably-indexed nodes across sessions (the
722
+ * in-memory `_indexedIds` cache starts empty on every open). */
723
+ protected abstract _vecContentHas(id: NodeId): boolean;
724
+ protected abstract _vecContentSize(): number;
725
+ protected abstract _vecContentLastReads(): number;
726
+ protected abstract _vecContentPhysicalSize(): number;
727
+ protected abstract _vecContentCompact(): void;
728
+ /** Live content-index entries whose INTERNAL id is > `after`, as
729
+ * {ext, internal} pairs in internal-id order. Internal ids are monotone
730
+ * at insert and preserved by the index's tombstone-splice compaction, so
731
+ * the largest internal id a scan has seen is a durable watermark for
732
+ * incremental maintenance ({@link compactContentIndex}). */
733
+ protected abstract _vecContentEntriesSince(
734
+ after: number,
735
+ ): IterableIterator<{ ext: NodeId; internal: number }>;
736
+ /** Every node id that carries a continuation edge (as source OR target) or
737
+ * a halo row — the RESONANCE-TARGET id set, sorted ascending and deduped.
738
+ * This is the exact keep/repair criterion of index maintenance; driving
739
+ * the maintenance loops from this (corpus-of-experiences-sized) set
740
+ * instead of probing per node turned repair from an every-node walk into
741
+ * a candidates-only walk. */
742
+ protected abstract _dbEdgeOrHaloIds(): NodeId[];
743
+ /** Remove a batch of entries from the content index by external id, under
744
+ * ONE storage transaction (a tombstone per implicit transaction is one WAL
745
+ * commit per id — the dominant cost of a bulk prune). Idempotent per id —
746
+ * already-deleted or non-existent ids are skipped. */
747
+ protected abstract _vecContentDeleteMany(ids: NodeId[]): void;
748
+
749
+ // -- Vector DB: halo index --
750
+ protected abstract _vecHaloUpsert(
751
+ entries: Array<{ id: NodeId; vector: Float32Array }>,
752
+ ): void;
753
+ protected abstract _vecHaloQuery(
754
+ v: Float32Array,
755
+ k: number,
756
+ ef: number,
757
+ ): Array<{ id: number; distance: number }>;
758
+ /** Live (non-tombstoned) entry count of the halo index — the denominator
759
+ * the tombstone-ratio compaction trigger compares physical size against. */
760
+ protected abstract _vecHaloSize(): number;
761
+ protected abstract _vecHaloPhysicalSize(): number;
762
+ protected abstract _vecHaloCompact(): void;
763
+
764
+ // ── Config ─────────────────────────────────────────────────────────────
765
+
766
+ protected _D: number;
767
+ protected _maxGroup: number;
768
+ protected readonly minHaloMass: number;
769
+ protected readonly efSearch: number;
770
+ protected readonly m: number;
771
+ protected readonly efConstruction: number;
772
+ protected readonly efConstructionInterior: number;
773
+ protected readonly overfetch: number;
774
+ protected readonly batchSize: number;
775
+ protected readonly compactEveryNWrites: number;
776
+
777
+ // ── State ──────────────────────────────────────────────────────────────
778
+
779
+ /** Branch node ids are a dense, monotonically-increasing integer sequence
780
+ * (0,1,2,…). Single-byte leaves occupy the implicit negative range −256…−1.
781
+ * They are NEVER deleted, so the count of minted branch ids IS the next id —
782
+ * which doubles as the branch-node count and lets has() be an O(1) check.
783
+ * Set by `_dbOpen()` from the stored node count; incremented by `mintId()`. */
784
+ protected _nextId = 0;
785
+ protected _writtenSinceCompact = 0;
786
+ protected closed = false;
787
+
788
+ /** Lifecycle guard — resolved once `_dbOpen()` completes. */
789
+ protected _ready: Promise<void> | null = null;
790
+
791
+ // ── Caches ─────────────────────────────────────────────────────────────
792
+
793
+ /** Exact-content dedup: content-key → node id. Intrinsic compression. */
794
+ protected readonly _leafKey: BoundedMap<string, NodeId>;
795
+ protected readonly _branchKey: BoundedMap<string, NodeId>;
796
+ /** Reconstructed-bytes read cache (regenerable), keyed by node id. */
797
+ protected readonly _bytesCache: BoundedMap<NodeId, Uint8Array>;
798
+ /** contentLen memo — content is immutable, so entries never invalidate. */
799
+ protected readonly _lenCache: BoundedMap<NodeId, number>;
800
+ /** Node-record cache — avoids repeated persistence queries for shared DAG
801
+ * nodes. Each record is small (a few ints + short leaf buffer). */
802
+ protected readonly _recCache: BoundedMap<NodeId, NodeRec>;
803
+
804
+ /** Captured-but-not-yet-indexed gists. Sized in bytes (each is D·4); a deposit
805
+ * links/pours a node right after interning it, so the working set is one
806
+ * deposit's nodes — a small budget captures ~all of it, and an eviction only
807
+ * means that node is reached by the DAG climb instead of by direct
808
+ * resonance. */
809
+ protected _pendingGist!: BoundedMap<NodeId, Vec>;
810
+ /** EXACT halo accumulators for the session's live pours: full-precision in
811
+ * memory, 2-bit on disk, so within-session accumulate-then-compare never
812
+ * round-trips through the quantizer. Regenerable — a miss reads the durable
813
+ * 2-bit row. */
814
+ protected _haloExact!: BoundedMap<NodeId, Vec>;
815
+ /** NORMALIZED halo read cache — the decoded, normalized vector {@link halo}
816
+ * returns, cached by id so repeat reads skip the per-call 2-bit decode and
817
+ * normalize of a full D-element row (measured on a trained store: ~15K
818
+ * halo() calls per deep query over ~50 distinct ids — all but the first
819
+ * per id pure re-decode). Point-invalidated by {@link pourHalo}, the one
820
+ * halo mutation site. Callers receive a COPY, so the cached vector is
821
+ * never aliased. Regenerable — a miss re-decodes the durable row. */
822
+ protected _haloNorm!: BoundedMap<NodeId, Vec>;
823
+
824
+ /** Interiors deliberately SKIPPED by indexSubtree (unique nodes with 1 parent
825
+ * that bridge nothing). Remembered so subsequent visits prune the subtree
826
+ * without re-checking parent count. LRU-bounded: an evicted entry is
827
+ * re-checked on next visit — if it gained parents in the meantime, it will
828
+ * be promoted to the index. */
829
+ protected _coveredIds!: BoundedMap<NodeId, true>;
830
+ /** Live content-index id set, LRU-bounded so a massive ingest never leaks
831
+ * memory; an evicted entry is still indexed (the row is durable), so the
832
+ * only cost of an eviction is a duplicate HNSW probe on next visit. */
833
+ protected _indexedIds!: BoundedMap<NodeId, true>;
834
+
835
+ // ── ANN read cache ─────────────────────────────────────────────────────
836
+ // The index is read-only between writes, so the same (v,k) always returns
837
+ // the same neighbours. Any index mutation (flush, delete, compact) drops
838
+ // the cache; the next miss recreates it. Content-addressed (vecKey), not
839
+ // identity-addressed — same principle as perceiveMemo.
840
+
841
+ /** ANN read cache for {@link resonate} — keyed by vecKey(v) + ":" + k;
842
+ * lazily initialised, dropped on any index mutation. */
843
+ protected _resonateCache: Map<string, Hit[]> | null = null;
844
+ /** ANN read cache for {@link resonateHalo} — same scheme. */
845
+ protected _resonateHaloCache: Map<string, Hit[]> | null = null;
846
+
847
+ // ── Write buffers ──────────────────────────────────────────────────────
848
+
849
+ /** Content (gist) index write buffer. `ef` is the per-entry HNSW
850
+ * construction budget: reach-only interiors carry the reduced
851
+ * `efConstructionInterior`; dedup targets omit it (full budget). */
852
+ protected _contentBuffer: Array<
853
+ { id: NodeId; vector: Float32Array; ef?: number }
854
+ > = [];
855
+ /** Halo index write buffer — keyed by id so repeats within a batch coalesce. */
856
+ protected _haloBuffer = new Map<NodeId, Float32Array>();
857
+ /** Containment write buffer: child → new parents, merged on flush cadence. */
858
+ protected _containBuf = new Map<NodeId, Set<NodeId>>();
859
+
860
+ /** Dedup-target candidates still in the write buffer (keyed by id). Only
861
+ * roots that have gained an edge/halo are targets; a fresh intermediate
862
+ * branch is never folded onto. */
863
+ protected _nearDedupBuf = new Map<NodeId, Float32Array>();
864
+ /** Ids currently in `_contentBuffer` (not yet flushed) — O(1) membership. */
865
+ protected _bufferedIds = new Set<NodeId>();
866
+
867
+ // ── Transparent-chain cache ────────────────────────────────────────────
868
+
869
+ /** {@link Store.chainRun} results, valid for the store's lifetime BETWEEN
870
+ * writes: a chain is a pure function of the kid and edge tables, so any
871
+ * write that could break a node's transparency (a fresh mint inserting kid
872
+ * rows, a link inserting an edge) drops the whole cache — see the two
873
+ * invalidation sites. Regenerable; a miss re-walks. */
874
+ protected _chainMemo!: BoundedMap<NodeId, NodeId[]>;
875
+
876
+ // ── Edge-source-count cache ────────────────────────────────────────────
877
+
878
+ /** Distinct edge-source count — the store's DOCUMENT COUNT (how many
879
+ * learnt contexts predict a continuation), the N of every
880
+ * inverse-document-frequency read. −1 until first asked for; from then
881
+ * on maintained INCREMENTALLY by {@link link} (edges are never deleted),
882
+ * so a read is O(1) — never a table scan on the recall path. */
883
+ protected _edgeSrcCount = -1;
884
+
885
+ // ── Constructor ────────────────────────────────────────────────────────
886
+
887
+ constructor(config: StoreConfig, D: number, maxGroup: number) {
888
+ this._D = D;
889
+ this._maxGroup = maxGroup;
890
+ this.minHaloMass = config.minHaloMass;
891
+ this.efSearch = config.efSearch;
892
+ this.m = config.m;
893
+ this.efConstruction = config.efConstruction;
894
+ this.efConstructionInterior = Math.max(
895
+ 1,
896
+ Math.min(config.efConstructionInterior, config.efConstruction),
897
+ );
898
+ this.overfetch = config.overfetch;
899
+ this.batchSize = config.batchSize;
900
+ this.compactEveryNWrites = config.compactEveryNWrites;
901
+ this._leafKey = new BoundedMap(config.dedupCacheMax);
902
+ this._branchKey = new BoundedMap(config.dedupCacheMax);
903
+ this._bytesCache = new BoundedMap(
904
+ config.bytesCacheMax,
905
+ (v) => v.byteLength,
906
+ "smallest",
907
+ );
908
+ this._lenCache = new BoundedMap(config.bytesCacheMax, () => 16);
909
+ this._recCache = new BoundedMap(
910
+ config.recCacheBytes,
911
+ (r) => (r.leaf?.byteLength ?? 0) + (r.kids?.length ?? 0) * 4 + 12,
912
+ );
913
+ this._pendingGist = new BoundedMap<NodeId, Vec>(
914
+ config.pendingGistBytes,
915
+ (v) => v.byteLength,
916
+ );
917
+ this._haloExact = new BoundedMap<NodeId, Vec>(
918
+ config.haloCacheBytes,
919
+ (v) => v.byteLength,
920
+ );
921
+ this._haloNorm = new BoundedMap<NodeId, Vec>(
922
+ config.haloCacheBytes,
923
+ (v) => v.byteLength,
924
+ );
925
+ this._coveredIds = new BoundedMap<NodeId, true>(
926
+ config.coveredIdsMax,
927
+ );
928
+ this._indexedIds = new BoundedMap<NodeId, true>(
929
+ config.coveredIdsMax,
930
+ );
931
+ this._chainMemo = new BoundedMap<NodeId, NodeId[]>(
932
+ config.chainCacheBytes,
933
+ (v) => v.length * 4 + 32,
934
+ );
935
+ }
936
+
937
+ // ── Public accessors ───────────────────────────────────────────────────
938
+
939
+ get D(): number {
940
+ return this._D;
941
+ }
942
+
943
+ /** Await the async initialisation performed by the concrete constructor. */
944
+ protected async _ensureReady(): Promise<void> {
945
+ if (!this._ready) throw new Error("Store: not open");
946
+ await this._ready;
947
+ }
948
+
949
+ // ── Id management ──────────────────────────────────────────────────────
950
+
951
+ has(id: NodeId): boolean {
952
+ // Byte leaves (negative ids) always exist.
953
+ if (id < 0) return id >= -256;
954
+ return Number.isInteger(id) && id >= 0 && id < this._nextId;
955
+ }
956
+
957
+ protected mintId(): NodeId {
958
+ return this._nextId++;
959
+ }
960
+
961
+ nodeCount(): number {
962
+ return this._nextId;
963
+ }
964
+
965
+ async size(): Promise<number> {
966
+ await this._ensureReady();
967
+ return this._nextId;
968
+ }
969
+
970
+ // ── DAG traversal ──────────────────────────────────────────────────────
971
+
972
+ get(id: NodeId): NodeRec | null {
973
+ // Byte leaves are implicit — fabricate from the id.
974
+ if (id < 0) {
975
+ return { id, leaf: new Uint8Array([-(id + 1)]), kids: null };
976
+ }
977
+ const hit = this._recCache.get(id);
978
+ if (hit !== undefined) return hit;
979
+ const rec = this._dbGetNode(id);
980
+ if (rec) this._recCache.set(id, rec);
981
+ return rec;
982
+ }
983
+
984
+ /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
985
+ * Iterative post-order on an explicit stack — the call stack never sees the
986
+ * tree depth, so even an adversarial chain of nodes stays safe. */
987
+ /** How many reads hit a MISSING node record this session (a dangling edge
988
+ * or kid id). Zero in a healthy store; a growing count means references
989
+ * outlive their records — the read degrades safely to empty bytes, this
990
+ * counter is what keeps that degradation observable. */
991
+ danglingReads = 0;
992
+
993
+ bytes(id: NodeId): Uint8Array {
994
+ // Fast path.
995
+ const hit = this._bytesCache.get(id);
996
+ if (hit) return hit;
997
+
998
+ const stack: NodeId[] = [id];
999
+ const cache = this._bytesCache;
1000
+
1001
+ while (stack.length > 0) {
1002
+ const nid = stack[stack.length - 1]; // peek
1003
+
1004
+ // Already resolved by an earlier traversal.
1005
+ if (cache.get(nid)) {
1006
+ stack.pop();
1007
+ continue;
1008
+ }
1009
+
1010
+ const rec = this.get(nid);
1011
+ if (!rec) {
1012
+ // A DANGLING id (an edge or kid pointing at no record) reads as
1013
+ // empty — safe (empty-bytes guards drop it from grounding) but a
1014
+ // symptom of store corruption, so count it rather than stay silent.
1015
+ // The cache makes the empty read permanent for the session; the
1016
+ // counter survives as the visible trace.
1017
+ this.danglingReads++;
1018
+ cache.set(nid, _ZERO);
1019
+ stack.pop();
1020
+ continue;
1021
+ }
1022
+ if (rec.leaf) {
1023
+ cache.set(nid, new Uint8Array(rec.leaf));
1024
+ stack.pop();
1025
+ continue;
1026
+ }
1027
+
1028
+ // Branch — push any uncached children (reverse order so they resolve
1029
+ // left-to-right). If every child is already cached, concatenate now.
1030
+ const kids = rec.kids ?? [];
1031
+ let ready = true;
1032
+ for (let i = kids.length - 1; i >= 0; i--) {
1033
+ if (!cache.get(kids[i])) {
1034
+ stack.push(kids[i]);
1035
+ ready = false;
1036
+ }
1037
+ }
1038
+ if (!ready) continue;
1039
+
1040
+ stack.pop();
1041
+ const out = concat(kids.map((k) => cache.get(k)!));
1042
+ cache.set(nid, out);
1043
+ }
1044
+
1045
+ return cache.get(id) ?? _ZERO;
1046
+ }
1047
+
1048
+ /** First `maxLen` bytes of a node. Walks only the leftmost branch,
1049
+ * stopping at `maxLen` — so a 1 MB document root costs the same as a
1050
+ * 4-byte leaf. Recursive, but tree depth is logarithmic.
1051
+ *
1052
+ * IMMUTABILITY CONTRACT (applies to {@link bytes} too): returned arrays
1053
+ * may be shared with the byte cache and with other callers — treat them
1054
+ * as read-only. Mutating one would corrupt every subsequent read. */
1055
+ bytesPrefix(id: NodeId, maxLen: number): Uint8Array {
1056
+ if (maxLen <= 0) return _ZERO;
1057
+
1058
+ // A FULL read (the ALL sentinel) routes through bytes(), whose
1059
+ // reconstruction enters the byte-budget cache. Without this, the mind's
1060
+ // read() — which always passes ALL — re-walked the DAG and re-concatenated
1061
+ // on EVERY repeated read of an uncached branch, bypassing the cache that
1062
+ // exists precisely for those reconstructions.
1063
+ if (maxLen >= 0x7fffffff) return this.bytes(id);
1064
+
1065
+ // Full-cache hit: bytes() already reconstructed the whole node.
1066
+ const full = this._bytesCache.get(id);
1067
+ if (full) return full.length <= maxLen ? full : full.subarray(0, maxLen);
1068
+
1069
+ const rec = this.get(id);
1070
+ if (!rec) return _ZERO;
1071
+
1072
+ if (rec.leaf) {
1073
+ // Cache the (small) leaf bytes — cheap and reusable. COPY before
1074
+ // caching: rec.leaf is the node record's own buffer, and handing it
1075
+ // out would let one mutating caller corrupt the record AND the cache
1076
+ // (bytes() makes the same copy for the same reason).
1077
+ const leaf = new Uint8Array(rec.leaf);
1078
+ this._bytesCache.set(id, leaf);
1079
+ return leaf.length <= maxLen ? leaf : leaf.subarray(0, maxLen);
1080
+ }
1081
+
1082
+ // Branch — walk children left-to-right, stopping at maxLen.
1083
+ const kids = rec.kids ?? [];
1084
+ const parts: Uint8Array[] = [];
1085
+ let got = 0;
1086
+ for (const k of kids) {
1087
+ if (got >= maxLen) break;
1088
+ const child = this.bytesPrefix(k, maxLen - got);
1089
+ parts.push(child);
1090
+ got += child.length;
1091
+ }
1092
+ return concat(parts);
1093
+ }
1094
+
1095
+ contentLen(id: NodeId, cap = Infinity): number {
1096
+ if (id < 0) return 1; // implicit single-byte leaf
1097
+ const hit = this._lenCache.get(id);
1098
+ if (hit !== undefined) return hit; // exact — valid under any cap
1099
+ if (cap <= 0) return 0;
1100
+ const rec = this.get(id);
1101
+ let n = 0;
1102
+ let clamped = false;
1103
+ if (rec) {
1104
+ if (rec.leaf) n = rec.leaf.length;
1105
+ else if (rec.kids) {
1106
+ for (const k of rec.kids) {
1107
+ n += this.contentLen(k, cap - n);
1108
+ if (n >= cap) {
1109
+ clamped = true; // partial sum — a lower bound, not the length
1110
+ break;
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+ if (!clamped) this._lenCache.set(id, n);
1116
+ return n;
1117
+ }
1118
+
1119
+ // ── Content-addressed lookup ───────────────────────────────────────────
1120
+
1121
+ findLeaf(bytes: Uint8Array): NodeId | null {
1122
+ if (bytes.length === 1) return -(bytes[0] + 1);
1123
+ const key = keyOf(bytes);
1124
+ const cached = this._leafKey.get(key);
1125
+ if (cached !== undefined) return cached;
1126
+ const id = this._dbFindLeaf(hashOf(bytes), bytes);
1127
+ if (id !== null) this._leafKey.set(key, id);
1128
+ return id;
1129
+ }
1130
+
1131
+ findBranch(kids: NodeId[]): NodeId | null {
1132
+ const key = kids.join(",");
1133
+ const cached = this._branchKey.get(key);
1134
+ if (cached !== undefined) return cached;
1135
+ const flat = flatKidsBytes(kids);
1136
+ let id: NodeId | null;
1137
+ if (flat) {
1138
+ id = this._dbFindBranchByLeaf(hashOf(flat), flat);
1139
+ } else {
1140
+ const packed = packKids(kids);
1141
+ id = this._dbFindBranchByKids(hashOf(packed), packed);
1142
+ }
1143
+ if (id !== null && key.length <= DEDUP_KEY_MAX) {
1144
+ this._branchKey.set(key, id);
1145
+ }
1146
+ return id;
1147
+ }
1148
+
1149
+ // ── Structural parents ─────────────────────────────────────────────────
1150
+
1151
+ parents(id: NodeId): NodeId[] {
1152
+ return this._dbGetParents(id);
1153
+ }
1154
+
1155
+ parentsFirst(id: NodeId, limit: number): NodeId[] {
1156
+ return this._dbGetParentsFirst(id, limit);
1157
+ }
1158
+
1159
+ hasParents(id: NodeId): boolean {
1160
+ return this._dbGetParentsFirst(id, 1).length > 0;
1161
+ }
1162
+
1163
+ chainRun(id: NodeId): readonly NodeId[] {
1164
+ const hit = this._chainMemo.get(id);
1165
+ if (hit !== undefined) return hit;
1166
+ const run = this._chainWalk(id, CHAIN_DEPTH_CAP);
1167
+ this._chainMemo.set(id, run);
1168
+ return run;
1169
+ }
1170
+
1171
+ /** {@link Store.chainRun}'s walk, node at a time through the existing
1172
+ * probes. Adapters with a set-based query engine should override with a
1173
+ * single server-side descent (the SQLite adapter uses a recursive CTE). */
1174
+ protected _chainWalk(id: NodeId, cap: number): NodeId[] {
1175
+ const run = [id];
1176
+ let n = id;
1177
+ while (run.length < cap) {
1178
+ if (this.hasNext(n) || this.prevCount(n) > 0) break;
1179
+ const ps = this._dbGetParentsFirst(n, 2);
1180
+ if (ps.length !== 1) break;
1181
+ n = ps[0];
1182
+ run.push(n);
1183
+ }
1184
+ return run;
1185
+ }
1186
+
1187
+ // ── Containment ────────────────────────────────────────────────────────
1188
+
1189
+ addContainer(child: NodeId, parent: NodeId): void {
1190
+ let set = this._containBuf.get(child);
1191
+ if (set === undefined) {
1192
+ set = new Set<NodeId>();
1193
+ this._containBuf.set(child, set);
1194
+ }
1195
+ set.add(parent);
1196
+ }
1197
+
1198
+ hasContainers(child: NodeId): boolean {
1199
+ if (this._dbContainExists(child)) return true;
1200
+ const buf = this._containBuf.get(child);
1201
+ return buf !== undefined && buf.size > 0;
1202
+ }
1203
+
1204
+ containersSlice(child: NodeId, offset: number, limit: number): NodeId[] {
1205
+ const out = this._dbGetContainParentsSlice(child, offset, limit);
1206
+ if (out.length >= limit) return out;
1207
+ // Buffered adds page in AFTER the stored ones. A buffered parent that is
1208
+ // also stored may repeat across the seam; page consumers dedup by id
1209
+ // (they all carry seen-sets), so a repeat costs a skip, never an error.
1210
+ const buf = this._containBuf.get(child);
1211
+ if (!buf || buf.size === 0) return out;
1212
+ const storedCount = this._dbGetContainCount(child);
1213
+ const bufStart = Math.max(0, offset - storedCount) +
1214
+ Math.max(0, out.length - Math.max(0, storedCount - offset));
1215
+ let i = 0;
1216
+ for (const p of buf) {
1217
+ if (out.length >= limit) break;
1218
+ if (i++ < bufStart) continue;
1219
+ out.push(p);
1220
+ }
1221
+ return out;
1222
+ }
1223
+
1224
+ containers(child: NodeId): NodeId[] {
1225
+ const stored = this._dbGetContainParents(child);
1226
+ const buf = this._containBuf.get(child);
1227
+ if (stored.length === 0) return buf ? [...buf] : [];
1228
+ if (!buf) return stored;
1229
+ const merged = new Set<NodeId>(stored);
1230
+ for (const p of buf) merged.add(p);
1231
+ return [...merged];
1232
+ }
1233
+
1234
+ // ── Walk kids to collect implicit per-byte leaf ids ────────────────────
1235
+
1236
+ private flatLeafIds(kids: NodeId[]): NodeId[] | null {
1237
+ const out: NodeId[] = [];
1238
+ const stack = [...kids].reverse();
1239
+ while (stack.length > 0) {
1240
+ const id = stack.pop()!;
1241
+ if (id < 0) {
1242
+ out.push(id);
1243
+ continue;
1244
+ }
1245
+ const rec = this.get(id);
1246
+ if (!rec) return null;
1247
+ if (rec.leaf !== null) {
1248
+ for (let i = 0; i < rec.leaf.length; i++) out.push(-(rec.leaf[i] + 1));
1249
+ } else if (rec.kids !== null) {
1250
+ for (let i = rec.kids.length - 1; i >= 0; i--) stack.push(rec.kids[i]);
1251
+ }
1252
+ }
1253
+ return out;
1254
+ }
1255
+
1256
+ /** On a dedup HIT, keep the node's gist available for lazy indexing —
1257
+ * EXACTLY when it is not already indexed. Replaces the old id-range
1258
+ * "recency" heuristic (id ≥ nextId − cacheWindow), which conflated an LRU
1259
+ * entry COUNT with an id RANGE and permanently refused to index any node
1260
+ * that first became a resonance target long after it was minted (an early
1261
+ * interior later reused as an edge/halo-bearing deposit root was silently
1262
+ * unreachable by resonance). The durable index itself is the arbiter:
1263
+ * one point query, cached in `_indexedIds` on a hit so repeats are O(1). */
1264
+ private captureIfUnindexed(id: NodeId, gist: Vec): void {
1265
+ if (this._indexedIds.has(id) || this._pendingGist.has(id)) return;
1266
+ if (this._vecContentHas(id)) {
1267
+ this._indexedIds.set(id, true);
1268
+ return;
1269
+ }
1270
+ this._pendingGist.set(id, normalize(copy(gist)));
1271
+ // A node that ALREADY bridges experiences but was never indexed (its 1→2
1272
+ // transition fired while its gist was evicted, or in a pre-transition
1273
+ // store) is promoted on this re-encounter — the recapture above is
1274
+ // exactly what makes its gist available again. Byte leaves (negative
1275
+ // ids) never have kid rows, so skip their parent probe.
1276
+ if (id >= 0) this.promoteBridge(id);
1277
+ }
1278
+
1279
+ /** If `id` structurally bridges ≥2 experiences (the post-hoc compaction
1280
+ * criterion), promote its gist into the content index NOW — the exact
1281
+ * moment it becomes useful for multi-experience recall. The 1→2 parent
1282
+ * transition fires on {@link _dbInsertKid} during mint, and nodes that
1283
+ * were already bridges but missed indexing (gist evicted, pre-transition
1284
+ * store) are recaptured in {@link captureIfUnindexed}.
1285
+ *
1286
+ * A no-op when the node is already indexed or its gist is evicted from
1287
+ * the pending cache — a future re-encounter will retry. */
1288
+ private promoteBridge(id: NodeId): void {
1289
+ // A LIMITed probe: this runs once per kid insert on the MINT hot path,
1290
+ // and a shared child's full parent set grows with the corpus.
1291
+ if (this._dbGetParentsFirst(id, 2).length < 2) return;
1292
+ this.indexGist(id, false);
1293
+ }
1294
+
1295
+ // ── Core interning: dedup → near-dedup → mint ──────────────────────────
1296
+
1297
+ private async intern(
1298
+ leaf: Uint8Array | null,
1299
+ kids: NodeId[] | null,
1300
+ gist: Vec,
1301
+ ): Promise<NodeId> {
1302
+ await this._ensureReady();
1303
+
1304
+ // 1. Exact dedup — equal content → one id, no vector work. Primary
1305
+ // compression mechanism, intrinsic to the store.
1306
+ //
1307
+ // findLeaf/findBranch are the content-addressed lookups: in-memory cache
1308
+ // first, then a durable probe that repopulates the cache on a hit.
1309
+ // Using them here (rather than only the cache) makes dedup survive a cold
1310
+ // cache — a resumed/checkpointed training run, or one whose dedup cache
1311
+ // has evicted old keys, still recognises content already on disk and
1312
+ // reuses its id instead of minting a duplicate.
1313
+ const hit = leaf !== null ? this.findLeaf(leaf) : this.findBranch(kids!);
1314
+ if (hit !== null) {
1315
+ this.captureIfUnindexed(hit, gist);
1316
+ return hit;
1317
+ }
1318
+
1319
+ // 1b. Content-addressed lookup by leaf-id signature. When the same byte
1320
+ // sequence was stored as a flat branch (via putBranch during deposit),
1321
+ // a branch node spanning those bytes reuses that id even when its tree
1322
+ // structure differs — pure content addressing, same bytes → same node.
1323
+ if (kids !== null && kids.length >= 2) {
1324
+ const leafIds = this.flatLeafIds(kids);
1325
+ if (leafIds !== null) {
1326
+ const flatHit = this.findBranch(leafIds);
1327
+ if (flatHit !== null) {
1328
+ this.captureIfUnindexed(flatHit, gist);
1329
+ return flatHit;
1330
+ }
1331
+ }
1332
+ }
1333
+
1334
+ const cache = leaf !== null ? this._leafKey : this._branchKey;
1335
+ const key = leaf !== null ? keyOf(leaf) : kids!.join(",");
1336
+
1337
+ // 2. Near dedup — BRANCHES ONLY, against RESONANCE TARGETS only.
1338
+ // Leaves are single bytes: exact dedup already collapses every identical
1339
+ // leaf, and near-merging distinct leaves only corrupts bytes for no real
1340
+ // saving. Real near-dedup compression lives in subtree (branch) fusion.
1341
+ //
1342
+ // There is deliberately NO HNSW probe of the FLUSHED index here. It used
1343
+ // to fire for EVERY new branch that the buffer scan didn't settle — i.e.
1344
+ // ~every interior branch, since interiors are never dedup targets —
1345
+ // making one ANN query per branch the dominant training cost (it dwarfed
1346
+ // perception and the index write). And it was not merely expensive but
1347
+ // WRONG: the 1-bit RaBitQ code can rank a byte-DISTINCT branch as the
1348
+ // nearest "target" of a fresh branch, so the fold collapsed two
1349
+ // byte-different subtrees onto one id and corrupted exact reconstruction
1350
+ // (02-roundtrip's random-byte streams). Real near-duplicate EXPERIENCES
1351
+ // are caught two cheaper, exact ways instead: identical content by the
1352
+ // exact-dedup hash-cons above, and a near-gist target still in the write
1353
+ // buffer by the scan below.
1354
+ if (leaf === null) {
1355
+ // Near-dedup PREFILTER — the scale-aware identity bar
1356
+ // ({@link identityBar}) for THIS branch's own length, not the fixed
1357
+ // estimator floor: under the linear fold a long branch crosses
1358
+ // 1 − 1/√D while whole windows differ, and every such crossing paid a
1359
+ // full differsByOneWindow byte reconstruction. Same final semantics
1360
+ // (the byte check below still decides identity); strictly fewer byte
1361
+ // reads. The scan runs FIRST: the branch length (Σ kids' contentLen —
1362
+ // memoized bottom-up by the interning order itself, O(kids)) is only
1363
+ // computed once a nearest candidate actually exists, so the hot
1364
+ // no-candidate mint pays nothing.
1365
+ let best = -1;
1366
+ let bestId: NodeId | null = null;
1367
+ if (this._nearDedupBuf.size > 0) {
1368
+ const g = normalize(copy(gist));
1369
+ // Candidates are the buffered DEDUP TARGETS only — genuine whole
1370
+ // experiences (edge/halo-bearing roots) not yet flushed.
1371
+ for (const [id, vector] of this._nearDedupBuf) {
1372
+ const s = dot(g, vector);
1373
+ if (s > best) {
1374
+ best = s;
1375
+ bestId = id;
1376
+ }
1377
+ }
1378
+ }
1379
+ let blen = 0;
1380
+ if (bestId !== null) {
1381
+ for (const k of kids!) blen += this.contentLen(k);
1382
+ }
1383
+ if (
1384
+ bestId !== null &&
1385
+ best >= identityBar(this.D, this._maxGroup, blen)
1386
+ ) {
1387
+ // Scale-aware acceptance. The cosine bar alone is scale-BLIND
1388
+ // against a scale-DEPENDENT quantity: the hierarchical fold dilutes
1389
+ // a localized difference faster than linearly in form size, so for
1390
+ // deep forms ANY fixed bar below 1 is crossed by exactly the one
1391
+ // span that distinguishes two experiences. No inversion of the
1392
+ // deficit is trustworthy, so the bytes themselves decide: the two
1393
+ // forms must be identical except for ONE local span of at most W
1394
+ // bytes — the river window, the perception's own resolution quantum.
1395
+ const W = this._maxGroup;
1396
+ if (this.differsByOneWindow(kids!, bestId, W)) {
1397
+ if (key.length <= DEDUP_KEY_MAX) cache.set(key, bestId);
1398
+ return bestId;
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ // 3. Mint a fresh node. A FLAT branch (every kid an implicit single-byte
1404
+ // leaf) stores its BYTES in the leaf column with a zero-length kids blob
1405
+ // as the marker — the kid list is derived on read.
1406
+ const id = this.mintId();
1407
+ this._dbBeginTx();
1408
+ const flat = kids ? flatKidsBytes(kids) : null;
1409
+ const packed = kids && !flat ? packKids(kids) : null;
1410
+ this._dbInsertNode(
1411
+ id,
1412
+ leaf ?? flat,
1413
+ packed ?? (flat ? _ZERO : null),
1414
+ hashOf(leaf ?? flat ?? packed!),
1415
+ );
1416
+ // Reverse structural edge: each distinct child → this parent. Lets the graph
1417
+ // be climbed upward in index time, with no scan of the kids blobs.
1418
+ //
1419
+ // Populated NATURALLY here and only here — one write per child, in the SAME
1420
+ // mint that creates the node, inside the SAME deferred transaction as the
1421
+ // node row, so node and kid rows are always durable together.
1422
+ //
1423
+ // Implicit single-byte leaves get NO parent edge: a byte belongs to nearly
1424
+ // every branch, so its parent set is the corpus-sized hub the climb's
1425
+ // saturation guard discards unread.
1426
+ if (kids) {
1427
+ // Kid rows change parent counts — a child whose parent set grows from
1428
+ // one is no longer transparent, so every cached chain that hopped
1429
+ // through it is stale. Which chains those are is unknowable without a
1430
+ // reverse index, so the WHOLE cache drops (writes come in training
1431
+ // bursts where the cache is cold anyway; queries rebuild it lazily).
1432
+ this._chainMemo.clear();
1433
+ for (const c of kids) {
1434
+ if (c < 0 && c >= -256) continue;
1435
+ this._dbInsertKid(c, id);
1436
+ // The 1→2 parent TRANSITION happens here and only here (hash-cons
1437
+ // means an existing branch never re-inserts kid rows, so parent sets
1438
+ // grow exclusively through fresh mints): the child just became a
1439
+ // structural bridge between experiences — the exact set post-hoc
1440
+ // compaction keeps — so its gist enters the reach index NOW.
1441
+ this.promoteBridge(c);
1442
+ }
1443
+ }
1444
+ if (key.length <= DEDUP_KEY_MAX) cache.set(key, id);
1445
+ if (leaf) this._bytesCache.set(id, new Uint8Array(leaf));
1446
+ else if (flat) this._bytesCache.set(id, flat);
1447
+ // Capture the gist; it is pushed into the content index lazily, the first
1448
+ // time this node becomes a resonance target (link / pourHalo). A node that
1449
+ // never does (a pure intermediate DAG node — ~99.5% of them) is never
1450
+ // indexed: it costs one persistence row, no HNSW slot and no merge probe.
1451
+ this._pendingGist.set(id, normalize(copy(gist)));
1452
+ await this.maybeFlush();
1453
+ return id;
1454
+ }
1455
+
1456
+ /** Whether the byte content under `kids` and the byte content of `targetId`
1457
+ * are identical except for ONE local span of at most `W` bytes on each side
1458
+ * — the near dedup's byte-grain definition of a near-duplicate. A
1459
+ * common-prefix / common-suffix trim: whatever remains after both trims is
1460
+ * the single differing span (substitution, insertion or deletion), and both
1461
+ * remainders must fit the budget. Scattered differences leave a wide
1462
+ * middle and are rejected. */
1463
+ private differsByOneWindow(
1464
+ kids: NodeId[],
1465
+ targetId: NodeId,
1466
+ W: number,
1467
+ ): boolean {
1468
+ const a = concat(
1469
+ kids.map((k) => this.bytesPrefix(k, Number.MAX_SAFE_INTEGER)),
1470
+ );
1471
+ const b = this.bytesPrefix(targetId, a.length + W + 1);
1472
+ if (Math.abs(a.length - b.length) > W) return false;
1473
+ const n = Math.min(a.length, b.length);
1474
+ let i = 0;
1475
+ while (i < n && a[i] === b[i]) i++;
1476
+ let j = 0;
1477
+ while (j < n - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
1478
+ return a.length - i - j <= W && b.length - i - j <= W;
1479
+ }
1480
+
1481
+ async putLeaf(bytes: Uint8Array, gist: Vec): Promise<NodeId> {
1482
+ // Single bytes are implicit — no DB row and no eager index slot. The gist
1483
+ // is captured like any other node's and promoted into the content index
1484
+ // LAZILY, the first time the byte becomes a resonance target.
1485
+ if (bytes.length === 1) {
1486
+ const id = -(bytes[0] + 1);
1487
+ this.captureIfUnindexed(id, gist);
1488
+ return id;
1489
+ }
1490
+ return this.intern(new Uint8Array(bytes), null, gist);
1491
+ }
1492
+
1493
+ async putBranch(kids: NodeId[], gist: Vec): Promise<NodeId> {
1494
+ return this.intern(null, kids, gist);
1495
+ }
1496
+
1497
+ // ── Lazy content indexing ──────────────────────────────────────────────
1498
+
1499
+ /** Promote a node's captured gist into the content (resonance) index, once.
1500
+ * Called the first time a node becomes a target — i.e. from `link` (it bears
1501
+ * or receives a continuation edge) or `pourHalo` (it gains distributional
1502
+ * company). Idempotent: a node already indexed, or whose gist has been evicted
1503
+ * from the bounded pending map, is a no-op.
1504
+ *
1505
+ * `dedupTarget` marks the node a candidate the near dedup may fold a fresh
1506
+ * near-gist branch ONTO. Only a genuine target — an edge/halo-bearing ROOT —
1507
+ * is one; a climb-only interior is reach-indexed but never a dedup sink. */
1508
+ protected indexGist(id: NodeId, dedupTarget: boolean): void {
1509
+ if (dedupTarget && this._bufferedIds.has(id)) {
1510
+ // Still buffered (indexed this batch, not yet flushed) — a live dedup
1511
+ // candidate, recorded in O(1) with no scan of the content buffer.
1512
+ const v = this._pendingGist.get(id);
1513
+ if (v !== undefined) this._nearDedupBuf.set(id, v);
1514
+ }
1515
+ if (this._indexedIds.has(id)) return;
1516
+ const v = this._pendingGist.get(id);
1517
+ if (v === undefined) return;
1518
+ // Already durably indexed by a previous session? A node id names its
1519
+ // content, and the gist is a pure function of the content, so the stored
1520
+ // vector can only be identical — re-buffering it would spend an encode
1521
+ // and an upsert on a guaranteed no-op. One point query recognises this;
1522
+ // it costs ~nothing for genuinely new nodes (a miss on a covering index).
1523
+ // This is what makes a RESUMED training run replay already-deposited
1524
+ // content at read speed instead of re-upserting the recent-id window.
1525
+ if (this._vecContentHas(id)) {
1526
+ this._indexedIds.set(id, true);
1527
+ return;
1528
+ }
1529
+ this._indexedIds.set(id, true);
1530
+ // Reach-only interiors build with the reduced construction budget — the
1531
+ // one deliberate speed-for-quality trade of ingestion (see
1532
+ // {@link StoreConfig.efConstructionInterior}); targets keep the full one.
1533
+ this._contentBuffer.push(
1534
+ dedupTarget
1535
+ ? { id, vector: v }
1536
+ : { id, vector: v, ef: this.efConstructionInterior },
1537
+ );
1538
+ this._bufferedIds.add(id);
1539
+ // A node indexed AS a dedup target enters the candidate set immediately.
1540
+ if (dedupTarget) this._nearDedupBuf.set(id, v);
1541
+ }
1542
+
1543
+ /** {@link Store.indexTarget} — the public hook for marking a deposit root a
1544
+ * resonance target, the one target `link`/`pourHalo` do not cover. A deposit
1545
+ * root is a genuine target (a whole experience), so it is a dedup target
1546
+ * too. */
1547
+ indexTarget(id: NodeId): void {
1548
+ this.indexGist(id, true);
1549
+ }
1550
+
1551
+ /** Index a node and its interior forms as resonance targets. A node that
1552
+ * gains an edge is a learnt EXPERIENCE, and the consensus climb
1553
+ * ({@link Mind.climbAttention}) answers a query naming only a PORTION of it by
1554
+ * resonating its SUB-REGIONS — branch nodes within the experience — and
1555
+ * climbing their parents back to it.
1556
+ *
1557
+ * EVERY interior branch is indexed unconditionally, and this is
1558
+ * LOAD-BEARING: indexing only structural bridges (nodes with ≥2 parents,
1559
+ * the post-hoc compaction criterion) was tried and REJECTED by the test
1560
+ * suite — partial recall of an experience's interior slices, multi-topic
1561
+ * attention, and counterfactual anchoring all resonate to SINGLE-parent
1562
+ * interiors (13 tests fail without them). Post-hoc structural compaction
1563
+ * ({@link compactContentIndex}) may still remove them, but that is a
1564
+ * storage/recall trade-off for archived stores, not a free optimisation.
1565
+ * The store's hash-cons bounds the index by the number of DISTINCT byte
1566
+ * patterns in the corpus — not by the number of deposits.
1567
+ *
1568
+ * Only the ROOT is a DEDUP TARGET — the whole experience a fresh near-gist
1569
+ * branch may legitimately fold onto. Interior nodes are REACH-ONLY: they
1570
+ * let a partial query resonate and climb, but a fresh branch must never
1571
+ * merge onto an interior node of another experience.
1572
+ *
1573
+ * Iterative explicit-queue walk: the call stack never sees tree depth. */
1574
+ protected indexSubtree(root: NodeId): void {
1575
+ // The root is the whole experience — always index as a merge target.
1576
+ this.indexGist(root, true);
1577
+
1578
+ const seen = new Set<NodeId>([root]);
1579
+ const stack: NodeId[] = [root];
1580
+ while (stack.length > 0) {
1581
+ const id = stack.pop()!;
1582
+ const kids = this.get(id)?.kids;
1583
+ if (!kids) continue; // leaf — never a resonance anchor
1584
+ const isRoot = id === root;
1585
+ // Already-indexed or already-skipped nodes PRUNE the walk — their
1586
+ // subtrees were classified in a prior call.
1587
+ if (!isRoot && (this._indexedIds.has(id) || this._coveredIds.has(id))) {
1588
+ continue;
1589
+ }
1590
+ for (const k of kids) {
1591
+ if (!seen.has(k)) {
1592
+ seen.add(k);
1593
+ stack.push(k);
1594
+ }
1595
+ }
1596
+ if (isRoot) continue;
1597
+ const g = this._pendingGist.get(id);
1598
+ if (g === undefined) {
1599
+ // Gist evicted from the bounded pending cache — the node can't be
1600
+ // indexed now. Mark as covered so subsequent visits prune here
1601
+ // instead of re-walking the subtree (the gist won't return).
1602
+ this._coveredIds.set(id, true);
1603
+ continue;
1604
+ }
1605
+ // Index unconditionally — every interior node is a valid resonance
1606
+ // anchor for partial-query recall. The _indexedIds cache and the
1607
+ // durable-index check in indexGist prevent duplicate indexing across
1608
+ // multiple encounters of the same shared subtree.
1609
+ this.indexGist(id, false);
1610
+ }
1611
+ }
1612
+
1613
+ // ── Soft resonance ─────────────────────────────────────────────────────
1614
+
1615
+ async resonate(v: Vec, k: number): Promise<Hit[]> {
1616
+ await this._ensureReady();
1617
+ // Synchronous flush of any buffered index writes: the FIRST resonance
1618
+ // after a large ingest pays that flush here, so it shows up in respond
1619
+ // latency, not ingest latency — correct behaviour, skewed attribution;
1620
+ // profile accordingly.
1621
+ this.flushContent();
1622
+ if (k <= 0) return [];
1623
+
1624
+ // ANN read cache — content-addressed so a fresh Float32Array with the
1625
+ // same values still hits. Lazy-init: null after any index write; the
1626
+ // first miss after a flush recreates it. When voteRegions resonates
1627
+ // identical perceived sub-regions, only the first call descends the ANN.
1628
+ const rk = vecKey(v) + ":" + k;
1629
+ const cache = this._resonateCache;
1630
+ if (cache) {
1631
+ const hit = cache.get(rk);
1632
+ if (hit !== undefined) return hit;
1633
+ }
1634
+
1635
+ const results = this._vecContentQuery(
1636
+ normalize(copy(v)),
1637
+ k * this.overfetch,
1638
+ this.efSearch,
1639
+ );
1640
+ const out: Hit[] = [];
1641
+ for (const r of results) {
1642
+ const id = r.id as NodeId;
1643
+ out.push({ id, score: 1 - r.distance });
1644
+ if (out.length >= k) break;
1645
+ }
1646
+
1647
+ const rc = this._resonateCache ??= new Map();
1648
+ if (rc.size >= RESONATE_CACHE_MAX) rc.clear();
1649
+ rc.set(rk, out);
1650
+ return out;
1651
+ }
1652
+
1653
+ indexedVectorCount(): number {
1654
+ this.flushContent();
1655
+ return this._vecContentSize();
1656
+ }
1657
+
1658
+ lastResonateReads(): number {
1659
+ return this._vecContentLastReads();
1660
+ }
1661
+
1662
+ // ── Content index compaction ────────────────────────────────────────────
1663
+
1664
+ /** How many physical compaction attempts have failed this session. Zero in
1665
+ * a healthy store; a growing count means tombstones are accumulating and
1666
+ * index query cost is drifting up (the first failure also warns once). */
1667
+ compactFailures = 0;
1668
+
1669
+ /** Meta key holding the incremental scan watermark of
1670
+ * {@link compactContentIndex}: "minParents:maxInternalIdScanned". KEEP
1671
+ * decisions are MONOTONE — parents, edges and halos only ever grow, so an
1672
+ * entry once kept can never become removable — and removed entries are
1673
+ * gone, so a pass only ever needs to examine entries indexed AFTER the
1674
+ * previous pass. Internal ids are monotone and survive the index's
1675
+ * splice compaction; the watermark is reset whenever a PHYSICAL index
1676
+ * compaction runs (id reuse after a dropped top row would otherwise hide
1677
+ * new entries behind it). */
1678
+ protected static readonly COMPACT_WATERMARK_KEY = "contentCompact.watermark";
1679
+
1680
+ /** {@link Store.compactContentIndex} */
1681
+ async compactContentIndex(minParents = 2): Promise<number> {
1682
+ await this._ensureReady();
1683
+ this.flush(); // commit any pending writes first
1684
+
1685
+ // Incremental scan: resume from the last pass's watermark when its
1686
+ // minParents matches; a changed criterion forces a full rescan.
1687
+ let after = 0;
1688
+ {
1689
+ const raw = this._dbGetMeta(AbstractStore.COMPACT_WATERMARK_KEY);
1690
+ if (raw !== null) {
1691
+ const sep = raw.indexOf(":");
1692
+ const mp = Number(raw.slice(0, sep));
1693
+ const wm = Number(raw.slice(sep + 1));
1694
+ if (mp === minParents && Number.isFinite(wm) && wm > 0) after = wm;
1695
+ }
1696
+ }
1697
+
1698
+ // The keep criterion "has edges or a halo" as ONE sorted id set (edge
1699
+ // sources ∪ edge targets ∪ halo rows), materialised by a single C-side
1700
+ // scan. A binary-search membership probe replaces the three per-entry
1701
+ // point queries that made this stage minutes long on a trained store.
1702
+ const targets = this._dbEdgeOrHaloIds();
1703
+ const isTarget = (id: NodeId): boolean => {
1704
+ let lo = 0, hi = targets.length - 1;
1705
+ while (lo <= hi) {
1706
+ const mid = (lo + hi) >> 1;
1707
+ const v = targets[mid];
1708
+ if (v === id) return true;
1709
+ if (v < id) lo = mid + 1;
1710
+ else hi = mid - 1;
1711
+ }
1712
+ return false;
1713
+ };
1714
+
1715
+ // Collect ids to remove: nodes that are structurally isolated (few
1716
+ // parents), not edge-bearing, and not halo-bearing. These nodes are
1717
+ // unique to one experience tree — they bridge nothing and their index
1718
+ // slots are wasted. Multi-parent nodes are the structural bridges
1719
+ // that let a partial query climb from one experience to another.
1720
+ const toRemove: NodeId[] = [];
1721
+ let scanned = 0;
1722
+ let watermark = after;
1723
+ for (const { ext: id, internal } of this._vecContentEntriesSince(after)) {
1724
+ // Yield a real event-loop turn on a fixed cadence: this scan runs over
1725
+ // every unexamined indexed entry, and point probes alone pin the
1726
+ // thread otherwise (the "frozen" symptom).
1727
+ if (++scanned % 8192 === 0) await yieldToEventLoop();
1728
+ watermark = internal;
1729
+ if (isTarget(id)) continue;
1730
+ if (this._dbGetParentsFirst(id, minParents).length >= minParents) {
1731
+ continue;
1732
+ }
1733
+ toRemove.push(id);
1734
+ }
1735
+
1736
+ // Delete in batches — each batch is ONE vector-store transaction (one WAL
1737
+ // commit), with an event-loop turn between batches.
1738
+ const BATCH = 1000;
1739
+ for (let i = 0; i < toRemove.length; i += BATCH) {
1740
+ const batch = toRemove.slice(i, i + BATCH);
1741
+ this._vecContentDeleteMany(batch);
1742
+ // Purge the "already indexed" cache for removed ids: without this, a
1743
+ // node that LATER becomes a resonance target again (gains an edge or
1744
+ // halo) hits the stale cache entry in indexGist and is silently never
1745
+ // re-indexed for the rest of the session.
1746
+ for (const id of batch) this._indexedIds.delete(id);
1747
+ await yieldToEventLoop();
1748
+ }
1749
+ // Persist the scan watermark so the next pass starts where this one
1750
+ // ended (kept beside the criterion that produced it).
1751
+ this._dbBeginTx();
1752
+ this._dbSetMeta(
1753
+ AbstractStore.COMPACT_WATERMARK_KEY,
1754
+ minParents + ":" + watermark,
1755
+ );
1756
+ this._dbCommitTx();
1757
+
1758
+ if (toRemove.length > 0) {
1759
+ // Compact to physically reclaim space from tombstones.
1760
+ try {
1761
+ if (this._vecContentPhysicalSize() > this._vecContentSize() * 1.5) {
1762
+ this._vecContentCompact();
1763
+ // Physical compaction may free the top internal id for reuse —
1764
+ // drop the watermark so the next pass rescans from the start.
1765
+ this._dbBeginTx();
1766
+ this._dbDeleteMeta(AbstractStore.COMPACT_WATERMARK_KEY);
1767
+ this._dbCommitTx();
1768
+ }
1769
+ } catch (e) {
1770
+ // Best-effort, but never SILENT: a persistently failing compaction
1771
+ // lets tombstones accumulate and query cost grow with no signal.
1772
+ this.compactFailures++;
1773
+ if (this.compactFailures === 1) {
1774
+ console.warn(
1775
+ "sema: content-index compaction failed (will keep " +
1776
+ "counting in store.compactFailures):",
1777
+ e,
1778
+ );
1779
+ }
1780
+ }
1781
+ // Index mutations (delete, compact) invalidate the ANN read cache.
1782
+ this._resonateCache = null;
1783
+ }
1784
+
1785
+ return toRemove.length;
1786
+ }
1787
+
1788
+ /** {@link Store.repairContentIndex} */
1789
+ async repairContentIndex(
1790
+ regenerateGist: (id: NodeId) => Promise<Vec | null>,
1791
+ minParents = 2,
1792
+ ): Promise<number> {
1793
+ await this._ensureReady();
1794
+ this.flush(); // commit any pending writes first
1795
+
1796
+ if (this._nextId === 0) return 0;
1797
+
1798
+ let added = 0;
1799
+
1800
+ // A repairable node MUST carry edges or a halo, so the candidate set IS
1801
+ // the edge/halo id set — corpus-of-experiences-sized (hundreds of
1802
+ // thousands), not node-count-sized (tens of millions). The old walk
1803
+ // visited EVERY branch id ever minted with a parent probe each; driving
1804
+ // from the target set visits only real candidates, in the same ascending
1805
+ // id order (the set is sorted), so the result and its ordering are
1806
+ // identical. Byte leaves (negative ids) were never visited by the old
1807
+ // walk and are skipped here too.
1808
+ const targets = this._dbEdgeOrHaloIds();
1809
+ let scanned = 0;
1810
+ for (const id of targets) {
1811
+ // Yield a real event-loop turn on a fixed cadence so the scan never
1812
+ // pins the thread for its whole duration.
1813
+ if (++scanned % 8192 === 0) await yieldToEventLoop();
1814
+
1815
+ if (id < 0) continue; // byte leaves: implicit, never repaired
1816
+
1817
+ // Already indexed in memory — skip.
1818
+ if (this._indexedIds.has(id)) continue;
1819
+
1820
+ // Must be a structural bridge: ≥ minParents parents in the DAG.
1821
+ // One LIMITed probe per candidate.
1822
+ if (this._dbGetParentsFirst(id, minParents).length < minParents) {
1823
+ continue;
1824
+ }
1825
+
1826
+ // Already durably indexed by a previous session — record and skip.
1827
+ // Probed AFTER the structural filters: candidates are few, so this
1828
+ // point query runs rarely instead of once per node in the store.
1829
+ if (this._vecContentHas(id)) {
1830
+ this._indexedIds.set(id, true);
1831
+ continue;
1832
+ }
1833
+
1834
+ // Regenerate the gist from bytes. The callback is async (the Mind's
1835
+ // perception is synchronous, but the interface allows a disk-backed
1836
+ // regenerator that yields).
1837
+ const gist = await regenerateGist(id);
1838
+ if (!gist) continue;
1839
+
1840
+ // Index it — same code path as indexGist, but the vector is injected
1841
+ // directly rather than read from the (empty) pending-gist cache.
1842
+ this._indexedIds.set(id, true);
1843
+ this._contentBuffer.push({
1844
+ id,
1845
+ vector: gist,
1846
+ ef: this.efConstructionInterior,
1847
+ });
1848
+ this._bufferedIds.add(id);
1849
+ // Repaired nodes are reach-indexed, never dedup targets: their gist is
1850
+ // regenerated (may differ numerically from the original) and they are
1851
+ // interiors, not deposit roots.
1852
+ added++;
1853
+
1854
+ // Flush periodically to bound the write-transaction size and yield to
1855
+ // the event loop, same cadence compactContentIndex uses.
1856
+ if (added % 1000 === 0) await this.maybeFlush();
1857
+ }
1858
+
1859
+ // Final flush for the last partial batch.
1860
+ if (added > 0) await this.maybeFlush();
1861
+
1862
+ return added;
1863
+ }
1864
+
1865
+ // ── Continuation edges ─────────────────────────────────────────────────
1866
+
1867
+ async link(from: NodeId, to: NodeId): Promise<void> {
1868
+ await this._ensureReady();
1869
+ // Both endpoints become learnt EXPERIENCES whose whole subtree is REACH-
1870
+ // indexed, because the seat is symmetric — a query may name only a PORTION of
1871
+ // either side and must still resonate to its interior and climb to the whole.
1872
+ // Only each ROOT is a MERGE target; the interiors are reach-only.
1873
+ this.indexSubtree(from);
1874
+ this.indexSubtree(to);
1875
+ // Flush the vectors indexSubtree just added to the buffer.
1876
+ // This keeps the buffer bounded and yields to the event loop.
1877
+ await this.maybeFlush();
1878
+ this._dbBeginTx();
1879
+ // Keep the document count exact as it grows: a source gaining its FIRST
1880
+ // edge is one new learnt context. One indexed point probe, and only once
1881
+ // the count has been materialised — before that, the lazy full count in
1882
+ // {@link edgeSourceCount} will see this edge anyway.
1883
+ if (this._edgeSrcCount >= 0 && !this._dbEdgeSrcExists(from)) {
1884
+ this._edgeSrcCount++;
1885
+ }
1886
+ // An edge breaks the transparency of both endpoints — drop cached chains
1887
+ // (same reasoning as the kid-row invalidation in put).
1888
+ this._chainMemo.clear();
1889
+ this._dbInsertEdge(from, to);
1890
+ }
1891
+
1892
+ next(id: NodeId): NodeId[] {
1893
+ return this._dbGetNextEdges(id);
1894
+ }
1895
+
1896
+ /** {@link Store.hasNext} — one indexed point probe, never a range read. */
1897
+ hasNext(id: NodeId): boolean {
1898
+ return this._dbEdgeSrcExists(id);
1899
+ }
1900
+
1901
+ prev(id: NodeId): NodeId[] {
1902
+ return this._dbGetPrevEdges(id);
1903
+ }
1904
+
1905
+ nextFirst(id: NodeId, limit: number): NodeId[] {
1906
+ return this._dbGetNextEdgesFirst(id, limit);
1907
+ }
1908
+
1909
+ prevFirst(id: NodeId, limit: number): NodeId[] {
1910
+ return this._dbGetPrevEdgesFirst(id, limit);
1911
+ }
1912
+
1913
+ /** {@link Store.prevCount}. Subclasses with an indexed reverse-edge count
1914
+ * should override; this default materialises (correct, not optimal). */
1915
+ prevCount(id: NodeId): number {
1916
+ return this._dbGetPrevEdges(id).length;
1917
+ }
1918
+
1919
+ edgeSourceCount(): number {
1920
+ // Materialised once per session (edges written before this moment are
1921
+ // covered by the full count), then kept exact incrementally by link().
1922
+ // The old form re-ran a full COUNT(*) table scan on EVERY call just to
1923
+ // detect staleness — O(edges) on the recall hot path, at every IDF read.
1924
+ if (this._edgeSrcCount < 0) {
1925
+ this._edgeSrcCount = this._dbEdgeDistinctSrcCount();
1926
+ }
1927
+ return this._edgeSrcCount;
1928
+ }
1929
+
1930
+ // ── Halos ──────────────────────────────────────────────────────────────
1931
+
1932
+ haloMass(id: NodeId): number {
1933
+ const r = this._dbGetHalo(id);
1934
+ return r ? r.mass : 0;
1935
+ }
1936
+
1937
+ halo(id: NodeId): Vec | null {
1938
+ const cached = this._haloNorm.get(id);
1939
+ if (cached !== undefined) return copy(cached);
1940
+ const r = this._dbGetHalo(id);
1941
+ if (!r || r.mass < this.minHaloMass) return null;
1942
+ const exact = this._haloExact.get(id);
1943
+ const v = normalize(exact ? copy(exact) : haloDecode(r.vec, this.D));
1944
+ this._haloNorm.set(id, v);
1945
+ return copy(v);
1946
+ }
1947
+
1948
+ /** {@link Store.hasHalo} — MUST mirror {@link halo}'s null condition
1949
+ * exactly (row present AND mass ≥ minHaloMass), minus the decode. */
1950
+ hasHalo(id: NodeId): boolean {
1951
+ const r = this._dbGetHalo(id);
1952
+ return r !== null && r.mass >= this.minHaloMass;
1953
+ }
1954
+
1955
+ async pourHalo(id: NodeId, add: Vec): Promise<void> {
1956
+ await this._ensureReady();
1957
+ // A node with a halo is a genuine resonance target — the consensus climb
1958
+ // resonates a query region to it, and articulation reads its halo.
1959
+ this.indexGist(id, true);
1960
+ const r = this._dbGetHalo(id);
1961
+ const acc = this._haloExact.get(id) ??
1962
+ (r ? haloDecode(r.vec, this.D) : new Float32Array(this.D));
1963
+ addInto(acc, add);
1964
+ this._haloExact.set(id, acc);
1965
+ this._haloNorm.delete(id); // the normalized read cache is now stale
1966
+ const mass = (r?.mass ?? 0) + 1;
1967
+ this._dbBeginTx();
1968
+ this._dbUpsertHalo(id, haloEncode(acc), mass);
1969
+
1970
+ // Re-index on a geometric schedule only (the exact halo is persisted).
1971
+ if (mass >= this.minHaloMass && geometricMass(mass)) {
1972
+ this._haloBuffer.set(id, normalize(copy(acc)));
1973
+ await this.maybeFlush();
1974
+ }
1975
+ }
1976
+
1977
+ async resonateHalo(v: Vec, k: number): Promise<Hit[]> {
1978
+ await this._ensureReady();
1979
+ this.flushHalos();
1980
+ if (k <= 0) return [];
1981
+
1982
+ // ANN read cache — same scheme as resonate's, but for the halo index.
1983
+ const rk = vecKey(v) + ":" + k;
1984
+ const cache = this._resonateHaloCache;
1985
+ if (cache) {
1986
+ const hit = cache.get(rk);
1987
+ if (hit !== undefined) return hit;
1988
+ }
1989
+
1990
+ const results = this._vecHaloQuery(
1991
+ normalize(copy(v)),
1992
+ k * this.overfetch,
1993
+ this.efSearch,
1994
+ );
1995
+ const out: Hit[] = [];
1996
+ for (const r of results) {
1997
+ const id = r.id as NodeId;
1998
+ out.push({ id, score: 1 - r.distance });
1999
+ if (out.length >= k) break;
2000
+ }
2001
+
2002
+ const rhc = this._resonateHaloCache ??= new Map();
2003
+ if (rhc.size >= RESONATE_CACHE_MAX) rhc.clear();
2004
+ rhc.set(rk, out);
2005
+ return out;
2006
+ }
2007
+
2008
+ // ── Buffering, flushing, compaction ────────────────────────────────────
2009
+
2010
+ private pending(): number {
2011
+ return this._contentBuffer.length + this._haloBuffer.size;
2012
+ }
2013
+
2014
+ protected flushContent(): number {
2015
+ if (this._contentBuffer.length === 0) return 0;
2016
+ const batch = this._contentBuffer.splice(0);
2017
+ // The merge scan only consults UNFLUSHED candidates, so clear them in
2018
+ // lockstep with the content buffer they mirror.
2019
+ this._nearDedupBuf.clear();
2020
+ this._bufferedIds.clear();
2021
+ this._vecContentUpsert(batch);
2022
+ this._resonateCache = null;
2023
+ return batch.length;
2024
+ }
2025
+
2026
+ protected flushHalos(): number {
2027
+ if (this._haloBuffer.size === 0) return 0;
2028
+ const batch = [...this._haloBuffer.entries()];
2029
+ this._haloBuffer.clear();
2030
+ this._vecHaloUpsert(
2031
+ batch.map(([id, vector]) => ({ id, vector })),
2032
+ );
2033
+ this._resonateHaloCache = null;
2034
+ return batch.length;
2035
+ }
2036
+
2037
+ /** Append the buffered containment pairs, inside the deferred transaction.
2038
+ * Pure appends: durable dedup lives in the adapter (the pair PK), so a
2039
+ * flush never reads a child's stored list back — the old packed-blob
2040
+ * read-merge-rewrite was O(fan-in) per touched child per flush, quadratic
2041
+ * over a long training run on a hot window. */
2042
+ protected flushContain(): void {
2043
+ if (this._containBuf.size === 0) return;
2044
+ this._dbBeginTx();
2045
+ for (const [child, set] of this._containBuf) {
2046
+ this._dbAppendContain(child, [...set]);
2047
+ }
2048
+ this._containBuf.clear();
2049
+ }
2050
+
2051
+ /** Flush all three buffers; compact vector indices on a write-volume cadence. */
2052
+ protected flush(): void {
2053
+ this.flushContain();
2054
+ const written = this.flushContent() + this.flushHalos();
2055
+ // Commit the deferred write transaction on the same cadence as the vector
2056
+ // buffers, so node rows / edges / halos become durable in coalesced batches.
2057
+ this._dbCommitTx();
2058
+ if (written === 0) return;
2059
+ this._writtenSinceCompact += written;
2060
+ if (this._writtenSinceCompact >= this.compactEveryNWrites) {
2061
+ this._writtenSinceCompact = 0;
2062
+ try {
2063
+ if (this._vecContentPhysicalSize() > this._vecContentSize() * 2) {
2064
+ this._vecContentCompact();
2065
+ // Physical compaction may free the top internal id for reuse —
2066
+ // invalidate the incremental maintenance watermark.
2067
+ this._dbBeginTx();
2068
+ this._dbDeleteMeta(AbstractStore.COMPACT_WATERMARK_KEY);
2069
+ this._dbCommitTx();
2070
+ }
2071
+ if (this._vecHaloPhysicalSize() > this._vecHaloSize() * 2) {
2072
+ this._vecHaloCompact();
2073
+ }
2074
+ } catch (e) {
2075
+ // Best-effort, but never SILENT (same contract as the prune-path
2076
+ // compaction above): a persistently failing compaction lets
2077
+ // tombstones accumulate and query cost grow with no signal.
2078
+ this.compactFailures++;
2079
+ if (this.compactFailures === 1) {
2080
+ console.warn(
2081
+ "sema: vector-index compaction failed (will keep " +
2082
+ "counting in store.compactFailures):",
2083
+ e,
2084
+ );
2085
+ }
2086
+ }
2087
+ }
2088
+ }
2089
+
2090
+ protected async maybeFlush(): Promise<void> {
2091
+ if (this.pending() >= this.batchSize) {
2092
+ this.flush();
2093
+ // A flush is the one HEAVY, fully-synchronous unit of the ingest path.
2094
+ // Every `await` elsewhere in ingestion resolves as a MICROTASK, which the
2095
+ // engine drains to empty before it ever services a MACROTASK — so a deposit
2096
+ // burst that only micro-awaits pins the single thread for its whole
2097
+ // duration, starving timers, pending I/O and any overlapped work. Here —
2098
+ // buffers empty, transaction committed, nothing mid-write — is the one
2099
+ // safe point to hand the event loop a real turn.
2100
+ await yieldToEventLoop();
2101
+ }
2102
+ }
2103
+
2104
+ // ── Meta ───────────────────────────────────────────────────────────────
2105
+
2106
+ async setMeta(key: string, val: string): Promise<void> {
2107
+ await this._ensureReady();
2108
+ this._dbBeginTx();
2109
+ this._dbSetMeta(key, val);
2110
+ }
2111
+
2112
+ async getMeta(key: string): Promise<string | null> {
2113
+ await this._ensureReady();
2114
+ return this._dbGetMeta(key);
2115
+ }
2116
+
2117
+ async deleteMeta(key: string): Promise<void> {
2118
+ await this._ensureReady();
2119
+ this._dbBeginTx();
2120
+ this._dbDeleteMeta(key);
2121
+ }
2122
+
2123
+ // ── Snapshot ───────────────────────────────────────────────────────────
2124
+
2125
+ async saveSnapshot(bytes: Uint8Array): Promise<void> {
2126
+ await this._ensureReady();
2127
+ this.flush(); // commits any open write transaction + vector buffers
2128
+ this._dbSaveSnapshot(bytes);
2129
+ }
2130
+
2131
+ async loadSnapshot(): Promise<Uint8Array | null> {
2132
+ await this._ensureReady();
2133
+ return this._dbLoadSnapshot();
2134
+ }
2135
+
2136
+ // ── Lifecycle ──────────────────────────────────────────────────────────
2137
+
2138
+ commit(): void {
2139
+ this.flush();
2140
+ }
2141
+
2142
+ async close(): Promise<void> {
2143
+ if (this.closed) return;
2144
+ this.closed = true;
2145
+ this.flush(); // commits any open write transaction + vector buffers
2146
+ this._dbClose();
2147
+ }
2148
+ }