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