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