@hviana/sema 0.1.4 → 0.1.6

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 (58) hide show
  1. package/AGENTS.md +6 -5
  2. package/CITATION.cff +49 -0
  3. package/HOW_IT_WORKS.md +11 -12
  4. package/README.md +7 -5
  5. package/dist/example/train_base.js +13 -10
  6. package/dist/src/alu/src/index.js +1 -1
  7. package/dist/src/config.d.ts +16 -19
  8. package/dist/src/config.js +3 -8
  9. package/dist/src/index.d.ts +2 -2
  10. package/dist/src/index.js +2 -6
  11. package/dist/src/rabitq-ivf/src/database.d.ts +113 -0
  12. package/dist/src/rabitq-ivf/src/database.js +201 -0
  13. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.d.ts +2 -5
  14. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.js +1 -3
  15. package/dist/src/rabitq-ivf/src/ivf.d.ts +200 -0
  16. package/dist/src/rabitq-ivf/src/ivf.js +1165 -0
  17. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.d.ts +1 -1
  18. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.js +1 -1
  19. package/dist/src/store-sqlite.d.ts +26 -1
  20. package/dist/src/store-sqlite.js +190 -8
  21. package/dist/src/store.d.ts +2 -9
  22. package/dist/src/store.js +6 -23
  23. package/example/train_base.ts +13 -10
  24. package/package.json +2 -2
  25. package/src/alu/README.md +1 -1
  26. package/src/alu/src/index.ts +1 -1
  27. package/src/config.ts +19 -27
  28. package/src/index.ts +6 -11
  29. package/src/rabitq-ivf/README.md +56 -0
  30. package/src/rabitq-ivf/src/database.ts +276 -0
  31. package/src/{rabitq-hnsw → rabitq-ivf}/src/index.ts +2 -5
  32. package/src/rabitq-ivf/src/ivf.ts +1330 -0
  33. package/src/{rabitq-hnsw → rabitq-ivf}/src/prng.ts +1 -1
  34. package/src/store-sqlite.ts +196 -9
  35. package/src/store.ts +8 -32
  36. package/test/08-storage.test.mjs +3 -3
  37. package/test/14-scaling.test.mjs +2 -2
  38. package/test/35-ivf.test.mjs +263 -0
  39. package/test/36-bloom.test.mjs +123 -0
  40. package/dist/src/rabitq-hnsw/src/database.d.ts +0 -200
  41. package/dist/src/rabitq-hnsw/src/database.js +0 -388
  42. package/dist/src/rabitq-hnsw/src/heap.d.ts +0 -22
  43. package/dist/src/rabitq-hnsw/src/heap.js +0 -89
  44. package/dist/src/rabitq-hnsw/src/hnsw.d.ts +0 -125
  45. package/dist/src/rabitq-hnsw/src/hnsw.js +0 -474
  46. package/dist/src/rabitq-hnsw/src/store.d.ts +0 -162
  47. package/dist/src/rabitq-hnsw/src/store.js +0 -825
  48. package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +0 -1
  49. package/dist/src/rabitq-hnsw/test/hnsw.test.js +0 -948
  50. package/src/rabitq-hnsw/README.md +0 -303
  51. package/src/rabitq-hnsw/src/database.ts +0 -492
  52. package/src/rabitq-hnsw/src/heap.ts +0 -90
  53. package/src/rabitq-hnsw/src/hnsw.ts +0 -514
  54. package/src/rabitq-hnsw/src/store.ts +0 -994
  55. package/src/rabitq-hnsw/test/hnsw.test.ts +0 -1213
  56. /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.d.ts +0 -0
  57. /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.js +0 -0
  58. /package/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.ts +0 -0
