@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,948 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { RaBitQuantizer, VectorDatabase, } from "../src/index.js";
5
- // ----------------------------- tiny harness ----------------------------------
6
- let passed = 0;
7
- function assert(cond, msg) {
8
- if (!cond)
9
- throw new Error("ASSERTION FAILED: " + msg);
10
- passed++;
11
- }
12
- function approxEqual(a, b, eps = 1e-4) {
13
- return Math.abs(a - b) <= eps * (1 + Math.abs(a) + Math.abs(b));
14
- }
15
- function section(title) {
16
- console.log("\n" + "=".repeat(72) + "\n" + title + "\n" + "=".repeat(72));
17
- }
18
- function now() {
19
- return typeof performance !== "undefined" ? performance.now() : Date.now();
20
- }
21
- // ------------------------------- seeded RNG ----------------------------------
22
- class RNG {
23
- s;
24
- constructor(seed) {
25
- this.s = seed >>> 0 || 1;
26
- }
27
- next() {
28
- let t = (this.s = (this.s + 0x6d2b79f5) | 0);
29
- t = Math.imul(t ^ (t >>> 15), t | 1);
30
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
31
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
32
- }
33
- int(n) {
34
- return (this.next() * n) | 0;
35
- }
36
- _gaussSpare = 0;
37
- _gaussHasSpare = false;
38
- /** Box-Muller — caches the second value so every call produces one Gaussian
39
- * without wasting the log/sqrt pair. */
40
- gauss() {
41
- if (this._gaussHasSpare) {
42
- this._gaussHasSpare = false;
43
- return this._gaussSpare;
44
- }
45
- let u = 0;
46
- let v = 0;
47
- while (u === 0)
48
- u = this.next();
49
- while (v === 0)
50
- v = this.next();
51
- const r = Math.sqrt(-2 * Math.log(u));
52
- this._gaussSpare = r * Math.sin(2 * Math.PI * v);
53
- this._gaussHasSpare = true;
54
- return r * Math.cos(2 * Math.PI * v);
55
- }
56
- }
57
- function unit(v) {
58
- let s = 0;
59
- for (const x of v)
60
- s += x * x;
61
- const n = Math.sqrt(s) || 1;
62
- return v.map((x) => x / n);
63
- }
64
- // =============================================================================
65
- // 1) Vector CRUD + persistence (SQLite file round-trip)
66
- // =============================================================================
67
- function testCrudAndPersistence() {
68
- section("1) Vector CRUD + persistence (get returns the 1-bit code)");
69
- const DIM = 48; // paddedDim 64 -> codeWords 2, distinctive codes
70
- const dbPath = path.join(os.tmpdir(), `rabitq-test-crud-${Date.now()}.sqlite`);
71
- let db = new VectorDatabase({
72
- dbPath,
73
- dim: DIM,
74
- M: 8,
75
- efConstruction: 64,
76
- seed: 7,
77
- });
78
- // distinct directions so the 1-bit codes differ
79
- const mk = (i, scale = 1) => {
80
- const v = new Array(DIM).fill(0);
81
- v[i] = scale;
82
- return v;
83
- };
84
- // Integer IDs only
85
- const ID = { alpha: 1, bravo: 2, charlie: 3, delta: 4, echo: 5 };
86
- const vectors = {
87
- alpha: mk(0),
88
- bravo: mk(10),
89
- charlie: mk(20),
90
- delta: mk(30),
91
- echo: mk(45),
92
- };
93
- // CREATE
94
- for (const [name, v] of Object.entries(vectors)) {
95
- db.insert(ID[name], v);
96
- }
97
- assert(db.size === 5, "size should be 5 after inserting 5 vectors");
98
- let threw = false;
99
- try {
100
- db.insert(ID.alpha, vectors.alpha);
101
- }
102
- catch {
103
- threw = true;
104
- }
105
- assert(threw, "inserting a duplicate external id must throw");
106
- // READ — returns the bare 1-bit code (Uint32Array)
107
- const code = db.get(ID.alpha);
108
- assert(code !== null, "get(alpha) returns a stored code");
109
- assert(code instanceof Uint32Array && code.length === 2, "code is 2 packed 32-bit words");
110
- assert(code.length * 32 === 64, "code covers paddedDim = 64 sign bits");
111
- assert(db.get(9999) === null, "get of unknown id returns null");
112
- assert(db.has(ID.bravo) && !db.has(9999), "has() reflects membership");
113
- // SEARCH — querying with an exact copy returns that vector first
114
- assert(db.query(vectors.alpha, 3)[0].id === ID.alpha, "exact-copy query returns alpha first");
115
- assert(db.query(vectors.bravo, 1)[0].id === ID.bravo, "exact-copy query returns bravo first");
116
- // UPDATE
117
- db.update(ID.charlie, mk(40));
118
- assert(db.query(mk(40), 1)[0].id === ID.charlie, "updated charlie is found at its new location");
119
- assert(db.size === 5, "size unchanged after update");
120
- // DELETE
121
- assert(db.delete(ID.delta) === true, "delete returns true for existing id");
122
- assert(db.delete(ID.delta) === false, "delete returns false for already removed id");
123
- assert(!db.has(ID.delta) && db.size === 4, "delta removed, size now 4");
124
- assert(!db.query(vectors.delta, 4).some((r) => r.id === ID.delta), "deleted vector never appears in results");
125
- // PERSISTENCE round-trip — capture state, then close and reopen the same file
126
- const liveKeys = Array.from(db.keys());
127
- const probe = mk(0);
128
- const before = db.query(probe, 4);
129
- const codesBefore = new Map();
130
- for (const id of liveKeys)
131
- codesBefore.set(id, db.get(id));
132
- db.close();
133
- const reloaded = new VectorDatabase({ dbPath });
134
- assert(reloaded.size === db.size, "reloaded size matches");
135
- for (const id of liveKeys) {
136
- const a = codesBefore.get(id);
137
- const b = reloaded.get(id);
138
- assert(b !== null, `reloaded code for ${id} is not null`);
139
- assert(a.length === b.length, `reloaded code length for ${id} matches`);
140
- for (let i = 0; i < a.length; i++) {
141
- assert(a[i] === b[i], `reloaded code word for ${id} matches`);
142
- }
143
- }
144
- const after = reloaded.query(probe, 4);
145
- assert(before.length === after.length, "reloaded query result length matches");
146
- for (let i = 0; i < before.length; i++) {
147
- assert(before[i].id === after[i].id, "reloaded query ordering matches");
148
- assert(approxEqual(before[i].distance, after[i].distance, 1e-6), "reloaded query distance matches");
149
- }
150
- reloaded.close();
151
- // Cleanup
152
- fs.unlinkSync(dbPath);
153
- console.log(`CRUD + persistence OK (get returns ${code.length}-word codes; reload identical)`);
154
- }
155
- // =============================================================================
156
- // 1b) insert / update / query by an already-quantized 1-bit code
157
- // =============================================================================
158
- function testCodeIO() {
159
- section("1b) insert / update / query by a 1-bit code (length-detected)");
160
- const DIM = 128; // paddedDim 128 -> codeWords 4
161
- const rng = new RNG(909);
162
- const vec = () => Array.from({ length: DIM }, () => rng.gauss());
163
- const db = new VectorDatabase({
164
- dbPath: ":memory:",
165
- dim: DIM,
166
- M: 16,
167
- efConstruction: 100,
168
- seed: 5,
169
- });
170
- const N = 800;
171
- for (let i = 0; i < N; i++)
172
- db.insert(i, vec());
173
- const src = db.get(123);
174
- assert(src instanceof Uint32Array && src.length === 4, "get() yields a 4-word code");
175
- // QUERY by code: a stored code finds itself at distance 0
176
- const byCode = db.query(src, 5);
177
- assert(byCode[0].id === 123, "query(code) returns the owning vector first");
178
- assert(approxEqual(byCode[0].distance, 0, 1e-9), "query(code) distance to the same code is 0");
179
- // INSERT by code (length-detected): the copy is a real, findable node
180
- const copyId = N + 1;
181
- db.insert(copyId, src);
182
- assert(db.size === N + 1, "insert(code) adds a vector");
183
- const copyCode = db.get(copyId);
184
- for (let i = 0; i < src.length; i++) {
185
- assert(copyCode[i] === src[i], "insert(code) stored the code verbatim");
186
- }
187
- const hits = db.query(src, 5).map((h) => h.id);
188
- assert(hits.includes(copyId) && hits.includes(123), "both the original and its code-copy are retrievable");
189
- // number[] code (e.g. after conversion) is detected the same way
190
- const asArray = Array.from(src);
191
- const arrId = N + 2;
192
- db.insert(arrId, asArray);
193
- const arrCode = db.get(arrId);
194
- for (let i = 0; i < src.length; i++) {
195
- assert(arrCode[i] === src[i], "insert(number[] code) works identically");
196
- }
197
- // UPDATE by code: move id 7 onto id 200's code
198
- const target = db.get(200);
199
- db.update(7, target);
200
- const moved = db.get(7);
201
- for (let i = 0; i < target.length; i++) {
202
- assert(moved[i] === target[i], "update(code) replaced the code");
203
- }
204
- assert(db.size === N + 2, "update(code) keeps size (replace, not add)");
205
- const near200 = db.query(target, 5).map((h) => h.id);
206
- assert(near200.includes(7) && near200.includes(200), "updated-by-code id now sits with its target");
207
- db.close();
208
- console.log("insert / update / query by 1-bit code OK (Uint32Array and number[])");
209
- }
210
- // =============================================================================
211
- // 2) Cosine metric sanity
212
- // =============================================================================
213
- function testCosine() {
214
- section("2) Cosine sanity");
215
- const DIM = 64;
216
- const db = new VectorDatabase({ dbPath: ":memory:", dim: DIM, seed: 3 });
217
- const axis = (i) => {
218
- const v = new Array(DIM).fill(0);
219
- v[i] = 1;
220
- return v;
221
- };
222
- db.insert(1, axis(0)); // "x"
223
- db.insert(2, axis(20)); // "y"
224
- db.insert(3, axis(40)); // "z"
225
- const r = db.query(axis(0), 3); // query along x's direction
226
- assert(r[0].id === 1, "cosine: nearest direction to x's axis is x");
227
- assert(r[0].distance <= 0.1, "cosine distance to an identical direction is ~ 0");
228
- assert(r[r.length - 1].distance > r[0].distance, "orthogonal directions are farther than the aligned one");
229
- db.close();
230
- console.log("Cosine metric OK");
231
- }
232
- // =============================================================================
233
- // 2a) Near-duplicate self-recall (tight cluster)
234
- // =============================================================================
235
- // Every other recall test here uses WELL-SEPARATED groups, so the 1-bit codes
236
- // differ in many sign bits and recall looks perfect. Real workloads are not so
237
- // kind: vectors often form a TIGHT cluster — a shared base plus a small
238
- // distinctive residual — so each one sits a hair away from its neighbours on
239
- // the sphere. The contract that must hold there is the most basic one: a stored
240
- // vector, queried verbatim, retrieves ITSELF as the nearest neighbour.
241
- //
242
- // This is the case that exposed a precision bug in the query estimator. RaBitQ
243
- // scalar-quantises the full-precision query to `1 << queryBits` levels before
244
- // scoring it against the 1-bit codes. At 4 bits (16 levels) that step is too
245
- // coarse to resolve the small residual separating cluster siblings, so the
246
- // estimate misranks them and a vector can fail to find itself (~82% self-recall
247
- // on this data). The fix lives in rabitq.ts: the default queryBits is 8, which
248
- // resolves the residual and restores near-perfect self-recall — touching only
249
- // the query side (the stored 1-bit codes, disk size and graph build are
250
- // unchanged; only the per-query LUT widens from Uint8 to Uint16). The
251
- // assertions pin both ends: an explicit 4 bits is measurably degraded, the
252
- // default recovers.
253
- function testNearDuplicateRecall() {
254
- section("2a) Near-duplicate self-recall (tight cluster)");
255
- const DIM = 256;
256
- const N = 150;
257
- const SPREAD = 0.15;
258
- // A fixed base direction; each vector is the base plus a small per-coordinate
259
- // perturbation, then normalised — a dense cluster of near-duplicates. A plain
260
- // LCG drives the perturbation: it produces a uniformly spread cloud around the
261
- // base (the regime where the queryBits effect is sharpest); the Box-Muller
262
- // RNG above clusters too tightly to separate the two settings.
263
- const build = (queryBits) => {
264
- let s = 7 >>> 0;
265
- const next = () => {
266
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
267
- return s / 4294967296;
268
- };
269
- const base = new Array(DIM);
270
- for (let d = 0; d < DIM; d++)
271
- base[d] = next() * 2 - 1;
272
- const vecs = [];
273
- const db = new VectorDatabase({
274
- dbPath: ":memory:",
275
- dim: DIM,
276
- M: 16,
277
- efConstruction: 64,
278
- efSearch: 64,
279
- seed: 123,
280
- queryBits,
281
- });
282
- for (let i = 0; i < N; i++) {
283
- const v = new Array(DIM);
284
- let n = 0;
285
- for (let d = 0; d < DIM; d++) {
286
- v[d] = base[d] + SPREAD * (next() * 2 - 1);
287
- n += v[d] * v[d];
288
- }
289
- n = Math.sqrt(n) || 1;
290
- for (let d = 0; d < DIM; d++)
291
- v[d] /= n;
292
- vecs.push(v);
293
- db.insert(i, v);
294
- }
295
- let self = 0;
296
- for (let i = 0; i < N; i++) {
297
- const h = db.query(vecs[i], 1);
298
- if (h[0] && h[0].id === i)
299
- self++;
300
- }
301
- db.close();
302
- return self / N;
303
- };
304
- const r4 = build(4); // explicitly coarse — the old default
305
- const rDefault = build(8); // the library default after the fix
306
- console.log(`near-duplicate self-recall: queryBits=4 ${(r4 * 100).toFixed(1)}% ` +
307
- `default(8) ${(rDefault * 100).toFixed(1)}%`);
308
- // The bug: 4-bit query quantisation cannot resolve a tight cluster, so a
309
- // sizeable fraction of vectors fail to retrieve themselves.
310
- assert(r4 < 0.9, `queryBits=4 is degraded on a tight cluster (got ${(r4 * 100).toFixed(1)}%)`);
311
- // The fix: the default B_q restores near-perfect self-recall — same stored
312
- // codes, sharper query estimate.
313
- assert(rDefault >= 0.95, `default queryBits recovers self-recall on a tight cluster (got ${(rDefault * 100).toFixed(1)}%)`);
314
- assert(rDefault > r4, "the default queryBits must beat the coarse 4-bit setting");
315
- console.log("Near-duplicate self-recall OK (queryBits resolves the cluster)");
316
- }
317
- // =============================================================================
318
- // 2a-bis) The cosFactor bias — a known, deliberate approximation in estimate()
319
- // =============================================================================
320
- // FINDING (the deeper cause behind the near-duplicate misranking above).
321
- //
322
- // RaBitQuantizer.estimate() returns `1 - cosFactor * A`, where
323
- // cosFactor = sqrt(pi / (2 * paddedDim))
324
- // is a FIXED constant: the EXPECTED L1 norm of a randomly-rotated unit vector.
325
- // Canonical RaBitQ does not use that expectation — it divides A by each
326
- // vector's OWN rotated-L1 norm (the per-vector factor <o_bar, o>). Substituting
327
- // the population mean for the per-vector quantity makes the estimator BIASED,
328
- // and the bias is what this test pins down precisely.
329
- //
330
- // The cleanest probe is self-distance: a stored code scored against the query
331
- // it was made from must, for an unbiased cosine estimator, return distance 0
332
- // (cosine 1). With the fixed cosFactor it does NOT. Because each vector's true
333
- // rotated-L1 norm scatters around the mean, the estimate scatters around 0:
334
- // - it is non-zero (mean and max are both > 0 by a clear margin), and
335
- // - it goes NEGATIVE for a large fraction of vectors — a negative distance
336
- // means estimated cosine > 1, which is geometrically impossible and is the
337
- // unmistakable fingerprint of the mean-vs-per-vector substitution.
338
- //
339
- // This bias is small in magnitude but systematic, so near-threshold pairs read
340
- // as closer (or farther) than they truly are — exactly the misranking that
341
- // collapses a tight cluster. The test documents the property as-is (it does NOT
342
- // assert the bias away): the bias is REAL and lives in estimate(); raising
343
- // queryBits, tested above, is what keeps recall correct in its presence. Should
344
- // estimate() ever be upgraded to the true per-vector factor, the self-distance
345
- // here would tighten to ~0 and the negatives would vanish — at which point this
346
- // test is the tripwire that flags the behavioural change.
347
- function testCosFactorBias() {
348
- section("2a-bis) cosFactor self-distance bias (fixed mean vs per-vector norm)");
349
- const DIM = 256;
350
- const N = 300;
351
- const SPREAD = 0.15;
352
- let s = 7 >>> 0;
353
- const next = () => {
354
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
355
- return s / 4294967296;
356
- };
357
- const base = new Array(DIM);
358
- for (let d = 0; d < DIM; d++)
359
- base[d] = next() * 2 - 1;
360
- const q = new RaBitQuantizer(DIM, { seed: 123 });
361
- const vecs = [];
362
- const codes = [];
363
- for (let i = 0; i < N; i++) {
364
- const v = new Array(DIM);
365
- let n = 0;
366
- for (let d = 0; d < DIM; d++) {
367
- v[d] = base[d] + SPREAD * (next() * 2 - 1);
368
- n += v[d] * v[d];
369
- }
370
- n = Math.sqrt(n) || 1;
371
- for (let d = 0; d < DIM; d++)
372
- v[d] /= n;
373
- vecs.push(v);
374
- codes.push(q.codeToBytes(q.encode(v)));
375
- }
376
- // Self-distance: each vector scored against its OWN code. An unbiased cosine
377
- // estimator returns 0 here; the fixed cosFactor does not.
378
- let sum = 0;
379
- let max = 0;
380
- let negatives = 0;
381
- for (let i = 0; i < N; i++) {
382
- const ctx = q.prepareQuery(vecs[i]);
383
- const d = q.estimate(codes[i], 0, ctx);
384
- sum += d;
385
- if (d > max)
386
- max = d;
387
- if (d < 0)
388
- negatives++; // distance < 0 <=> estimated cosine > 1 (impossible)
389
- }
390
- const mean = sum / N;
391
- console.log(`self-distance under fixed cosFactor: mean ${mean.toFixed(5)}, ` +
392
- `max ${max.toFixed(5)}, negatives ${negatives}/${N} ` +
393
- `(an unbiased estimator would give 0 with 0 negatives)`);
394
- // The estimate is genuinely non-zero on a self-match: the fixed mean factor
395
- // does not reproduce the per-vector normalisation.
396
- assert(max > 0.005, `self-distance is biased away from 0 (max ${max.toFixed(5)})`);
397
- // The fingerprint: a large fraction of self-matches yield an IMPOSSIBLE
398
- // cosine > 1 (negative distance) — only a mean-vs-per-vector substitution
399
- // produces this. If this ever drops to 0, estimate() changed.
400
- assert(negatives > N / 10, `cosFactor produces impossible cosine>1 on self-matches ` +
401
- `(${negatives}/${N} negative distances)`);
402
- console.log("cosFactor bias documented (fixed mean L1 norm, not the per-vector factor)");
403
- }
404
- // =============================================================================
405
- // 2b) SQLite persistence round-trip
406
- // =============================================================================
407
- function testPersistence() {
408
- section("2b) SQLite persistence round-trip");
409
- const DIM = 96;
410
- const rng = new RNG(2024);
411
- const vec = () => Array.from({ length: DIM }, () => rng.gauss());
412
- {
413
- const dbPath = path.join(os.tmpdir(), `rabitq-test-persist-${Date.now()}.sqlite`);
414
- const db = new VectorDatabase({
415
- dbPath,
416
- dim: DIM,
417
- M: 12,
418
- efConstruction: 80,
419
- seed: 17,
420
- });
421
- for (let i = 0; i < 1500; i++)
422
- db.insert(i, vec());
423
- for (let i = 0; i < 150; i++)
424
- db.delete(i * 10); // tombstones
425
- for (let i = 0; i < 120; i++)
426
- db.update(3 + i * 10, vec()); // re-inserts
427
- // Capture state before closing
428
- const liveKeys = Array.from(db.keys());
429
- const codesBefore = new Map();
430
- for (const id of liveKeys)
431
- codesBefore.set(id, db.get(id));
432
- const queriesBefore = [];
433
- for (let q = 0; q < 40; q++) {
434
- const query = vec();
435
- queriesBefore.push({ query, results: db.query(query, 10) });
436
- }
437
- const sizeBefore = db.size;
438
- db.close();
439
- // Reopen from file
440
- const db2 = new VectorDatabase({ dbPath });
441
- assert(db2.size === sizeBefore, "size preserved across persistence round-trip");
442
- // get() parity: identical codes for every live id
443
- for (const id of liveKeys) {
444
- const a = codesBefore.get(id);
445
- const b = db2.get(id);
446
- assert(b !== null, `id ${id} present after reload`);
447
- assert(a.length === b.length, "code length identical after reload");
448
- for (let i = 0; i < a.length; i++) {
449
- assert(a[i] === b[i], "code identical after reload");
450
- }
451
- }
452
- // query parity: identical ids and (bit-exact) distances
453
- let maxDiff = 0;
454
- for (const { query, results: ra } of queriesBefore) {
455
- const rb = db2.query(query, 10);
456
- assert(ra.length === rb.length, "result length identical after reload");
457
- for (let i = 0; i < ra.length; i++) {
458
- assert(ra[i].id === rb[i].id, "result id/order identical after reload");
459
- maxDiff = Math.max(maxDiff, Math.abs(ra[i].distance - rb[i].distance));
460
- }
461
- }
462
- assert(maxDiff === 0, `distances are bit-exact after reload (max diff ${maxDiff})`);
463
- // Mutation still works after a reload
464
- db2.insert(99999, vec());
465
- assert(db2.has(99999), "insert works after persistence load");
466
- assert(db2.get(99999) !== null, "get works after persistence load");
467
- db2.close();
468
- fs.unlinkSync(dbPath);
469
- console.log(`${sizeBefore} vecs, round-trip via SQLite file — exact match`);
470
- }
471
- // Non-existent file: node:sqlite may create the file or may error — this is
472
- // implementation-specific. We just verify no crash.
473
- try {
474
- new VectorDatabase({ dbPath: "/nonexistent/path/db.sqlite" });
475
- }
476
- catch {
477
- // tolerated — either outcome is acceptable
478
- }
479
- console.log("Non-existent path handled gracefully");
480
- }
481
- // =============================================================================
482
- // 2c) compact() — lossless rebuild that reclaims tombstones
483
- // =============================================================================
484
- function testCompact() {
485
- section("2c) compact() — lossless rebuild reclaiming tombstones");
486
- const DIM = 80;
487
- const rng = new RNG(555);
488
- const vec = () => Array.from({ length: DIM }, () => rng.gauss());
489
- // ---- losslessness: build-then-delete-then-compact == fresh build of survivors ----
490
- const N = 2500;
491
- const data = [];
492
- for (let i = 0; i < N; i++)
493
- data.push(vec());
494
- const deleted = new Set();
495
- for (let i = 0; i < N; i += 3)
496
- deleted.add(i); // delete ~1/3
497
- const dbFull = new VectorDatabase({
498
- dbPath: ":memory:",
499
- dim: DIM,
500
- M: 16,
501
- efConstruction: 100,
502
- seed: 88,
503
- });
504
- for (let i = 0; i < N; i++)
505
- dbFull.insert(i, data[i]);
506
- for (const i of deleted)
507
- dbFull.delete(i);
508
- assert(dbFull.physicalSize === N, "before compact: tombstones still occupy physical slots");
509
- assert(dbFull.size === N - deleted.size, "before compact: live size already excludes deleted");
510
- dbFull.compact();
511
- const survivors = [];
512
- for (let i = 0; i < N; i++)
513
- if (!deleted.has(i))
514
- survivors.push(i);
515
- assert(dbFull.size === survivors.length, "after compact: size is the survivor count");
516
- assert(dbFull.physicalSize === survivors.length, "after compact: no tombstones remain (physical == live)");
517
- for (const i of survivors) {
518
- assert(dbFull.has(i), `survivor ${i} still present after compact`);
519
- }
520
- for (const i of deleted) {
521
- assert(!dbFull.has(i), `deleted ${i} absent after compact`);
522
- }
523
- // Fresh index built from the survivors, same order + seed.
524
- const dbFresh = new VectorDatabase({
525
- dbPath: ":memory:",
526
- dim: DIM,
527
- M: 16,
528
- efConstruction: 100,
529
- seed: 88,
530
- });
531
- for (const i of survivors)
532
- dbFresh.insert(i, data[i]);
533
- // HNSW is an approximate index — two builds with the same data+seed may
534
- // produce slightly different graphs (the heuristic is not order-invariant
535
- // under all edge cases). What matters is that recall between the two is
536
- // very high, and that distances are consistent.
537
- let compactHits = 0;
538
- let maxDistDiff = 0;
539
- let totalCmp = 0;
540
- for (let q = 0; q < 60; q++) {
541
- const query = vec();
542
- const a = dbFull.query(query, 10);
543
- const b = dbFresh.query(query, 10);
544
- const bIds = new Set(b.map((r) => r.id));
545
- for (const r of a)
546
- if (bIds.has(r.id))
547
- compactHits++;
548
- totalCmp += a.length;
549
- for (let i = 0; i < Math.min(a.length, b.length); i++) {
550
- maxDistDiff = Math.max(maxDistDiff, Math.abs(a[i].distance - b[i].distance));
551
- }
552
- }
553
- const recallCompact = compactHits / totalCmp;
554
- assert(recallCompact >= 0.97, `compacted recall vs fresh build must be high (got ${(recallCompact * 100).toFixed(1)}%)`);
555
- assert(maxDistDiff < 0.05, `distance agreement across the two builds (max diff ${maxDistDiff})`);
556
- console.log(`losslessness: ${N} built, ${deleted.size} deleted, compacted to ${survivors.length}; recall vs fresh=${(recallCompact * 100).toFixed(1)}%`);
557
- // ---- compact also handles updates, and recall stays high on separable data ----
558
- const G = 10;
559
- const groups = 400;
560
- const centers = [];
561
- for (let g = 0; g < groups; g++)
562
- centers.push(vec());
563
- const db2 = new VectorDatabase({
564
- dbPath: ":memory:",
565
- dim: DIM,
566
- M: 16,
567
- efConstruction: 100,
568
- seed: 7,
569
- });
570
- const live = new Map();
571
- let id = 0;
572
- for (let g = 0; g < groups; g++) {
573
- for (let j = 0; j < G; j++) {
574
- const v = centers[g].map((c) => c + 0.1 * rng.gauss());
575
- db2.insert(id, v);
576
- live.set(id, v);
577
- id++;
578
- }
579
- }
580
- for (let k = 0; k < 400; k++)
581
- db2.delete(k); // drop 40 whole groups
582
- for (let k = 0; k < 400; k++)
583
- live.delete(k);
584
- for (let k = 600; k < 800; k++) {
585
- const v = centers[Math.floor(k / G)].map((c) => c + 0.1 * rng.gauss());
586
- db2.update(k, v); // re-place within the same group
587
- live.set(k, v);
588
- }
589
- assert(db2.physicalSize > db2.size, "updates+deletes created tombstones");
590
- db2.compact();
591
- assert(db2.physicalSize === db2.size && db2.size === live.size, "compact reclaimed all tombstones");
592
- // recall@10 over the surviving set after compaction
593
- const liveIds = Array.from(live.keys());
594
- const liveVecs = Array.from(live.values());
595
- // Precompute data norms once (they don't change per query).
596
- const liveNorms = new Float64Array(liveVecs.length);
597
- for (let n = 0; n < liveVecs.length; n++) {
598
- let s = 0;
599
- const v = liveVecs[n];
600
- for (let d = 0; d < DIM; d++)
601
- s += v[d] * v[d];
602
- liveNorms[n] = Math.sqrt(s) || 1;
603
- }
604
- let hits = 0;
605
- const Q = 100;
606
- for (let q = 0; q < Q; q++) {
607
- const center = centers[Math.floor((600 + (q % 200)) / G)];
608
- const query = center.map((c) => c + 0.1 * rng.gauss());
609
- // Query norm computed once, not N times.
610
- let qn = 0;
611
- for (let d = 0; d < DIM; d++)
612
- qn += query[d] * query[d];
613
- qn = Math.sqrt(qn) || 1;
614
- const bd = new Float64Array(10).fill(Infinity);
615
- const bi = new Int32Array(10).fill(-1);
616
- for (let n = 0; n < liveVecs.length; n++) {
617
- const v = liveVecs[n];
618
- let dot = 0;
619
- for (let d = 0; d < DIM; d++)
620
- dot += v[d] * query[d];
621
- const s = 1 - dot / (liveNorms[n] * qn);
622
- if (s < bd[9]) {
623
- let p = 9;
624
- while (p > 0 && bd[p - 1] > s) {
625
- bd[p] = bd[p - 1];
626
- bi[p] = bi[p - 1];
627
- p--;
628
- }
629
- bd[p] = s;
630
- bi[p] = liveIds[n];
631
- }
632
- }
633
- const truth = new Set(Array.from(bi).filter((x) => x >= 0));
634
- for (const r of db2.query(query, 10))
635
- if (truth.has(r.id))
636
- hits++;
637
- }
638
- const recall = hits / (Q * 10);
639
- assert(recall >= 0.9, `recall after compact stays high (got ${(recall * 100).toFixed(1)}%)`);
640
- console.log(`updates+deletes: compacted to ${db2.size} live; recall@10 after compact ${(recall * 100).toFixed(1)}%`);
641
- dbFull.close();
642
- dbFresh.close();
643
- db2.close();
644
- }
645
- // =============================================================================
646
- // 3) Memory + recall + sub-linearity benchmark
647
- // =============================================================================
648
- function benchmark() {
649
- section("3) Memory + recall + sub-linearity benchmark (HNSW over 1-bit codes)");
650
- // Tweak these for a faster run or a wider sweep.
651
- const DIM = 256; // 256-d -> 32-byte codes (the headline example)
652
- const checkpoints = [1000, 2000, 4000, 8000, 16000, 32000];
653
- const GROUP = 10; // well-separated groups of near-duplicates
654
- const K = 10; // recall@K against the exact K nearest
655
- const SPREAD = 0.1;
656
- const Q = 200;
657
- const EF = 150;
658
- const rng = new RNG(12345);
659
- const MAX_N = checkpoints[checkpoints.length - 1];
660
- const numGroups = Math.ceil(MAX_N / GROUP);
661
- console.log(`dim=${DIM}, ${GROUP}-point groups, queries=${Q}, k=${K}, efSearch=${EF}`);
662
- console.log("generating data ...");
663
- const centers = new Float32Array(numGroups * DIM);
664
- for (let g = 0; g < numGroups; g++) {
665
- for (let d = 0; d < DIM; d++)
666
- centers[g * DIM + d] = rng.gauss();
667
- }
668
- const data = new Float32Array(MAX_N * DIM);
669
- for (let i = 0; i < MAX_N; i++) {
670
- const base = Math.floor(i / GROUP) * DIM;
671
- const off = i * DIM;
672
- for (let d = 0; d < DIM; d++) {
673
- data[off + d] = centers[base + d] + SPREAD * rng.gauss();
674
- }
675
- }
676
- // queries land in groups that exist at every checkpoint
677
- const firstGroups = checkpoints[0] / GROUP;
678
- const queries = new Float32Array(Q * DIM);
679
- for (let q = 0; q < Q; q++) {
680
- const base = rng.int(firstGroups) * DIM;
681
- const off = q * DIM;
682
- for (let d = 0; d < DIM; d++) {
683
- queries[off + d] = centers[base + d] + SPREAD * rng.gauss();
684
- }
685
- }
686
- const db = new VectorDatabase({
687
- dbPath: ":memory:",
688
- dim: DIM,
689
- M: 16,
690
- efConstruction: 100,
691
- efSearch: EF,
692
- seed: 999,
693
- });
694
- // Precompute data vector norms once — O(N·D) instead of O(Q·N·D).
695
- const dataNorms = new Float64Array(MAX_N);
696
- for (let i = 0; i < MAX_N; i++) {
697
- let s = 0;
698
- const off = i * DIM;
699
- for (let d = 0; d < DIM; d++)
700
- s += data[off + d] * data[off + d];
701
- dataNorms[i] = Math.sqrt(s) || 1;
702
- }
703
- function bruteForceTopK(n, qOff) {
704
- const bd = new Float64Array(K).fill(Infinity);
705
- const bi = new Int32Array(K).fill(-1);
706
- let qn = 0;
707
- for (let d = 0; d < DIM; d++)
708
- qn += queries[qOff + d] * queries[qOff + d];
709
- qn = Math.sqrt(qn) || 1;
710
- for (let i = 0; i < n; i++) {
711
- const off = i * DIM;
712
- let dot = 0;
713
- for (let d = 0; d < DIM; d++)
714
- dot += data[off + d] * queries[qOff + d];
715
- const s = 1 - dot / (dataNorms[i] * qn);
716
- if (s < bd[K - 1]) {
717
- let p = K - 1;
718
- while (p > 0 && bd[p - 1] > s) {
719
- bd[p] = bd[p - 1];
720
- bi[p] = bi[p - 1];
721
- p--;
722
- }
723
- bd[p] = s;
724
- bi[p] = i;
725
- }
726
- }
727
- // Direct set from typed array filtered values.
728
- const truth = new Set();
729
- for (let i = 0; i < K; i++) {
730
- if (bi[i] >= 0)
731
- truth.add(bi[i]);
732
- }
733
- return truth;
734
- }
735
- const rows = [];
736
- let inserted = 0;
737
- for (const cp of checkpoints) {
738
- const tb = now();
739
- for (; inserted < cp; inserted++) {
740
- db.insert(inserted, data.subarray(inserted * DIM, inserted * DIM + DIM));
741
- }
742
- console.log(` built N=${cp} (+${(now() - tb).toFixed(0)}ms)`);
743
- for (let w = 0; w < 25; w++) {
744
- const o = (w % Q) * DIM;
745
- db.query(queries.subarray(o, o + DIM), K);
746
- }
747
- const hnswResults = new Array(Q);
748
- let comps = 0;
749
- const th = now();
750
- for (let qi = 0; qi < Q; qi++) {
751
- const o = qi * DIM;
752
- hnswResults[qi] = db.query(queries.subarray(o, o + DIM), K).map((x) => x.id);
753
- comps += db.lastQueryDistanceComputations;
754
- }
755
- const hnswMs = now() - th;
756
- const truths = new Array(Q);
757
- const tbf = now();
758
- for (let qi = 0; qi < Q; qi++)
759
- truths[qi] = bruteForceTopK(cp, qi * DIM);
760
- const bruteMs = now() - tbf;
761
- let hits = 0;
762
- for (let qi = 0; qi < Q; qi++) {
763
- for (const id of hnswResults[qi])
764
- if (truths[qi].has(id))
765
- hits++;
766
- }
767
- rows.push({
768
- n: cp,
769
- hnswUs: (hnswMs / Q) * 1000,
770
- comps: comps / Q,
771
- bruteUs: (bruteMs / Q) * 1000,
772
- recall: hits / (Q * K),
773
- speedup: bruteMs / hnswMs,
774
- });
775
- }
776
- // ---- memory report ----
777
- const st = db.storage;
778
- const mb = (bytes) => (bytes / (1024 * 1024)).toFixed(2) + " MB";
779
- console.log("");
780
- console.log("Per-vector storage (vector payload only, excludes the HNSW graph):");
781
- console.log(` Float32 baseline : ${st.float32BytesPerVector} B`);
782
- console.log(` 1-bit RaBitQ code : ${st.codeBytesPerVector} B (${(st.float32BytesPerVector / st.codeBytesPerVector).toFixed(1)}x smaller)`);
783
- console.log(` code (kept) : ${st.bytesPerVector} B (${st.compressionRatio.toFixed(1)}x smaller overall)`);
784
- console.log(` payload at N=${MAX_N} : Float32 ${mb(st.float32BytesPerVector * MAX_N)} vs index ${mb(st.bytesPerVector * MAX_N)}`);
785
- // ---- results table ----
786
- console.log("");
787
- console.log(" N | HNSW q (us) | dist comps | Brute q (us) | recall@10 | speedup");
788
- console.log("-------+-------------+------------+--------------+-----------+--------");
789
- for (const r of rows) {
790
- console.log(`${String(r.n).padStart(6)} | ${r.hnswUs.toFixed(1).padStart(11)} | ${r.comps.toFixed(0).padStart(10)} | ${r.bruteUs
791
- .toFixed(1)
792
- .padStart(12)} | ${(r.recall * 100).toFixed(1).padStart(8)}% | ${r.speedup.toFixed(1).padStart(6)}x`);
793
- }
794
- const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
795
- const exponent = (xs, ys) => {
796
- const lx = xs.map(Math.log);
797
- const ly = ys.map((y) => Math.log(Math.max(y, 1e-12)));
798
- const mx = mean(lx);
799
- const my = mean(ly);
800
- let num = 0;
801
- let den = 0;
802
- for (let i = 0; i < lx.length; i++) {
803
- num += (lx[i] - mx) * (ly[i] - my);
804
- den += (lx[i] - mx) * (lx[i] - mx);
805
- }
806
- return num / den;
807
- };
808
- const ns = rows.map((r) => r.n);
809
- const expComps = exponent(ns, rows.map((r) => r.comps));
810
- const expBrute = exponent(ns, rows.map((r) => r.bruteUs));
811
- const avgRecall = mean(rows.map((r) => r.recall));
812
- const range = ns[ns.length - 1] / ns[0];
813
- console.log("");
814
- console.log(`Empirical scaling over a ${range}x increase in N (work ~ N^exponent):`);
815
- console.log(` HNSW distance computations : N^${expComps.toFixed(3)} <- sub-linear (target < 1)`);
816
- console.log(` Brute-force query : N^${expBrute.toFixed(3)} (linear reference ~ 1.0)`);
817
- console.log(` Mean recall@${K} : ${(avgRecall * 100).toFixed(2)}%`);
818
- const last = rows[rows.length - 1];
819
- assert(st.codeBytesPerVector === Math.ceil(DIM / 32) * 4, "code size is ceil(dim/32) 32-bit words");
820
- assert(st.compressionRatio > 10, `index must be far smaller than Float32 (got ${st.compressionRatio.toFixed(1)}x)`);
821
- assert(expComps < 0.8, `HNSW distance-computation growth must be sub-linear (got N^${expComps.toFixed(3)})`);
822
- assert(avgRecall >= 0.9, `mean recall must be >= 90% (got ${(avgRecall * 100).toFixed(1)}%)`);
823
- assert(last.hnswUs < last.bruteUs, "HNSW must be faster than brute force at the largest N");
824
- assert(last.speedup > 1, `HNSW must be faster than brute force at the largest N (got ${last.speedup.toFixed(1)}x)`);
825
- console.log("\nMemory, sub-linearity, recall and speed-up checks OK");
826
- db.close();
827
- }
828
- // =============================================================================
829
- // 4) Scalability is INTRINSIC, not cache-borne
830
- //
831
- // Codes and adjacency lists live in SQLite and are read on demand; the only
832
- // cache is SQLite's page cache (Store's cacheSizeMb), a pure speed layer. So
833
- // the engine's scalability must hold with that cache OFF: the STORAGE READS an
834
- // operation issues (Store.reads, surfaced per query as lastQueryStorageReads)
835
- // must be governed by the WORK the operation does — the distinct nodes it
836
- // visits, an HNSW property that grows ~log N — NOT by how many vectors already
837
- // exist. An implementation that leans on a cache to be fast betrays itself here:
838
- // with the cache disabled its per-operation reads track the whole working set
839
- // and climb with N. This is a STORAGE contract, so the guard lives in the
840
- // rabitq-hnsw suite.
841
- //
842
- // The witness is `db.lastQueryStorageReads` — node + neighbour-list fetches that
843
- // hit the database on the last query, counted below the cache so a warm cache
844
- // can never hide a bad access pattern. We build ONE store in blocks (cacheSizeMb
845
- // 0) and, at each checkpoint, measure the mean storage reads of a fixed query
846
- // set. Across an 8x increase in N the per-query read count must stay within a
847
- // flat band; the log–log growth exponent makes "does not grow with N" precise.
848
- // =============================================================================
849
- function testScalabilityWithoutCache() {
850
- section("4) Scalability is intrinsic, not cache-borne (cacheSizeMb = 0)");
851
- // A small dimension and modest graph degree keep the build cheap — this test
852
- // is about the SHAPE of storage-reads vs N, not throughput, so the constants
853
- // only need to be large enough for HNSW's structure to hold.
854
- const DIM = 64;
855
- const db = new VectorDatabase({
856
- dbPath: ":memory:",
857
- dim: DIM,
858
- M: 8,
859
- efConstruction: 40,
860
- efSearch: 40,
861
- seed: 4242,
862
- cacheSizeMb: 0, // ALL caches off (page cache + code LRU) — honest reads
863
- });
864
- const rng = new RNG(20260628);
865
- const vec = () => {
866
- const v = new Array(DIM);
867
- for (let d = 0; d < DIM; d++)
868
- v[d] = rng.gauss();
869
- return unit(v);
870
- };
871
- // A fixed set of probes, reused at every checkpoint so the only variable is N.
872
- const Q = 40;
873
- const probes = [];
874
- for (let q = 0; q < Q; q++)
875
- probes.push(vec());
876
- const checkpoints = [1000, 2000, 4000, 8000]; // 8x range
877
- const readsPerQuery = [];
878
- let id = 0;
879
- for (const cp of checkpoints) {
880
- const tb = now();
881
- for (; id < cp; id++)
882
- db.insert(id, vec());
883
- // Mean storage reads over the fixed probe set — the cache-independent cost
884
- // of ONE query at this N.
885
- let reads = 0;
886
- for (const p of probes) {
887
- db.query(p, 10);
888
- reads += db.lastQueryStorageReads;
889
- }
890
- readsPerQuery.push(reads / Q);
891
- console.log(` N=${String(cp).padStart(6)} +${(now() - tb).toFixed(0)}ms ` +
892
- `storage-reads/query ${(reads / Q).toFixed(1)}`);
893
- }
894
- // Compare the per-query read count at the LARGEST store against the smallest:
895
- // if scaling leaned on the cache, the cache-disabled reads would rise with N
896
- // (a deeper graph re-read from disk). The contract is that it stays within a
897
- // small constant band — work set by the query, not by the corpus. Log–log
898
- // growth exponent makes it precise.
899
- const lx = checkpoints.map(Math.log);
900
- const ly = readsPerQuery.map((y) => Math.log(Math.max(y, 1e-9)));
901
- const mx = lx.reduce((a, c) => a + c, 0) / lx.length;
902
- const my = ly.reduce((a, c) => a + c, 0) / ly.length;
903
- let num = 0, den = 0;
904
- for (let i = 0; i < lx.length; i++) {
905
- num += (lx[i] - mx) * (ly[i] - my);
906
- den += (lx[i] - mx) * (lx[i] - mx);
907
- }
908
- const exponent = num / den;
909
- const first = readsPerQuery[0];
910
- const last = readsPerQuery[readsPerQuery.length - 1];
911
- console.log(` reads/query ${first.toFixed(1)} → ${last.toFixed(1)} over ${checkpoints[checkpoints.length - 1] / checkpoints[0]}x N · growth N^${exponent.toFixed(3)}`);
912
- // PRIMARY guard — sub-linear in N: an O(1)-per-touch, log-N-touched query has
913
- // storage-reads growing like log N (exponent ≈ 0); an implementation forced to
914
- // re-read its working set from disk as N grows pushes this toward and past 1.
915
- // Well under 0.5 proves the index does not lean on the cache to scale.
916
- assert(exponent < 0.5, `cache-disabled storage-reads/query grew as N^${exponent.toFixed(3)} — the index must scale WITHOUT the page cache (reads set by the query's work, not the corpus size)`);
917
- // SECONDARY — a flat band: the largest store costs no more than ~2.5x the
918
- // smallest per query. Generous against HNSW's genuine log-N growth, strict
919
- // enough to fail a regression whose reads track the corpus.
920
- assert(last < first * 2.5 + 8, `cache-disabled reads/query climbed ${first.toFixed(1)} → ${last.toFixed(1)} as the store grew — expected a flat band (work set by the query, not the corpus)`);
921
- db.close();
922
- console.log("\nCache-independent scalability OK");
923
- }
924
- // ------------------------------------ main -----------------------------------
925
- function main() {
926
- const start = now();
927
- testCrudAndPersistence();
928
- testCodeIO();
929
- testCosine();
930
- testNearDuplicateRecall();
931
- testCosFactorBias();
932
- testPersistence();
933
- testCompact();
934
- testScalabilityWithoutCache();
935
- benchmark();
936
- const dt = ((now() - start) / 1000).toFixed(1);
937
- console.log("\n" + "=".repeat(72));
938
- console.log(`ALL TESTS PASSED (${passed} assertions, ${dt}s)`);
939
- console.log("=".repeat(72));
940
- }
941
- try {
942
- main();
943
- }
944
- catch (err) {
945
- console.error("\n" + String(err && err.stack ? err.stack : err));
946
- if (typeof process !== "undefined")
947
- process.exitCode = 1;
948
- }