@hviana/sema 0.1.5 → 0.1.7

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