@@ -1,994 +0,0 @@
1
- import { DatabaseSync, StatementSync } from "node:sqlite";
2
-
3
- /**
4
- * The on-disk structure of the index, in SQLite. This is NOT a serialization of
5
- * an in-memory graph: it IS the graph. Every code and every adjacency list lives
6
- * in these tables and is read/written on demand, so process memory stays flat as
7
- * the collection grows -- only the working set of the current operation, plus
8
- * SQLite's fixed-size page cache, is ever resident.
9
- *
10
- * Tables
11
- * meta one row of configuration + mutable global state (entry point, counts,
12
- * level-RNG state). The quantizer's rotation is fully determined by
13
- * {dim, rounds, seed, centroid}, so reopening a database reproduces it
14
- * exactly without storing any matrix.
15
- * nodes one row per inserted vector: id (rowid), external id, top level,
16
- * tombstone flag, and the 1-bit code as a BLOB. Indexed by rowid (code
17
- * lookup) and by a partial unique index on the external id (live nodes).
18
- * links one row per (node, layer) holding that node's neighbour ids packed as
19
- * a BLOB. WITHOUT ROWID makes (node, layer) the clustering key, so a
20
- * neighbour-list fetch -- the hot path of search -- is a single B-tree
21
- * descent landing on the inline BLOB.
22
- */
23
-
24
- export interface StoreConfig {
25
- dim: number;
26
- m: number;
27
- efConstruction: number;
28
- efSearch: number;
29
- queryBits: number;
30
- rotationRounds: number;
31
- seed: number;
32
- centroid: Float64Array | null;
33
- codeWords: number;
34
- paddedDim: number;
35
- }
36
-
37
- export interface GlobalState {
38
- entryPoint: number;
39
- maxLevel: number;
40
- live: number;
41
- total: number;
42
- rng: number;
43
- }
44
-
45
- /** A node as the graph algorithms need it: code bytes, tombstone, external id. */
46
- export interface NodeRec {
47
- code: Uint8Array;
48
- deleted: number;
49
- ext: number | null;
50
- }
51
-
52
- const NODES = (name: string) => `
53
- CREATE TABLE ${name} (
54
- id INTEGER PRIMARY KEY,
55
- ext INTEGER,
56
- level INTEGER NOT NULL,
57
- deleted INTEGER NOT NULL DEFAULT 0,
58
- code BLOB NOT NULL
59
- )`;
60
-
61
- const LINKS = (name: string) => `
62
- CREATE TABLE ${name} (
63
- node INTEGER NOT NULL,
64
- layer INTEGER NOT NULL,
65
- nbrs BLOB NOT NULL,
66
- PRIMARY KEY (node, layer)
67
- ) WITHOUT ROWID`;
68
-
69
- const EXT_INDEX =
70
- "CREATE UNIQUE INDEX idx_nodes_ext ON nodes(ext) WHERE ext IS NOT NULL";
71
-
72
- /** Direct-mapped, typed-array-backed node cache: `slot = id % capacity`, a
73
- * colliding insert overwrites. No per-entry heap objects, no recency
74
- * bookkeeping — see the cache commentary in {@link Store}. */
75
- class CodeSlab {
76
- private readonly keys: Float64Array; // node id per slot; -1 = empty
77
- private readonly ext: Float64Array; // NaN = null ext
78
- private readonly deleted: Uint8Array;
79
- private readonly codes: Uint8Array; // capacity × codeBytes, flat
80
- readonly capacity: number;
81
-
82
- constructor(capacity: number, private readonly codeBytes: number) {
83
- this.capacity = capacity;
84
- this.keys = new Float64Array(capacity).fill(-1);
85
- this.ext = new Float64Array(capacity);
86
- this.deleted = new Uint8Array(capacity);
87
- this.codes = new Uint8Array(capacity * codeBytes);
88
- }
89
-
90
- /** The cached record, or undefined. The returned NodeRec is a fresh small
91
- * object whose code is a read-only VIEW into the slab (callers never
92
- * mutate codes; an overwrite of this slot only happens on a later insert,
93
- * after the view's one-operation lifetime). */
94
- get(id: number): NodeRec | undefined {
95
- const slot = id % this.capacity;
96
- if (this.keys[slot] !== id) return undefined;
97
- const e = this.ext[slot];
98
- return {
99
- code: this.codes.subarray(
100
- slot * this.codeBytes,
101
- (slot + 1) * this.codeBytes,
102
- ),
103
- deleted: this.deleted[slot],
104
- ext: Number.isNaN(e) ? null : e,
105
- };
106
- }
107
-
108
- has(id: number): boolean {
109
- return this.keys[id % this.capacity] === id;
110
- }
111
-
112
- set(id: number, code: Uint8Array, deleted: number, ext: number | null) {
113
- if (code.length !== this.codeBytes) return; // foreign geometry — skip
114
- const slot = id % this.capacity;
115
- this.keys[slot] = id;
116
- this.ext[slot] = ext === null ? NaN : ext;
117
- this.deleted[slot] = deleted;
118
- this.codes.set(code, slot * this.codeBytes);
119
- }
120
-
121
- /** Mark a cached id tombstoned in place (mirrors the row update). */
122
- markDeleted(id: number): void {
123
- const slot = id % this.capacity;
124
- if (this.keys[slot] === id) {
125
- this.deleted[slot] = 1;
126
- this.ext[slot] = NaN;
127
- }
128
- }
129
-
130
- clear(): void {
131
- this.keys.fill(-1);
132
- }
133
- }
134
-
135
- /** Direct-mapped cache of decoded neighbour lists, same design as
136
- * {@link CodeSlab}: `slot = (node·64+layer) % capacity`, fixed-width rows,
137
- * a colliding insert overwrites, nothing heap-allocated per entry. */
138
- class NbrSlab {
139
- private readonly keys: Float64Array; // node·64+layer per slot; -1 = empty
140
- private readonly counts: Uint8Array; // 0xff = cached "no list" (null)
141
- private readonly data: Uint32Array; // capacity × width, flat
142
- readonly capacity: number;
143
-
144
- constructor(capacity: number, private readonly width: number) {
145
- this.capacity = capacity;
146
- this.keys = new Float64Array(capacity).fill(-1);
147
- this.counts = new Uint8Array(capacity);
148
- this.data = new Uint32Array(capacity * width);
149
- }
150
-
151
- /** The cached list (a fresh copy — safe across later overwrites), null for
152
- * a cached "no list here", undefined on a miss. */
153
- get(key: number): Uint32Array | null | undefined {
154
- const slot = key % this.capacity;
155
- if (this.keys[slot] !== key) return undefined;
156
- const n = this.counts[slot];
157
- if (n === 0xff) return null;
158
- const base = slot * this.width;
159
- return this.data.slice(base, base + n);
160
- }
161
-
162
- set(key: number, ids: Uint32Array | number[] | null): void {
163
- if (ids !== null && ids.length > this.width) return; // oversize — skip
164
- const slot = key % this.capacity;
165
- this.keys[slot] = key;
166
- if (ids === null) {
167
- this.counts[slot] = 0xff;
168
- return;
169
- }
170
- this.counts[slot] = ids.length;
171
- const base = slot * this.width;
172
- for (let i = 0; i < ids.length; i++) this.data[base + i] = ids[i];
173
- }
174
-
175
- delete(key: number): void {
176
- const slot = key % this.capacity;
177
- if (this.keys[slot] === key) this.keys[slot] = -1;
178
- }
179
-
180
- clear(): void {
181
- this.keys.fill(-1);
182
- }
183
- }
184
-
185
- export class Store {
186
- readonly db: DatabaseSync;
187
-
188
- /**
189
- * Number of row reads served from the database (node and neighbour-list
190
- * fetches). This counts *storage* accesses only, so it is unaffected by any
191
- * caching layer above it -- the honest measure of how the engine scales.
192
- */
193
- reads = 0;
194
-
195
- private sInsNode!: StatementSync;
196
- private sNode!: StatementSync;
197
- private sIdByExt!: StatementSync;
198
- private sTombstone!: StatementSync;
199
- private sNbrs!: StatementSync;
200
- private sSetNbrs!: StatementSync;
201
- private sState!: StatementSync;
202
-
203
- // A node's 1-bit code is IMMUTABLE once written, yet building and searching the
204
- // graph re-read the same hub nodes constantly: an insert touches ~ef distinct
205
- // nodes, and across consecutive inserts (especially clustered data) those sets
206
- // overlap heavily, so the same code is fetched again and again. Each fetch is a
207
- // SQLite point-query that decodes the row and copies the BLOB, while the
208
- // distance it feeds is ~0.04µs. So getNode, not arithmetic, dominates a build.
209
- //
210
- // A DIRECT-MAPPED SLAB CACHE removes it. Earlier revisions used a Map-based
211
- // LRU of NodeRec objects; measured at trained-store scale (2.4M+ cached
212
- // entries) the Map itself became the bottleneck — per-op hash cost, the
213
- // delete+set recency churn, eviction bookkeeping, and millions of long-lived
214
- // heap objects for the GC to trace. Filling the cache made ingest SLOWER
215
- // than leaving it cold. The slab holds everything in a handful of typed
216
- // arrays (keys, ext, deleted flags, and one flat code slab), slot = id mod
217
- // capacity: a lookup is one modulo and one key compare; an insert on a
218
- // colliding slot simply overwrites it (dense monotone ids make modulo a
219
- // uniform spread, so collisions stay rare while occupancy < capacity); there
220
- // is NO recency bookkeeping and NOTHING for the GC to trace. Two slabs:
221
- // the MAIN one, and an UPPER-LAYER one for level ≥ 1 nodes — the ~12% of
222
- // the collection every operation's descent touches — so layer-0 fan-out
223
- // traffic can never evict the descent's working set however far the
224
- // collection outgrows the budget.
225
- //
226
- // The honesty rules are unchanged from the LRU it replaces:
227
- // • A LATENCY layer only — `reads` (the scalability witness) counts
228
- // SQLite fall-throughs, so it is UNAFFECTED by the cache; the rabitq
229
- // suite asserts scaling with the cache off.
230
- // • BOUNDED BY THE SAME MEMORY BUDGET as the page cache: slot count is
231
- // derived from `cacheSizeMb` and the code size (no second knob), the
232
- // upper-layer slab takes at most half. cacheSizeMb=0 disables both.
233
- // • The only mutable fields (deleted/ext) are updated in place by
234
- // tombstone(); both slabs are dropped at compaction. A collision or a
235
- // miss only repeats work, never changes a result.
236
- private readonly cacheBudgetBytes: number;
237
- /** Whether the shared memory budget is non-zero — consumers that trade RAM
238
- * for speed (the graph's visited-tag array) key off this so the
239
- * `cacheSizeMb: 0` flat-memory mode stays exactly flat. */
240
- get cacheEnabled(): boolean {
241
- return this.cacheBudgetBytes > 0;
242
- }
243
- private slab: CodeSlab | null = null; // main section (level 0)
244
- private slabHot: CodeSlab | null = null; // upper-layer section (level ≥ 1)
245
- private nbrSlab: NbrSlab | null = null; // neighbour-list cache
246
- private slabsDerived = false; // false until the first cacheRec sizes them
247
-
248
- /**
249
- * @param dbPath path to the SQLite file (":memory:" for transient).
250
- * @param cacheSizeMb the ONE memory knob (MiB). It sizes BOTH SQLite's page
251
- * cache AND the immutable-code LRU above (whose entry-capacity is derived
252
- * from this budget and the code size — no second knob). Both are pure
253
- * speed enhancements: correctness and the per-operation storage-read
254
- * count do not depend on them. Pass 0 to run with essentially no cache,
255
- * which is how the tests run so a poor access pattern can never hide
256
- * behind a warm cache.
257
- */
258
- constructor(dbPath: string, cacheSizeMb = 64) {
259
- this.cacheBudgetBytes = Math.max(0, Math.floor(cacheSizeMb * 1024 * 1024));
260
- this.db = new DatabaseSync(dbPath);
261
- // WAL + NORMAL sync is crash-safe and fast; temp structures stay in memory.
262
- // The page cache is the single memory budget; mmap is tied to it so "off"
263
- // means every row read is an honest B-tree descent against the file.
264
- const kb = Math.max(0, Math.floor(cacheSizeMb * 1024));
265
- const cacheSize = kb > 0 ? `-${kb}` : "0";
266
- const mmap = kb > 0 ? cacheSizeMb * 1024 * 1024 : 0;
267
- // Rows here are one code (~dim/8 bytes) or one packed neighbour list —
268
- // small against SQLite's default 4 KiB page, which then carries mostly
269
- // slack. 1 KiB pages keep leaf-page fill high; must be set before the
270
- // first table is created (no-op on an existing database).
271
- this.db.exec("PRAGMA page_size = 1024;");
272
- this.db.exec(`
273
- PRAGMA journal_mode = WAL;
274
- PRAGMA synchronous = NORMAL;
275
- PRAGMA temp_store = MEMORY;
276
- PRAGMA cache_size = ${cacheSize};
277
- PRAGMA mmap_size = ${mmap};
278
- PRAGMA foreign_keys = OFF;
279
- `);
280
- this.db.exec(`
281
- CREATE TABLE IF NOT EXISTS meta (
282
- id INTEGER PRIMARY KEY CHECK (id = 0),
283
- dim INTEGER NOT NULL,
284
- m INTEGER NOT NULL,
285
- ef_construction INTEGER NOT NULL,
286
- ef_search INTEGER NOT NULL,
287
- query_bits INTEGER NOT NULL,
288
- rotation_rounds INTEGER NOT NULL,
289
- seed INTEGER NOT NULL,
290
- centroid BLOB,
291
- code_words INTEGER NOT NULL,
292
- padded_dim INTEGER NOT NULL,
293
- entry_point INTEGER NOT NULL DEFAULT -1,
294
- max_level INTEGER NOT NULL DEFAULT -1,
295
- live INTEGER NOT NULL DEFAULT 0,
296
- total INTEGER NOT NULL DEFAULT 0,
297
- rng INTEGER NOT NULL DEFAULT 0
298
- );
299
- `);
300
- if (!this.tableExists("nodes")) {
301
- this.db.exec(NODES("nodes"));
302
- this.db.exec(EXT_INDEX);
303
- this.db.exec(LINKS("links"));
304
- }
305
- this.prepareAll();
306
- }
307
-
308
- private tableExists(name: string): boolean {
309
- const row = this.db
310
- .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
311
- .get(name);
312
- return row !== undefined;
313
- }
314
-
315
- /** (Re)compile the hot statements against the current schema. */
316
- private prepareAll(): void {
317
- this.batchStmts.clear();
318
- // Recompiled statements mean a table swap (compaction) — every cached
319
- // decoded list may now describe a dropped table's rows.
320
- this.nbrSlab?.clear();
321
- this.sInsNode = this.db.prepare(
322
- "INSERT INTO nodes(ext, level, code) VALUES (?, ?, ?)",
323
- );
324
- this.sNode = this.db.prepare(
325
- "SELECT ext, level, deleted, code FROM nodes WHERE id = ?",
326
- );
327
- // Positional rows: the hot read path pays for a plain array instead of a
328
- // per-row object with named properties.
329
- this.sNode.setReturnArrays(true);
330
- this.sIdByExt = this.db.prepare("SELECT id FROM nodes WHERE ext = ?");
331
- this.sTombstone = this.db.prepare(
332
- "UPDATE nodes SET deleted = 1, ext = NULL WHERE id = ?",
333
- );
334
- this.sNbrs = this.db.prepare(
335
- "SELECT nbrs FROM links WHERE node = ? AND layer = ?",
336
- );
337
- this.sNbrs.setReturnArrays(true);
338
- this.sSetNbrs = this.db.prepare(
339
- "INSERT INTO links(node, layer, nbrs) VALUES (?, ?, ?) " +
340
- "ON CONFLICT(node, layer) DO UPDATE SET nbrs = excluded.nbrs",
341
- );
342
- this.sState = this.db.prepare(
343
- "UPDATE meta SET entry_point = ?, max_level = ?, live = ?, total = ?, rng = ? WHERE id = 0",
344
- );
345
- }
346
-
347
- // ----------------------------- config / state ----------------------------
348
-
349
- /** Load the persisted configuration, or null for a fresh database. */
350
- loadConfig(): StoreConfig | null {
351
- const row = this.db.prepare("SELECT * FROM meta WHERE id = 0").get() as
352
- | Record<string, unknown>
353
- | undefined;
354
- if (!row) return null;
355
- const cb = row.centroid as Uint8Array | null;
356
- let centroid: Float64Array | null = null;
357
- if (cb) {
358
- centroid = new Float64Array(row.dim as number);
359
- new Uint8Array(centroid.buffer).set(cb);
360
- }
361
- return {
362
- dim: row.dim as number,
363
- m: row.m as number,
364
- efConstruction: row.ef_construction as number,
365
- efSearch: row.ef_search as number,
366
- queryBits: row.query_bits as number,
367
- rotationRounds: row.rotation_rounds as number,
368
- seed: row.seed as number,
369
- centroid,
370
- codeWords: row.code_words as number,
371
- paddedDim: row.padded_dim as number,
372
- };
373
- }
374
-
375
- /** Initialise the meta row for a new database. */
376
- initConfig(c: StoreConfig): void {
377
- const centroidBlob = c.centroid
378
- ? new Uint8Array(
379
- c.centroid.buffer,
380
- c.centroid.byteOffset,
381
- c.centroid.byteLength,
382
- )
383
- : null;
384
- this.db
385
- .prepare(
386
- "INSERT INTO meta(id, dim, m, ef_construction, ef_search, query_bits, rotation_rounds, " +
387
- "seed, centroid, code_words, padded_dim, rng) VALUES (0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
388
- )
389
- .run(
390
- c.dim,
391
- c.m,
392
- c.efConstruction,
393
- c.efSearch,
394
- c.queryBits,
395
- c.rotationRounds,
396
- c.seed,
397
- centroidBlob,
398
- c.codeWords,
399
- c.paddedDim,
400
- c.seed >>> 0,
401
- );
402
- }
403
-
404
- loadState(): GlobalState {
405
- const row = this.db
406
- .prepare(
407
- "SELECT entry_point, max_level, live, total, rng FROM meta WHERE id = 0",
408
- )
409
- .get() as Record<string, number>;
410
- return {
411
- entryPoint: row.entry_point,
412
- maxLevel: row.max_level,
413
- live: row.live,
414
- total: row.total,
415
- rng: row.rng >>> 0,
416
- };
417
- }
418
-
419
- saveState(s: GlobalState): void {
420
- this.sState.run(s.entryPoint, s.maxLevel, s.live, s.total, s.rng | 0);
421
- }
422
-
423
- setEfSearch(ef: number): void {
424
- this.db.prepare("UPDATE meta SET ef_search = ? WHERE id = 0").run(ef);
425
- }
426
-
427
- setQueryBits(bits: number): void {
428
- this.db.prepare("UPDATE meta SET query_bits = ? WHERE id = 0").run(bits);
429
- }
430
-
431
- resetReads(): void {
432
- this.reads = 0;
433
- }
434
-
435
- /** Stream every live external id in id order, in bounded-memory batches. */
436
- *liveExts(batch = 1024): IterableIterator<number> {
437
- const q = this.db.prepare(
438
- "SELECT id, ext FROM nodes WHERE deleted = 0 AND id > ? ORDER BY id LIMIT ?",
439
- );
440
- let cursor = 0;
441
- for (;;) {
442
- const rows = q.all(cursor, batch) as Array<{ id: number; ext: number }>;
443
- if (rows.length === 0) break;
444
- for (const r of rows) yield r.ext;
445
- cursor = rows[rows.length - 1].id;
446
- }
447
- }
448
-
449
- // -------------------------------- nodes -----------------------------------
450
-
451
- /** Size the two slabs from the shared memory budget and the actual code
452
- * size (known on the first record cached): total slots = budget / bytes
453
- * per slot, the upper-layer slab taking half. A slot is the code bytes
454
- * plus 17 B of typed-array bookkeeping (key + ext + deleted). */
455
- private deriveSlabs(codeBytes: number): void {
456
- this.slabsDerived = true;
457
- if (this.cacheBudgetBytes === 0) return; // flat-memory mode — no slabs
458
- const perSlot = codeBytes + 17;
459
- const total = Math.max(2, Math.floor(this.cacheBudgetBytes / perSlot));
460
- this.slabHot = new CodeSlab(total >> 1, codeBytes);
461
- this.slab = new CodeSlab(total - (total >> 1), codeBytes);
462
- // One-quarter of the budget for neighbour lists (neighbour ids).
463
- // A layer-0 list is ~4·Mmax0 entries; we budget 4·16 ids per row.
464
- this.nbrSlab = new NbrSlab(Math.max(1, total >> 2), 64);
465
- }
466
-
467
- /** Record a node in the slab cache. Upper-layer nodes (level ≥ 1) go to
468
- * the pinned section layer-0 traffic never touches; a colliding slot is
469
- * simply overwritten (direct-mapped eviction — no bookkeeping). */
470
- private cacheRec(
471
- id: number,
472
- code: Uint8Array,
473
- deleted: number,
474
- ext: number | null,
475
- level: number,
476
- ): void {
477
- if (!this.slabsDerived) this.deriveSlabs(code.byteLength);
478
- const slab = level >= 1 ? this.slabHot : this.slab;
479
- slab?.set(id, code, deleted, ext);
480
- }
481
-
482
- /** Insert a node, returning its assigned id (rowid). */
483
- addNode(ext: number | null, level: number, code: Uint8Array): number {
484
- const info = this.sInsNode.run(ext, level, code);
485
- const id = Number(info.lastInsertRowid);
486
- // The new node is read back immediately and repeatedly while it is wired in;
487
- // seed its (immutable) code now so those reads hit the cache, not SQLite.
488
- this.cacheRec(id, code, 0, ext, level);
489
- return id;
490
- }
491
-
492
- /** Fetch a node's code (copied), tombstone flag and external id, or null.
493
- * Served from the slab cache when present; a miss reads SQLite, counts
494
- * one `reads` (the cache-independent scalability witness), and caches the
495
- * result. The cache is a latency layer ONLY — `reads` does not count hits, so
496
- * the index's storage-read scaling is identical with the cache off. */
497
- getNode(id: number): NodeRec | null {
498
- // Pinned upper-layer section first, then the main slab.
499
- const cached = this.slabHot?.get(id) ?? this.slab?.get(id);
500
- if (cached !== undefined) return cached;
501
- this.reads++;
502
- // Positional row: [ext, level, deleted, code]. node:sqlite hands back an
503
- // OWNED Uint8Array per BLOB read (verified: mutating one read never shows
504
- // in another), so the code is kept as returned — no defensive copy.
505
- const row = this.sNode.get(id) as
506
- | [number | null, number, number, Uint8Array]
507
- | undefined;
508
- if (!row) return null;
509
- this.cacheRec(id, row[3], row[2], row[0], row[1]);
510
- return { code: row[3], deleted: row[2], ext: row[0] };
511
- }
512
-
513
- idByExt(ext: number): number | null {
514
- const row = this.sIdByExt.get(ext) as { id: number } | undefined;
515
- return row ? row.id : null;
516
- }
517
-
518
- // Batched code fetch. An insert's search layer expands a popped node's
519
- // whole neighbour list at once, and each cache-missing neighbour used to be
520
- // its own point query — the statement dispatch (not the B-tree descent)
521
- // dominated a trained-store build at ~50% of total CPU. One IN(...) query
522
- // per expansion fetches all misses together; the per-row `reads` accounting
523
- // is unchanged, so the cache-independent scalability witness is identical.
524
- private readonly batchStmts = new Map<number, StatementSync>();
525
- private static readonly BATCH_MAX = 64;
526
-
527
- private batchStmt(n: number): StatementSync {
528
- let s = this.batchStmts.get(n);
529
- if (s === undefined) {
530
- s = this.db.prepare(
531
- "SELECT id, ext, level, deleted, code FROM nodes WHERE id IN (" +
532
- "?,".repeat(n - 1) + "?)",
533
- );
534
- s.setReturnArrays(true);
535
- this.batchStmts.set(n, s);
536
- }
537
- return s;
538
- }
539
-
540
- /** Fetch several nodes into `out` (skipping ids already present). Serves
541
- * from the code LRU first; the misses are read with one IN query per
542
- * chunk. Ids with no row are simply absent from `out`. Each id that
543
- * reaches SQLite counts one `reads`, exactly like a getNode miss. */
544
- getNodesInto(
545
- ids: number[],
546
- out: Map<number, NodeRec>,
547
- count = ids.length,
548
- ): void {
549
- let miss: number[] | null = null;
550
- for (let i = 0; i < count; i++) {
551
- const id = ids[i];
552
- if (out.has(id)) continue;
553
- const cached = this.slabHot?.get(id) ?? this.slab?.get(id);
554
- if (cached !== undefined) {
555
- out.set(id, cached);
556
- continue;
557
- }
558
- (miss ??= []).push(id);
559
- }
560
- if (miss === null) return;
561
- for (let o = 0; o < miss.length; o += Store.BATCH_MAX) {
562
- const chunk = miss.slice(o, o + Store.BATCH_MAX);
563
- this.reads += chunk.length;
564
- // Positional rows: [id, ext, level, deleted, code]; the code BLOB is an
565
- // owned buffer (see getNode), kept without a defensive copy.
566
- const rows = this.batchStmt(chunk.length).all(
567
- ...chunk,
568
- ) as unknown as Array<
569
- [number, number | null, number, number, Uint8Array]
570
- >;
571
- for (const row of rows) {
572
- this.cacheRec(row[0], row[4], row[3], row[1], row[2]);
573
- out.set(row[0], { code: row[4], deleted: row[3], ext: row[1] });
574
- }
575
- }
576
- }
577
-
578
- /** Pre-fill the code and neighbour-list caches with ONE sequential table
579
- * scan each, up to their existing budget-derived caps. A cold session
580
- * otherwise warms the caches through hundreds of thousands of RANDOM point
581
- * reads spread across its first minutes of inserts/queries; a sequential
582
- * scan streams the same rows at C speed in seconds. A pure latency
583
- * optimisation with the exact same caps and coherence rules as demand
584
- * filling — nothing about results, `reads` discipline, or memory ceilings
585
- * changes. Two passes over `nodes` so the upper-layer pinned section is
586
- * filled before layer-0 rows compete for the LRU. Returns rows warmed. */
587
- warmCache(): number {
588
- if (this.cacheBudgetBytes === 0) return 0;
589
- let warmed = 0;
590
- // One scan over the node rows; cacheRec routes each to its slab (the
591
- // sections are direct-mapped, so there is no fill-order competition).
592
- {
593
- const scan = this.db.prepare(
594
- "SELECT id, ext, level, deleted, code FROM nodes",
595
- );
596
- scan.setReturnArrays(true);
597
- for (
598
- const row of scan.iterate() as IterableIterator<
599
- [number, number | null, number, number, Uint8Array]
600
- >
601
- ) {
602
- this.cacheRec(row[0], row[4], row[3], row[1], row[2]);
603
- warmed++;
604
- }
605
- }
606
- // Neighbour lists: one pass; nbrSlab is direct-mapped so fill order
607
- // doesn't matter (later entries simply overwrite earlier collisions).
608
- {
609
- const scan = this.db.prepare("SELECT node, layer, nbrs FROM links");
610
- scan.setReturnArrays(true);
611
- for (
612
- const row of scan.iterate() as IterableIterator<
613
- [number, number, Uint8Array]
614
- >
615
- ) {
616
- if (row[1] >= 64) continue;
617
- this.nbrSlab?.set(
618
- row[0] * 64 + row[1],
619
- Uint32Array.from(this.decodeNbrs(row[2])),
620
- );
621
- warmed++;
622
- }
623
- }
624
- return warmed;
625
- }
626
-
627
- tombstone(id: number): void {
628
- this.sTombstone.run(id);
629
- // Keep any cached record coherent with the row: a later hit must show the
630
- // same deleted/ext a fresh SQLite read would (search routes through but never
631
- // returns tombstoned nodes).
632
- this.slab?.markDeleted(id);
633
- this.slabHot?.markDeleted(id);
634
- }
635
-
636
- // -------------------------------- links -----------------------------------
637
-
638
- // Neighbour lists are SETS to the graph algorithms: search visits every
639
- // member and pruning recomputes distances from the codes, so storage order
640
- // carries no information. That freedom pays for compression: the list is
641
- // stored sorted as delta-VARINTS — consecutive graph ids are small deltas, so
642
- // a neighbour costs ~1–2 bytes instead of a fixed 4, and the cost scales with
643
- // log(collection) instead of a fixed word width. Links are written once per
644
- // wiring update and read on every hop, and decoding is a linear byte scan —
645
- // noise against the row fetch it rides on.
646
-
647
- // DECODED NEIGHBOUR-LIST CACHE. A direct-mapped NbrSlab (same design as
648
- // the code slab). Budget: one quarter of the shared cacheSizeMb, sized in
649
- // deriveSlabs alongside the code slabs. Each list is a fresh copy from the
650
- // slab, so callers are safe across later overwrites. `setNeighbors` writes
651
- // through, so a hit always equals a fresh row read.
652
-
653
- /** A node's neighbour ids at a layer, or null if it has no list there. */
654
- getNeighbors(node: number, layer: number): Uint32Array | null {
655
- if (layer < 64) {
656
- const hit = this.nbrSlab?.get(node * 64 + layer);
657
- if (hit !== undefined) return hit;
658
- }
659
- this.reads++;
660
- const row = this.sNbrs.get(node, layer) as [Uint8Array] | undefined;
661
- if (!row) {
662
- this.nbrSlab?.set(node * 64 + layer, null);
663
- return null;
664
- }
665
- const blob = row[0];
666
- // Entry count = terminator bytes (high bit clear): one cheap scan sizes
667
- // the result exactly, so the hot path pays ONE allocation instead of a
668
- // growable number[] plus a Uint32Array copy per graph hop.
669
- let count = 0;
670
- for (let i = 0; i < blob.length; i++) {
671
- if ((blob[i] & 0x80) === 0) count++;
672
- }
673
- const ids = new Uint32Array(count);
674
- let acc = 0, shift = 0, prev = 0, n = 0;
675
- for (let i = 0; i < blob.length; i++) {
676
- const b = blob[i];
677
- acc |= (b & 0x7f) << shift;
678
- if (b & 0x80) {
679
- shift += 7;
680
- } else {
681
- prev += acc;
682
- ids[n++] = prev;
683
- acc = 0;
684
- shift = 0;
685
- }
686
- }
687
- this.nbrSlab?.set(node * 64 + layer, ids);
688
- return ids;
689
- }
690
-
691
- setNeighbors(node: number, layer: number, ids: number[]): void {
692
- const s = ids.slice().sort((a, b) => a - b);
693
- const buf = new Uint8Array(s.length * 5);
694
- let o = 0, prev = 0;
695
- for (let i = 0; i < s.length; i++) {
696
- let d = s[i] - prev;
697
- prev = s[i];
698
- while (d >= 0x80) {
699
- buf[o++] = (d & 0x7f) | 0x80;
700
- d >>>= 7;
701
- }
702
- buf[o++] = d;
703
- }
704
- this.sSetNbrs.run(node, layer, buf.subarray(0, o));
705
- // Write through the decoded-list cache: a later hit must equal what a
706
- // fresh row read would decode (same sorted order).
707
- // Write through the decoded-list cache: a later hit must equal what a
708
- // fresh row read would decode (same sorted order).
709
- if (layer < 64) this.nbrSlab?.set(node * 64 + layer, Uint32Array.from(s));
710
- }
711
-
712
- // ----------------------------- transactions -------------------------------
713
-
714
- begin(): void {
715
- this.db.exec("BEGIN");
716
- }
717
- commit(): void {
718
- this.db.exec("COMMIT");
719
- }
720
- rollback(): void {
721
- this.db.exec("ROLLBACK");
722
- }
723
-
724
- // ------------------------------- compaction -------------------------------
725
- // spliceCompact() reclaims tombstones by a GRAPH-PRESERVING structural copy:
726
- // live node rows are copied verbatim (SAME internal ids — nothing external
727
- // ever remaps), and each live neighbour list is rewritten with its dead
728
- // members replaced by those members' own live neighbours (a 1-hop bridge, the
729
- // standard tombstone splice). Codes are untouched — the index stays exactly
730
- // as lossy as RaBitQ made it, never more — and the graph keeps the wiring the
731
- // original inserts built, minus the dead routing. The previous compact
732
- // replayed every live code through a full HNSW insert: O(live · ef · log N)
733
- // storage reads — HOURS on a multi-million-node trained store, inside ONE
734
- // transaction whose WAL grew by the whole rebuild. The splice is one
735
- // streaming pass over the node and link rows (batched commits, bounded WAL).
736
-
737
- /** Encode a neighbour id list to the delta-varint blob format. */
738
- private encodeNbrs(ids: number[]): Uint8Array {
739
- const s = ids.slice().sort((a, b) => a - b);
740
- const buf = new Uint8Array(s.length * 5);
741
- let o = 0, prev = 0;
742
- for (let i = 0; i < s.length; i++) {
743
- let d = s[i] - prev;
744
- prev = s[i];
745
- while (d >= 0x80) {
746
- buf[o++] = (d & 0x7f) | 0x80;
747
- d >>>= 7;
748
- }
749
- buf[o++] = d;
750
- }
751
- return buf.subarray(0, o);
752
- }
753
-
754
- /** Decode a delta-varint neighbour blob into a number[]. */
755
- private decodeNbrs(blob: Uint8Array): number[] {
756
- const ids: number[] = [];
757
- let acc = 0, shift = 0, prev = 0;
758
- for (let i = 0; i < blob.length; i++) {
759
- const b = blob[i];
760
- acc |= (b & 0x7f) << shift;
761
- if (b & 0x80) {
762
- shift += 7;
763
- } else {
764
- prev += acc;
765
- ids.push(prev);
766
- acc = 0;
767
- shift = 0;
768
- }
769
- }
770
- return ids;
771
- }
772
-
773
- /** Tombstone-splice compaction. `M`/`Mmax0` cap a rewritten list on upper
774
- * layers / layer 0. Returns the new global state scalars (internal ids are
775
- * preserved, so `entry` survives unless it was itself dead). The caller
776
- * persists state and vacuums. */
777
- spliceCompact(
778
- M: number,
779
- Mmax0: number,
780
- entry: number,
781
- ): { entry: number; maxLevel: number; live: number } {
782
- const db = this.db;
783
- this.slab?.clear();
784
- this.slabHot?.clear();
785
- this.nbrSlab?.clear();
786
-
787
- // Fresh target tables (leftovers from an interrupted compact are dropped).
788
- db.exec(`
789
- DROP TABLE IF EXISTS nodes_new;
790
- DROP TABLE IF EXISTS links_new;
791
- ${NODES("nodes_new")};
792
- ${LINKS("links_new")};
793
- `);
794
-
795
- // Dead internal ids, sorted, for O(log n) membership. A neighbour id
796
- // always references an existing row, so "not dead" ⇔ live.
797
- const deadCount = (db.prepare(
798
- "SELECT COUNT(*) AS n FROM nodes WHERE deleted = 1",
799
- ).get() as { n: number }).n;
800
- const dead = new Float64Array(deadCount);
801
- {
802
- let i = 0;
803
- for (
804
- const row of db.prepare(
805
- "SELECT id FROM nodes WHERE deleted = 1 ORDER BY id",
806
- ).iterate() as IterableIterator<{ id: number }>
807
- ) dead[i++] = row.id;
808
- }
809
- const isDead = (id: number): boolean => {
810
- let lo = 0, hi = deadCount - 1;
811
- while (lo <= hi) {
812
- const mid = (lo + hi) >> 1;
813
- const v = dead[mid];
814
- if (v === id) return true;
815
- if (v < id) lo = mid + 1;
816
- else hi = mid - 1;
817
- }
818
- return false;
819
- };
820
-
821
- // 1. Copy live node rows verbatim (same ids), in batched transactions so
822
- // the WAL stays bounded by the batch, not the table.
823
- const copy = db.prepare(
824
- "INSERT INTO nodes_new (id, ext, level, deleted, code) " +
825
- "SELECT id, ext, level, 0, code FROM nodes " +
826
- "WHERE deleted = 0 AND id > ? ORDER BY id LIMIT ?",
827
- );
828
- const maxCopied = db.prepare("SELECT MAX(id) AS m FROM nodes_new");
829
- const COPY_BATCH = 50_000;
830
- let cursor = 0;
831
- for (;;) {
832
- db.exec("BEGIN");
833
- const n = Number(copy.run(cursor, COPY_BATCH).changes);
834
- db.exec("COMMIT");
835
- if (n === 0) break;
836
- cursor = (maxCopied.get() as { m: number | null }).m ?? cursor;
837
- if (n < COPY_BATCH) break;
838
- }
839
-
840
- // 2. Rewrite live neighbour lists. A list with no dead member is copied
841
- // blob-verbatim (the common case). A dead member is replaced by its own
842
- // live neighbours at the same layer (1-hop splice), appended after the
843
- // surviving originals in ascending-id order and truncated at the layer
844
- // cap — deterministic, and the originals (chosen by the build heuristic)
845
- // always take precedence.
846
- const scan = db.prepare(
847
- "SELECT node, layer, nbrs FROM links " +
848
- "WHERE node > ? OR (node = ? AND layer > ?) " +
849
- "ORDER BY node, layer LIMIT ?",
850
- );
851
- const insNew = db.prepare(
852
- "INSERT INTO links_new (node, layer, nbrs) VALUES (?, ?, ?)",
853
- );
854
- // Small cache of dead nodes' lists — a dead hub is spliced by many of its
855
- // former neighbours in a row.
856
- const deadLists = new Map<string, number[]>();
857
- const DEAD_CACHE_MAX = 65_536;
858
- const deadListOf = (id: number, layer: number): number[] => {
859
- const key = id + ":" + layer;
860
- const hit = deadLists.get(key);
861
- if (hit !== undefined) return hit;
862
- const row = this.sNbrs.get(id, layer) as [Uint8Array] | undefined;
863
- const ids = row ? this.decodeNbrs(row[0]) : [];
864
- if (deadLists.size >= DEAD_CACHE_MAX) deadLists.clear();
865
- deadLists.set(key, ids);
866
- return ids;
867
- };
868
-
869
- const SCAN_BATCH = 20_000;
870
- let curNode = -1, curLayer = -1;
871
- for (;;) {
872
- const rows = scan.all(curNode, curNode, curLayer, SCAN_BATCH) as Array<
873
- { node: number; layer: number; nbrs: Uint8Array }
874
- >;
875
- if (rows.length === 0) break;
876
- db.exec("BEGIN");
877
- for (const row of rows) {
878
- curNode = row.node;
879
- curLayer = row.layer;
880
- if (isDead(row.node)) continue; // dead nodes leave no list behind
881
- const ids = this.decodeNbrs(row.nbrs);
882
- let hasDead = false;
883
- for (let i = 0; i < ids.length; i++) {
884
- if (isDead(ids[i])) {
885
- hasDead = true;
886
- break;
887
- }
888
- }
889
- if (!hasDead) {
890
- insNew.run(row.node, row.layer, row.nbrs);
891
- continue;
892
- }
893
- const cap = row.layer === 0 ? Mmax0 : M;
894
- const keep: number[] = [];
895
- const seen = new Set<number>([row.node]);
896
- const deadHere: number[] = [];
897
- for (const id of ids) {
898
- if (isDead(id)) deadHere.push(id);
899
- else if (!seen.has(id)) {
900
- seen.add(id);
901
- keep.push(id);
902
- }
903
- }
904
- if (keep.length < cap) {
905
- // Bridge candidates: the dead members' own live neighbours, in
906
- // ascending id order for determinism.
907
- const bridge: number[] = [];
908
- for (const d of deadHere) {
909
- for (const n of deadListOf(d, row.layer)) {
910
- if (!isDead(n) && !seen.has(n)) {
911
- seen.add(n);
912
- bridge.push(n);
913
- }
914
- }
915
- }
916
- bridge.sort((a, b) => a - b);
917
- for (const b of bridge) {
918
- if (keep.length >= cap) break;
919
- keep.push(b);
920
- }
921
- }
922
- if (keep.length > 0) {
923
- insNew.run(row.node, row.layer, this.encodeNbrs(keep));
924
- }
925
- }
926
- db.exec("COMMIT");
927
- if (rows.length < SCAN_BATCH) break;
928
- }
929
-
930
- // 3. Atomic swap: the real tables flip to the compacted copies in one
931
- // transaction (the partial-index name frees up when the old table
932
- // drops, so it is recreated on the renamed table here).
933
- db.exec(`
934
- BEGIN;
935
- DROP TABLE nodes;
936
- DROP TABLE links;
937
- ALTER TABLE nodes_new RENAME TO nodes;
938
- ALTER TABLE links_new RENAME TO links;
939
- ${EXT_INDEX};
940
- COMMIT;
941
- `);
942
- this.prepareAll();
943
-
944
- const live = (db.prepare("SELECT COUNT(*) AS n FROM nodes").get() as {
945
- n: number;
946
- }).n;
947
- let newEntry = entry;
948
- let maxLevel = -1;
949
- if (live === 0) {
950
- newEntry = -1;
951
- } else if (entry === -1 || isDead(entry)) {
952
- // The entry point died: promote the lowest-id node of the top level —
953
- // deterministic, corpus-determined.
954
- const top = db.prepare(
955
- "SELECT id, level FROM nodes ORDER BY level DESC, id ASC LIMIT 1",
956
- ).get() as { id: number; level: number };
957
- newEntry = top.id;
958
- maxLevel = top.level;
959
- } else {
960
- maxLevel = (db.prepare("SELECT level FROM nodes WHERE id = ?").get(
961
- entry,
962
- ) as { level: number }).level;
963
- }
964
- return { entry: newEntry, maxLevel, live };
965
- }
966
-
967
- /** Stream live external ids with internal id > `after`, in internal-id
968
- * order, in bounded batches. Internal ids are assigned monotonically and
969
- * PRESERVED by {@link spliceCompact}, so a caller can use the largest
970
- * internal id it has seen as a durable incremental watermark. */
971
- *liveExtsSince(
972
- after: number,
973
- batch = 1024,
974
- ): IterableIterator<{ ext: number; internal: number }> {
975
- const q = this.db.prepare(
976
- "SELECT id, ext FROM nodes WHERE deleted = 0 AND id > ? ORDER BY id LIMIT ?",
977
- );
978
- let cursor = after;
979
- for (;;) {
980
- const rows = q.all(cursor, batch) as Array<{ id: number; ext: number }>;
981
- if (rows.length === 0) break;
982
- for (const r of rows) yield { ext: r.ext, internal: r.id };
983
- cursor = rows[rows.length - 1].id;
984
- }
985
- }
986
-
987
- vacuum(): void {
988
- this.db.exec("VACUUM");
989
- }
990
-
991
- close(): void {
992
- this.db.close();
993
- }
994
- }