@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
@@ -0,0 +1,1330 @@
1
+ // ivf.ts — an adaptive PARTITIONED (IVF-style) index over 1-bit RaBitQ codes,
2
+ // persisted in SQLite.
3
+ //
4
+ // The collection is a flat set of CLUSTERS. Each cluster has a binary PIVOT
5
+ // code and holds its member codes in fixed-size CHUNK blobs. Inserting is
6
+ // "route + append": Hamming-scan the pivot table (RAM-resident, tiny) for the
7
+ // nearest cluster, then append the code to that cluster's tail chunk — no
8
+ // graph walk, no neighbour rewiring, so insert cost is essentially FLAT in
9
+ // collection size (the pivot scan is K = N/⌀cluster comparisons of a few
10
+ // dozen machine words; at 10M vectors that is ~0.1 ms of pure arithmetic).
11
+ // A graph-based index pays a beam search of dozens of random storage reads
12
+ // per insert here — route + append pays none.
13
+ //
14
+ // Querying ranks every pivot with the accurate RaBitQ estimator (one LUT pass
15
+ // per pivot), then linearly scans the NPROBE nearest clusters' chunks with
16
+ // the same estimator, keeping the top k in a bounded heap. Per-query storage
17
+ // reads are bounded by nprobe × (chunks per cluster) — a CONSTANT once the
18
+ // collection is large enough to split, independent of N.
19
+ //
20
+ // A cluster that reaches SPLIT_MAX entries is SPLIT in place: two seed codes
21
+ // are chosen deterministically (farthest from the old pivot, then farthest
22
+ // from the first), members are MEDIAN-split on the two-seed margin (two exact
23
+ // halves — nearer-seed assignment degenerates on tie-heavy distributions and
24
+ // cascades cluster count), and both halves get fresh majority-bit pivots.
25
+ // No RNG anywhere — the index is a pure function of the insertion sequence.
26
+ //
27
+ // Durability follows the store discipline used across Sema: WAL journal,
28
+ // batched caller-owned transactions (upsertMany), 1 KiB pages, and a 64 MiB
29
+ // autocheckpoint so hot pages coalesce in the WAL instead of being copied out
30
+ // per commit.
31
+ //
32
+ // Layout (all in one SQLite file):
33
+ // meta one row: quantizer geometry + efSearch + counters.
34
+ // cent one row per cluster: pivot code + chunk/entry counts.
35
+ // chunk one row per fixed-capacity chunk of a cluster, keyed by
36
+ // cid·2^16 + seq: a packed blob of [n | dead bitmap | ids | exts |
37
+ // codes]. Fixed-size blobs keep B-tree pages stable under the
38
+ // read-modify-write tail append.
39
+ // vmap one row per live vector: ext → (monotone internal id, cid, seq,
40
+ // slot). The internal id survives compaction, so the largest id a
41
+ // scan has seen is a durable incremental watermark (keysSince).
42
+
43
+ import { DatabaseSync, StatementSync } from "node:sqlite";
44
+ import { QueryContext, RaBitQuantizer } from "./rabitq.js";
45
+
46
+ /** Entries per chunk blob. 64 keeps the tail read-modify-write ≤ ~9 KiB at
47
+ * D=1024 while a full cluster is only SPLIT_MAX/64 blob reads per probe. */
48
+ const CHUNK_CAP = 64;
49
+ /** Entries (live + dead) at which a cluster splits in two. */
50
+ const SPLIT_MAX = 4096;
51
+ /** chunk rowid key = cid·SEQ_SPAN + seq. Chunks per cluster ≤ SPLIT_MAX /
52
+ * CHUNK_CAP = 64 ≪ 2^16, and cid·2^16 stays exact in a double for every
53
+ * reachable cid. */
54
+ const SEQ_SPAN = 1 << 16;
55
+
56
+ export interface IvfConfig {
57
+ dim: number;
58
+ efSearch: number;
59
+ queryBits: number;
60
+ rotationRounds: number;
61
+ seed: number;
62
+ centroid: Float64Array | null;
63
+ codeWords: number;
64
+ paddedDim: number;
65
+ }
66
+
67
+ export interface IvfHit {
68
+ /** external (user) id */
69
+ id: number;
70
+ /** estimated cosine distance (1 - cosine) */
71
+ distance: number;
72
+ }
73
+
74
+ // ── chunk blob layout (fixed size) ─────────────────────────────────────────
75
+ // [0] u32 n entries used (live + dead)
76
+ // [4..12) u64 dead bitmap bit s set = slot s tombstoned
77
+ // [12..16) pad
78
+ // [16..) CHUNK_CAP × f64 internal ids
79
+ // [+..) CHUNK_CAP × i32 external ids
80
+ // [+..) CHUNK_CAP × codeBytes codes
81
+ const IDS_OFF = 16;
82
+ const EXTS_OFF = IDS_OFF + CHUNK_CAP * 8;
83
+ const CODES_OFF = EXTS_OFF + CHUNK_CAP * 4;
84
+
85
+ function chunkSize(codeBytes: number): number {
86
+ return CODES_OFF + CHUNK_CAP * codeBytes;
87
+ }
88
+
89
+ /** Typed-array views over one chunk blob. The blob is allocated aligned
90
+ * (fresh Uint8Array), so the f64/i32 views are always valid. */
91
+ class Chunk {
92
+ readonly buf: Uint8Array;
93
+ readonly n32: Uint32Array; // [0] = n
94
+ readonly dead: Uint8Array; // 8 bytes at offset 4
95
+ readonly ids: Float64Array;
96
+ readonly exts: Int32Array;
97
+ constructor(buf: Uint8Array) {
98
+ // A blob straight from SQLite may sit at an arbitrary byteOffset; copy to
99
+ // an aligned buffer only when needed.
100
+ if (buf.byteOffset % 8 !== 0) buf = new Uint8Array(buf);
101
+ this.buf = buf;
102
+ this.n32 = new Uint32Array(buf.buffer, buf.byteOffset, 4);
103
+ this.dead = buf.subarray(4, 12);
104
+ this.ids = new Float64Array(
105
+ buf.buffer,
106
+ buf.byteOffset + IDS_OFF,
107
+ CHUNK_CAP,
108
+ );
109
+ this.exts = new Int32Array(
110
+ buf.buffer,
111
+ buf.byteOffset + EXTS_OFF,
112
+ CHUNK_CAP,
113
+ );
114
+ }
115
+ get n(): number {
116
+ return this.n32[0];
117
+ }
118
+ set n(v: number) {
119
+ this.n32[0] = v;
120
+ }
121
+ isDead(slot: number): boolean {
122
+ return (this.dead[slot >> 3] & (1 << (slot & 7))) !== 0;
123
+ }
124
+ markDead(slot: number): void {
125
+ this.dead[slot >> 3] |= 1 << (slot & 7);
126
+ }
127
+ codeAt(slot: number, codeBytes: number): number {
128
+ return CODES_OFF + slot * codeBytes; // byte offset into buf
129
+ }
130
+ }
131
+
132
+ /** Bounded max-heap of (distance, ext) — keeps the k SMALLEST distances. */
133
+ class TopK {
134
+ readonly ds: Float64Array;
135
+ readonly ids: Float64Array;
136
+ size = 0;
137
+ constructor(readonly k: number) {
138
+ this.ds = new Float64Array(k);
139
+ this.ids = new Float64Array(k);
140
+ }
141
+ get worst(): number {
142
+ return this.size < this.k ? Infinity : this.ds[0];
143
+ }
144
+ push(d: number, id: number): void {
145
+ const { ds, ids } = this;
146
+ if (this.size < this.k) {
147
+ let i = this.size++;
148
+ ds[i] = d;
149
+ ids[i] = id;
150
+ for (;;) { // sift up
151
+ if (i === 0) break;
152
+ const p = (i - 1) >> 1;
153
+ if (ds[p] >= ds[i]) break;
154
+ const td = ds[p], ti = ids[p];
155
+ ds[p] = ds[i];
156
+ ids[p] = ids[i];
157
+ ds[i] = td;
158
+ ids[i] = ti;
159
+ i = p;
160
+ }
161
+ return;
162
+ }
163
+ if (d >= ds[0]) return;
164
+ ds[0] = d;
165
+ ids[0] = id;
166
+ let i = 0;
167
+ for (;;) { // sift down
168
+ const l = 2 * i + 1, r = l + 1;
169
+ let m = i;
170
+ if (l < this.size && ds[l] > ds[m]) m = l;
171
+ if (r < this.size && ds[r] > ds[m]) m = r;
172
+ if (m === i) break;
173
+ const td = ds[m], ti = ids[m];
174
+ ds[m] = ds[i];
175
+ ids[m] = ids[i];
176
+ ds[i] = td;
177
+ ids[i] = ti;
178
+ i = m;
179
+ }
180
+ }
181
+ drain(): IvfHit[] {
182
+ const out: IvfHit[] = new Array(this.size);
183
+ for (let i = 0; i < this.size; i++) {
184
+ out[i] = { id: this.ids[i], distance: this.ds[i] < 0 ? 0 : this.ds[i] };
185
+ }
186
+ out.sort((a, b) => a.distance - b.distance);
187
+ return out;
188
+ }
189
+ }
190
+
191
+ export class IvfIndex {
192
+ readonly db: DatabaseSync;
193
+ private readonly quantizer: RaBitQuantizer;
194
+ private readonly codeBytes: number;
195
+ private readonly chunkBytes: number;
196
+ private _efSearch: number;
197
+
198
+ // ── RAM-resident routing state (O(K); K = clusters ≈ N / 2048) ──────────
199
+ /** Pivot codes, K × codeBytes, contiguous — the insert/query routing scan
200
+ * is one linear pass over this array. */
201
+ private centCodes = new Uint8Array(0);
202
+ /** Aligned 32-bit word view of {@link centCodes} — the routing scan's
203
+ * operand (4 bytes per XOR+popcount instead of byte re-composition). */
204
+ private centWords = new Uint32Array(0);
205
+ /** Per-cluster entry count (live + dead) — the split trigger. */
206
+ private centEntries = new Int32Array(0);
207
+ /** Per-cluster chunk count (tail seq = count − 1). */
208
+ private centChunks = new Int32Array(0);
209
+ private K = 0;
210
+
211
+ // global counters (persisted in meta at commit)
212
+ private live = 0;
213
+ private totalSlots = 0;
214
+ private nextId = 1;
215
+
216
+ /** Storage row reads (chunk blob fetches) — the cache-independent
217
+ * scalability witness, same discipline as the graph index it replaces. */
218
+ reads = 0;
219
+ lastQueryDistComps = 0;
220
+ lastQueryStorageReads = 0;
221
+
222
+ // Per-transaction write-back buffer of dirty chunks, doubling as a CLEAN
223
+ // read cache when a memory budget was granted. The `reads` counter counts
224
+ // SQLite fall-throughs only, so with `cacheSizeMb: 0` clean retention is
225
+ // DISABLED (dirty chunks must still buffer within a transaction — that is
226
+ // write coalescing, not read caching) and every re-fetch is an honest
227
+ // storage read. Budget-derived cap; flushed entries stay readable from
228
+ // SQLite, so eviction is safe anywhere outside the dirty set.
229
+ private readonly chunkCache = new Map<number, Chunk>();
230
+ private readonly dirtyChunks = new Set<number>();
231
+ private readonly dirtyCents = new Set<number>();
232
+ private metaDirty = false;
233
+ private readonly cacheEnabled: boolean;
234
+ private readonly chunkCacheMax: number;
235
+
236
+ private sSelChunk!: StatementSync;
237
+ private sUpsChunk!: StatementSync;
238
+ private sDelChunk!: StatementSync;
239
+ private sSelVmap!: StatementSync;
240
+ private sInsVmap!: StatementSync;
241
+ private sUpdVmap!: StatementSync;
242
+ private sDelVmap!: StatementSync;
243
+ private sUpsCent!: StatementSync;
244
+ private sMeta!: StatementSync;
245
+
246
+ constructor(
247
+ dbPath: string,
248
+ options: {
249
+ dim?: number;
250
+ efSearch?: number;
251
+ queryBits?: number;
252
+ rotationRounds?: number;
253
+ seed?: number;
254
+ centroid?: ArrayLike<number>;
255
+ cacheSizeMb?: number;
256
+ },
257
+ ) {
258
+ this.db = new DatabaseSync(dbPath);
259
+ const kb = Math.max(0, Math.floor((options.cacheSizeMb ?? 64) * 1024));
260
+ this.cacheEnabled = kb > 0;
261
+ // 1 KiB pages (chunk blobs span ~9 pages; cent/vmap rows are small), WAL
262
+ // with a 64 MiB autocheckpoint (see store-sqlite.ts for the rationale —
263
+ // the 1000-page default forced a synchronous checkpoint per batch).
264
+ this.db.exec("PRAGMA page_size = 1024;");
265
+ this.db.exec(`
266
+ PRAGMA journal_mode = WAL;
267
+ PRAGMA synchronous = NORMAL;
268
+ PRAGMA temp_store = MEMORY;
269
+ PRAGMA cache_size = ${kb > 0 ? `-${kb}` : "0"};
270
+ PRAGMA wal_autocheckpoint = 65536;
271
+ PRAGMA foreign_keys = OFF;
272
+ `);
273
+ this.db.exec(`
274
+ CREATE TABLE IF NOT EXISTS meta (
275
+ id INTEGER PRIMARY KEY CHECK (id = 0),
276
+ format TEXT NOT NULL,
277
+ dim INTEGER NOT NULL,
278
+ query_bits INTEGER NOT NULL,
279
+ rotation_rounds INTEGER NOT NULL,
280
+ seed INTEGER NOT NULL,
281
+ centroid BLOB,
282
+ code_words INTEGER NOT NULL,
283
+ padded_dim INTEGER NOT NULL,
284
+ ef_search INTEGER NOT NULL,
285
+ live INTEGER NOT NULL DEFAULT 0,
286
+ total INTEGER NOT NULL DEFAULT 0,
287
+ next_id INTEGER NOT NULL DEFAULT 1
288
+ );
289
+ CREATE TABLE IF NOT EXISTS cent (
290
+ cid INTEGER PRIMARY KEY,
291
+ code BLOB NOT NULL,
292
+ chunks INTEGER NOT NULL,
293
+ entries INTEGER NOT NULL
294
+ );
295
+ CREATE TABLE IF NOT EXISTS chunk (
296
+ key INTEGER PRIMARY KEY,
297
+ blob BLOB NOT NULL
298
+ );
299
+ CREATE TABLE IF NOT EXISTS vmap (
300
+ ext INTEGER PRIMARY KEY,
301
+ id INTEGER NOT NULL,
302
+ cid INTEGER NOT NULL,
303
+ seq INTEGER NOT NULL,
304
+ slot INTEGER NOT NULL
305
+ );
306
+ CREATE INDEX IF NOT EXISTS idx_vmap_id ON vmap(id);
307
+ `);
308
+
309
+ // ── config: load stored, or initialise from options ──
310
+ const row = this.db.prepare("SELECT * FROM meta WHERE id = 0").get() as
311
+ | Record<string, unknown>
312
+ | undefined;
313
+ let cfg: IvfConfig;
314
+ if (row) {
315
+ if (row.format !== "ivf1") {
316
+ throw new Error(
317
+ `vector index at ${dbPath} has format '${String(row.format)}', ` +
318
+ `expected 'ivf1' — delete the .vec files and rebuild ` +
319
+ `(Store.repairContentIndex regenerates the content index)`,
320
+ );
321
+ }
322
+ let centroid: Float64Array | null = null;
323
+ const cb = row.centroid as Uint8Array | null;
324
+ if (cb) {
325
+ centroid = new Float64Array(row.dim as number);
326
+ new Uint8Array(centroid.buffer).set(cb);
327
+ }
328
+ cfg = {
329
+ dim: row.dim as number,
330
+ efSearch: options.efSearch ?? (row.ef_search as number),
331
+ queryBits: options.queryBits ?? (row.query_bits as number),
332
+ rotationRounds: row.rotation_rounds as number,
333
+ seed: row.seed as number,
334
+ centroid,
335
+ codeWords: row.code_words as number,
336
+ paddedDim: row.padded_dim as number,
337
+ };
338
+ if (
339
+ options.efSearch !== undefined &&
340
+ options.efSearch !== (row.ef_search as number)
341
+ ) {
342
+ this.db.prepare("UPDATE meta SET ef_search = ? WHERE id = 0")
343
+ .run(options.efSearch);
344
+ }
345
+ if (
346
+ options.queryBits !== undefined &&
347
+ options.queryBits !== (row.query_bits as number)
348
+ ) {
349
+ this.db.prepare("UPDATE meta SET query_bits = ? WHERE id = 0")
350
+ .run(options.queryBits);
351
+ }
352
+ this.live = row.live as number;
353
+ this.totalSlots = row.total as number;
354
+ this.nextId = row.next_id as number;
355
+ } else {
356
+ if (!Number.isInteger(options.dim) || (options.dim as number) <= 0) {
357
+ throw new Error("dim must be a positive integer for a new database");
358
+ }
359
+ const dim = options.dim as number;
360
+ const probe = new RaBitQuantizer(dim, { rounds: 1, seed: 0 });
361
+ cfg = {
362
+ dim,
363
+ efSearch: options.efSearch ?? 64,
364
+ queryBits: options.queryBits ?? 8,
365
+ rotationRounds: options.rotationRounds ?? 3,
366
+ seed: (options.seed ?? 0x1234abcd) >>> 0,
367
+ centroid: options.centroid ? Float64Array.from(options.centroid) : null,
368
+ codeWords: probe.codeWords,
369
+ paddedDim: probe.paddedDim,
370
+ };
371
+ const centroidBlob = cfg.centroid
372
+ ? new Uint8Array(cfg.centroid.buffer, 0, cfg.centroid.byteLength)
373
+ : null;
374
+ this.db.prepare(
375
+ "INSERT INTO meta (id, format, dim, query_bits, rotation_rounds, " +
376
+ "seed, centroid, code_words, padded_dim, ef_search) " +
377
+ "VALUES (0, 'ivf1', ?, ?, ?, ?, ?, ?, ?, ?)",
378
+ ).run(
379
+ cfg.dim,
380
+ cfg.queryBits,
381
+ cfg.rotationRounds,
382
+ cfg.seed,
383
+ centroidBlob,
384
+ cfg.codeWords,
385
+ cfg.paddedDim,
386
+ cfg.efSearch,
387
+ );
388
+ }
389
+
390
+ this.quantizer = new RaBitQuantizer(cfg.dim, {
391
+ queryBits: cfg.queryBits,
392
+ rounds: cfg.rotationRounds,
393
+ seed: cfg.seed,
394
+ centroid: cfg.centroid ?? undefined,
395
+ });
396
+ if (this.quantizer.codeWords !== cfg.codeWords) {
397
+ throw new Error(
398
+ "Stored code geometry does not match the quantizer (corrupt database?)",
399
+ );
400
+ }
401
+ this.codeBytes = cfg.codeWords * 4;
402
+ this.chunkBytes = chunkSize(this.codeBytes);
403
+ // Clean-chunk retention cap, derived from the memory budget (~9 KiB per
404
+ // chunk at D=1024). With the budget at 0 only dirty chunks are held.
405
+ this.chunkCacheMax = this.cacheEnabled
406
+ ? Math.max(64, Math.min(Math.floor((kb * 1024) / this.chunkBytes), 65536))
407
+ : 64;
408
+ this._efSearch = cfg.efSearch;
409
+ this.dim = cfg.dim;
410
+
411
+ this.prepareAll();
412
+ this.loadCents();
413
+ }
414
+
415
+ readonly dim: number;
416
+
417
+ private prepareAll(): void {
418
+ this.sSelChunk = this.db.prepare("SELECT blob FROM chunk WHERE key = ?");
419
+ this.sSelChunk.setReturnArrays(true);
420
+ this.sUpsChunk = this.db.prepare(
421
+ "INSERT INTO chunk (key, blob) VALUES (?, ?) " +
422
+ "ON CONFLICT(key) DO UPDATE SET blob = excluded.blob",
423
+ );
424
+ this.sDelChunk = this.db.prepare("DELETE FROM chunk WHERE key = ?");
425
+ this.sSelVmap = this.db.prepare(
426
+ "SELECT id, cid, seq, slot FROM vmap WHERE ext = ?",
427
+ );
428
+ this.sSelVmap.setReturnArrays(true);
429
+ this.sInsVmap = this.db.prepare(
430
+ "INSERT INTO vmap (ext, id, cid, seq, slot) VALUES (?, ?, ?, ?, ?)",
431
+ );
432
+ this.sUpdVmap = this.db.prepare(
433
+ "UPDATE vmap SET cid = ?, seq = ?, slot = ? WHERE ext = ?",
434
+ );
435
+ this.sDelVmap = this.db.prepare("DELETE FROM vmap WHERE ext = ?");
436
+ this.sUpsCent = this.db.prepare(
437
+ "INSERT INTO cent (cid, code, chunks, entries) VALUES (?, ?, ?, ?) " +
438
+ "ON CONFLICT(cid) DO UPDATE SET code = excluded.code, " +
439
+ "chunks = excluded.chunks, entries = excluded.entries",
440
+ );
441
+ this.sMeta = this.db.prepare(
442
+ "UPDATE meta SET live = ?, total = ?, next_id = ? WHERE id = 0",
443
+ );
444
+ }
445
+
446
+ /** Load the pivot table into RAM — K rows, ~(codeBytes + 8) bytes each. */
447
+ private loadCents(): void {
448
+ const rows = this.db.prepare(
449
+ "SELECT cid, code, chunks, entries FROM cent ORDER BY cid",
450
+ ).all() as Array<
451
+ { cid: number; code: Uint8Array; chunks: number; entries: number }
452
+ >;
453
+ this.K = rows.length === 0 ? 0 : rows[rows.length - 1].cid + 1;
454
+ const cap = Math.max(16, this.K);
455
+ this.centCodes = new Uint8Array(cap * this.codeBytes);
456
+ this.centWords = new Uint32Array(this.centCodes.buffer);
457
+ this.centEntries = new Int32Array(cap);
458
+ this.centChunks = new Int32Array(cap);
459
+ for (const r of rows) {
460
+ this.centCodes.set(r.code, r.cid * this.codeBytes);
461
+ this.centEntries[r.cid] = r.entries;
462
+ this.centChunks[r.cid] = r.chunks;
463
+ }
464
+ // Super-pivots are derived state — rebuild lazily from scratch.
465
+ this.superWords = new Uint32Array(0);
466
+ this.superStale.clear();
467
+ const G = IvfIndex.SUPER_G;
468
+ for (let g = 0; g * G < this.K; g++) this.superStale.add(g);
469
+ }
470
+
471
+ private growCents(): void {
472
+ const cap = this.centEntries.length;
473
+ if (this.K < cap) return;
474
+ const ncap = cap * 2;
475
+ const codes = new Uint8Array(ncap * this.codeBytes);
476
+ codes.set(this.centCodes);
477
+ this.centCodes = codes;
478
+ this.centWords = new Uint32Array(codes.buffer);
479
+ const e = new Int32Array(ncap);
480
+ e.set(this.centEntries);
481
+ this.centEntries = e;
482
+ const c = new Int32Array(ncap);
483
+ c.set(this.centChunks);
484
+ this.centChunks = c;
485
+ }
486
+
487
+ // ── counters ────────────────────────────────────────────────────────────
488
+
489
+ get size(): number {
490
+ return this.live;
491
+ }
492
+ get physicalSize(): number {
493
+ return this.totalSlots;
494
+ }
495
+ get clusterCount(): number {
496
+ return this.K;
497
+ }
498
+ get bytesPerVector(): number {
499
+ return this.codeBytes;
500
+ }
501
+ get efSearch(): number {
502
+ return this._efSearch;
503
+ }
504
+ set efSearch(v: number) {
505
+ this._efSearch = Math.max(1, v | 0);
506
+ this.db.prepare("UPDATE meta SET ef_search = ? WHERE id = 0")
507
+ .run(this._efSearch);
508
+ }
509
+ resetReads(): void {
510
+ this.reads = 0;
511
+ }
512
+
513
+ // ── chunk IO (write-back cached within a transaction) ───────────────────
514
+
515
+ /** Fetch a chunk, serving dirty/cached copies first. `retain` controls
516
+ * whether a clean fetch enters the cache — read-only consumers pass the
517
+ * cacheEnabled flag so the `cacheSizeMb: 0` mode stays honestly uncached;
518
+ * writers always retain (the copy is about to become dirty). */
519
+ private chunkAt(cid: number, seq: number, retain = true): Chunk {
520
+ const key = cid * SEQ_SPAN + seq;
521
+ const hit = this.chunkCache.get(key);
522
+ if (hit !== undefined) return hit;
523
+ this.reads++;
524
+ const row = this.sSelChunk.get(key) as [Uint8Array] | undefined;
525
+ const c = new Chunk(row ? row[0] : new Uint8Array(this.chunkBytes));
526
+ if (retain) this.cacheChunk(key, c);
527
+ return c;
528
+ }
529
+
530
+ private cacheChunk(key: number, c: Chunk): void {
531
+ if (this.chunkCache.size >= this.chunkCacheMax) this.flushChunks();
532
+ this.chunkCache.set(key, c);
533
+ }
534
+
535
+ private markDirty(cid: number, seq: number, c: Chunk): void {
536
+ const key = cid * SEQ_SPAN + seq;
537
+ if (!this.chunkCache.has(key)) this.chunkCache.set(key, c);
538
+ this.dirtyChunks.add(key);
539
+ }
540
+
541
+ /** Write every dirty chunk row; drop the clean cache only when retention
542
+ * is off (no budget) — under a budget the warm set survives the flush.
543
+ * Runs inside the caller's transaction. */
544
+ private flushChunks(): void {
545
+ for (const key of this.dirtyChunks) {
546
+ const c = this.chunkCache.get(key)!;
547
+ this.sUpsChunk.run(key, c.buf);
548
+ }
549
+ this.dirtyChunks.clear();
550
+ if (!this.cacheEnabled || this.chunkCache.size >= this.chunkCacheMax) {
551
+ this.chunkCache.clear();
552
+ }
553
+ }
554
+
555
+ private flushCents(): void {
556
+ for (const cid of this.dirtyCents) {
557
+ this.sUpsCent.run(
558
+ cid,
559
+ this.centCodes.subarray(
560
+ cid * this.codeBytes,
561
+ (cid + 1) * this.codeBytes,
562
+ ),
563
+ this.centChunks[cid],
564
+ this.centEntries[cid],
565
+ );
566
+ }
567
+ this.dirtyCents.clear();
568
+ if (this.metaDirty) {
569
+ this.sMeta.run(this.live, this.totalSlots, this.nextId);
570
+ this.metaDirty = false;
571
+ }
572
+ }
573
+
574
+ /** Persist all buffered state. MUST be called before the enclosing
575
+ * transaction commits (upsertMany/deleteMany do; single-op paths too). */
576
+ private flushAll(): void {
577
+ this.flushChunks();
578
+ this.flushCents();
579
+ }
580
+
581
+ // ── routing ─────────────────────────────────────────────────────────────
582
+ //
583
+ // TWO-LEVEL: a flat pivot scan is O(K) per insert with K growing as N/2048,
584
+ // which quietly becomes the dominant insert cost at millions of vectors
585
+ // (profiled: 33% of a 400k bulk load, and rising). Pivots are therefore
586
+ // grouped into SUPER-GROUPS of SUPER_G consecutive cids; each group carries
587
+ // a majority-bit super-pivot (pure RAM, derived — rebuilt from centCodes on
588
+ // open, marked stale when any member pivot changes). Routing scans the
589
+ // K/SUPER_G super-pivots, takes the SUPER_TOP nearest groups, and scans
590
+ // only their members exactly: O(K/64 + 256) word-Hamming rows per insert.
591
+ // Deterministic (majority + fixed tie order). Routing is a placement
592
+ // heuristic — a near-best cluster is as good as the best for recall, since
593
+ // queries probe many clusters — so the approximation never affects results
594
+ // beyond which (equally valid) cluster holds a code.
595
+
596
+ private static readonly SUPER_G = 64;
597
+ private static readonly SUPER_TOP = 4;
598
+ /** Majority super-pivot codes, one per group of SUPER_G clusters. */
599
+ private superWords = new Uint32Array(0);
600
+ /** Group ids whose super-pivot is stale (member pivot changed). */
601
+ private readonly superStale = new Set<number>();
602
+
603
+ /** Scratch word view of the code being routed (avoids per-call copies). */
604
+ private routeScratch = new Uint32Array(0);
605
+
606
+ private markSuperStale(cid: number): void {
607
+ this.superStale.add(Math.floor(cid / IvfIndex.SUPER_G));
608
+ }
609
+
610
+ /** Recompute stale super-pivots (majority bit over member pivots). */
611
+ private freshenSupers(): void {
612
+ if (this.superStale.size === 0) return;
613
+ const words = this.codeBytes >> 2;
614
+ const G = IvfIndex.SUPER_G;
615
+ const groups = Math.ceil(this.K / G);
616
+ if (this.superWords.length < groups * words) {
617
+ const grown = new Uint32Array(
618
+ Math.max(groups, 16) * 2 * words,
619
+ );
620
+ grown.set(this.superWords);
621
+ this.superWords = grown;
622
+ }
623
+ const cw = this.centWords;
624
+ for (const g of this.superStale) {
625
+ const lo = g * G;
626
+ const hi = Math.min(lo + G, this.K);
627
+ if (hi <= lo) continue;
628
+ const m = hi - lo;
629
+ const half = m / 2;
630
+ const out = g * words;
631
+ for (let w = 0; w < words; w++) {
632
+ let word = 0;
633
+ for (let bit = 0; bit < 32; bit++) {
634
+ const mask = 1 << bit;
635
+ let ones = 0;
636
+ for (let cid = lo; cid < hi; cid++) {
637
+ if (cw[cid * words + w] & mask) ones++;
638
+ }
639
+ if (ones > half) word |= mask;
640
+ }
641
+ this.superWords[out + w] = word;
642
+ }
643
+ }
644
+ this.superStale.clear();
645
+ }
646
+
647
+ /** Word-popcount Hamming between the routing scratch and a words-row. */
648
+ private hamAt(q: Uint32Array, arr: Uint32Array, base: number): number {
649
+ const words = this.codeBytes >> 2;
650
+ let ham = 0;
651
+ for (let w = 0; w < words; w++) {
652
+ let x = q[w] ^ arr[base + w];
653
+ x -= (x >>> 1) & 0x55555555;
654
+ x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
655
+ x = (x + (x >>> 4)) & 0x0f0f0f0f;
656
+ ham += Math.imul(x, 0x01010101) >>> 24;
657
+ }
658
+ return ham;
659
+ }
660
+
661
+ /** Nearest cluster to a code — exact over member pivots of the SUPER_TOP
662
+ * nearest super-groups (or over everything while K is small). Pure RAM,
663
+ * no allocation, no storage reads. */
664
+ private nearestCid(code: Uint8Array): number {
665
+ const words = this.codeBytes >> 2;
666
+ if (this.routeScratch.length < words) {
667
+ this.routeScratch = new Uint32Array(words);
668
+ }
669
+ const q = this.routeScratch;
670
+ new Uint8Array(q.buffer, 0, this.codeBytes).set(code);
671
+ const cw = this.centWords;
672
+ const G = IvfIndex.SUPER_G;
673
+
674
+ let best = -1;
675
+ let bestD = Infinity;
676
+ const scanGroup = (lo: number, hi: number): void => {
677
+ for (let cid = lo; cid < hi; cid++) {
678
+ const d = this.hamAt(q, cw, cid * words);
679
+ if (d < bestD) {
680
+ bestD = d;
681
+ best = cid;
682
+ }
683
+ }
684
+ };
685
+
686
+ if (this.K <= 2 * G * IvfIndex.SUPER_TOP) {
687
+ scanGroup(0, this.K); // small collection — exact flat scan
688
+ return best;
689
+ }
690
+
691
+ this.freshenSupers();
692
+ const groups = Math.ceil(this.K / G);
693
+ // Top SUPER_TOP groups by super-pivot Hamming (tiny insertion "heap").
694
+ const T = IvfIndex.SUPER_TOP;
695
+ const gIds = new Int32Array(T).fill(-1);
696
+ const gDs = new Float64Array(T).fill(Infinity);
697
+ for (let g = 0; g < groups; g++) {
698
+ const d = this.hamAt(q, this.superWords, g * words);
699
+ if (d >= gDs[T - 1]) continue;
700
+ let i = T - 1;
701
+ while (i > 0 && gDs[i - 1] > d) {
702
+ gDs[i] = gDs[i - 1];
703
+ gIds[i] = gIds[i - 1];
704
+ i--;
705
+ }
706
+ gDs[i] = d;
707
+ gIds[i] = g;
708
+ }
709
+ for (let t = 0; t < T; t++) {
710
+ const g = gIds[t];
711
+ if (g < 0) continue;
712
+ scanGroup(g * G, Math.min((g + 1) * G, this.K));
713
+ }
714
+ return best;
715
+ }
716
+
717
+ /** The nprobe nearest clusters to a QUERY (accurate estimator on pivots),
718
+ * as cids ordered nearest-first. */
719
+ private probeOrder(q: QueryContext, nprobe: number): number[] {
720
+ const top = new TopK(Math.min(nprobe, this.K));
721
+ const cb = this.codeBytes;
722
+ for (let cid = 0; cid < this.K; cid++) {
723
+ // Empty clusters can exist transiently after a compact; skip.
724
+ if (this.centEntries[cid] === 0) continue;
725
+ const d = this.quantizer.estimate(this.centCodes, cid * cb, q);
726
+ top.push(d, cid);
727
+ }
728
+ return top.drain().map((h) => h.id);
729
+ }
730
+
731
+ private probeOrderByCode(code: Uint8Array, nprobe: number): number[] {
732
+ const top = new TopK(Math.min(nprobe, this.K));
733
+ const cb = this.codeBytes;
734
+ const cc = this.centCodes;
735
+ for (let cid = 0; cid < this.K; cid++) {
736
+ if (this.centEntries[cid] === 0) continue;
737
+ top.push(
738
+ this.quantizer.codeDistanceBytes(
739
+ code,
740
+ cc.subarray(cid * cb, cid * cb + cb),
741
+ ),
742
+ cid,
743
+ );
744
+ }
745
+ return top.drain().map((h) => h.id);
746
+ }
747
+
748
+ // ── insert / update / delete ────────────────────────────────────────────
749
+
750
+ /** Whether an external id is present (live). */
751
+ has(ext: number): boolean {
752
+ return this.sSelVmap.get(ext) !== undefined;
753
+ }
754
+
755
+ /** The stored code for an ext (a copy), or null. */
756
+ codeOf(ext: number): Uint8Array | null {
757
+ const row = this.sSelVmap.get(ext) as
758
+ | [number, number, number, number]
759
+ | undefined;
760
+ if (!row) return null;
761
+ const c = this.chunkAt(row[1], row[2], this.cacheEnabled);
762
+ const off = c.codeAt(row[3], this.codeBytes);
763
+ return c.buf.slice(off, off + this.codeBytes);
764
+ }
765
+
766
+ /** Insert a code under `ext`. Caller owns the transaction; buffered rows
767
+ * are flushed by {@link commitFlush}. Throws if ext is already live. */
768
+ insert(ext: number, code: Uint8Array): void {
769
+ if (this.sSelVmap.get(ext) !== undefined) {
770
+ throw new Error(`External id already exists: ${ext} (use update())`);
771
+ }
772
+ this.insertNew(ext, code);
773
+ }
774
+
775
+ /** Insert-or-update with ONE vmap probe. The point probe is a real cost
776
+ * at bulk-load rates (statement dispatch + a B-tree descent per row —
777
+ * profiled as the single largest insert-path term), so the presence check
778
+ * and the update lookup share it. */
779
+ upsert(ext: number, code: Uint8Array): void {
780
+ const row = this.sSelVmap.get(ext) as
781
+ | [number, number, number, number]
782
+ | undefined;
783
+ if (row === undefined) this.insertNew(ext, code);
784
+ else this.updateAt(row, ext, code);
785
+ }
786
+
787
+ /** The insert core — presence already established by the caller. */
788
+ private insertNew(ext: number, code: Uint8Array): void {
789
+ const cid = this.K === 0 ? this.newCluster(code) : this.nearestCid(code);
790
+ this.appendToCluster(cid, ext, code, this.nextId++);
791
+ this.live++;
792
+ this.totalSlots++;
793
+ this.metaDirty = true;
794
+ if (this.centEntries[cid] >= SPLIT_MAX) this.split(cid);
795
+ }
796
+
797
+ private newCluster(pivot: Uint8Array): number {
798
+ const cid = this.K++;
799
+ this.growCents();
800
+ this.centCodes.set(pivot, cid * this.codeBytes);
801
+ this.centEntries[cid] = 0;
802
+ this.centChunks[cid] = 0;
803
+ this.dirtyCents.add(cid);
804
+ this.markSuperStale(cid);
805
+ return cid;
806
+ }
807
+
808
+ private appendToCluster(
809
+ cid: number,
810
+ ext: number,
811
+ code: Uint8Array,
812
+ id: number,
813
+ ): void {
814
+ let seq = this.centChunks[cid] - 1;
815
+ let c: Chunk;
816
+ if (seq < 0 || (c = this.chunkAt(cid, seq)).n >= CHUNK_CAP) {
817
+ seq = this.centChunks[cid]++;
818
+ c = new Chunk(new Uint8Array(this.chunkBytes));
819
+ this.cacheChunk(cid * SEQ_SPAN + seq, c);
820
+ }
821
+ const slot = c.n;
822
+ c.ids[slot] = id;
823
+ c.exts[slot] = ext;
824
+ c.buf.set(code, c.codeAt(slot, this.codeBytes));
825
+ c.n = slot + 1;
826
+ this.markDirty(cid, seq, c);
827
+ this.centEntries[cid]++;
828
+ this.dirtyCents.add(cid);
829
+ this.sInsVmap.run(ext, id, cid, seq, slot);
830
+ }
831
+
832
+ /** Tombstone a live ext. Returns false when absent. */
833
+ remove(ext: number): boolean {
834
+ const row = this.sSelVmap.get(ext) as
835
+ | [number, number, number, number]
836
+ | undefined;
837
+ if (!row) return false;
838
+ const [, cid, seq, slot] = row;
839
+ const c = this.chunkAt(cid, seq);
840
+ c.markDead(slot);
841
+ this.markDirty(cid, seq, c);
842
+ this.sDelVmap.run(ext);
843
+ this.live--;
844
+ this.metaDirty = true;
845
+ return true;
846
+ }
847
+
848
+ /** Update the code bound to a live ext. A byte-identical code is a no-op
849
+ * (content-addressed callers re-upsert unchanged vectors wholesale after a
850
+ * restart; each no-op otherwise costs a tombstone + reinsert). */
851
+ update(ext: number, code: Uint8Array): void {
852
+ const row = this.sSelVmap.get(ext) as
853
+ | [number, number, number, number]
854
+ | undefined;
855
+ if (!row) throw new Error(`Unknown external id: ${ext}`);
856
+ this.updateAt(row, ext, code);
857
+ }
858
+
859
+ private updateAt(
860
+ row: [number, number, number, number],
861
+ ext: number,
862
+ code: Uint8Array,
863
+ ): void {
864
+ const [id, cid, seq, slot] = row;
865
+ const c = this.chunkAt(cid, seq);
866
+ const off = c.codeAt(slot, this.codeBytes);
867
+ let same = true;
868
+ for (let i = 0; i < this.codeBytes; i++) {
869
+ if (c.buf[off + i] !== code[i]) {
870
+ same = false;
871
+ break;
872
+ }
873
+ }
874
+ if (same) return;
875
+ c.markDead(slot);
876
+ this.markDirty(cid, seq, c);
877
+ const ncid = this.nearestCid(code);
878
+ this.sDelVmap.run(ext);
879
+ this.appendToCluster(ncid, ext, code, id); // internal id is preserved
880
+ this.totalSlots++;
881
+ this.metaDirty = true;
882
+ if (this.centEntries[ncid] >= SPLIT_MAX) this.split(ncid);
883
+ }
884
+
885
+ /** Flush buffered chunk/cent/meta rows. Call before COMMIT. */
886
+ commitFlush(): void {
887
+ this.flushAll();
888
+ }
889
+
890
+ // ── split ───────────────────────────────────────────────────────────────
891
+
892
+ /** Split a full cluster in two, deterministically. Dead slots are dropped
893
+ * (splits double as incremental compaction). Both halves get fresh
894
+ * majority-bit pivots, so routing sharpens as the corpus grows. */
895
+ private split(cid: number): void {
896
+ const cb = this.codeBytes;
897
+ // Slots (live + dead) this cluster held before the rewrite — the exact
898
+ // amount the rewrite's dead-drop reclaims from the physical count.
899
+ const slotsBefore = this.centEntries[cid];
900
+ // 1. Gather live members.
901
+ const chunks = this.centChunks[cid];
902
+ const exts: number[] = [];
903
+ const ids: number[] = [];
904
+ const codes: Uint8Array[] = [];
905
+ for (let seq = 0; seq < chunks; seq++) {
906
+ const c = this.chunkAt(cid, seq);
907
+ for (let s = 0; s < c.n; s++) {
908
+ if (c.isDead(s)) continue;
909
+ exts.push(c.exts[s]);
910
+ ids.push(c.ids[s]);
911
+ const off = c.codeAt(s, cb);
912
+ codes.push(c.buf.slice(off, off + cb));
913
+ }
914
+ }
915
+ const n = codes.length;
916
+ if (n < 2) {
917
+ // Degenerate (a cluster emptied by removes): rewrite in place, dropping
918
+ // dead slots; no second cluster is minted.
919
+ const oc = this.centChunks[cid];
920
+ this.rebuildCluster(cid, oc, codes, exts, ids, null, 0);
921
+ this.totalSlots -= slotsBefore - n;
922
+ this.metaDirty = true;
923
+ return;
924
+ }
925
+ const pivot = this.centCodes.subarray(cid * cb, cid * cb + cb);
926
+
927
+ // 2. Deterministic 2-seed choice: farthest from the old pivot, then
928
+ // farthest from the first seed (ties → lowest index).
929
+ const dist = (a: Uint8Array, b: Uint8Array) =>
930
+ this.quantizer.codeDistanceBytes(a, b);
931
+ let ai = 0, bd = -1;
932
+ for (let i = 0; i < n; i++) {
933
+ const d = dist(codes[i], pivot);
934
+ if (d > bd) {
935
+ bd = d;
936
+ ai = i;
937
+ }
938
+ }
939
+ let bi = ai === 0 ? 1 % Math.max(n, 1) : 0;
940
+ bd = -1;
941
+ for (let i = 0; i < n; i++) {
942
+ if (i === ai) continue;
943
+ const d = dist(codes[i], codes[ai]);
944
+ if (d > bd) {
945
+ bd = d;
946
+ bi = i;
947
+ }
948
+ }
949
+
950
+ // 3. MEDIAN split on the two-seed margin. Assigning each member to its
951
+ // nearer seed degenerates on tie-heavy code distributions (everything
952
+ // lands on one side, the full side immediately re-splits, and cluster
953
+ // count cascades) — the median cut guarantees two n/2 halves whatever
954
+ // the geometry, so a split always halves the cluster. Ties break by
955
+ // insertion index: deterministic.
956
+ const seedA0 = codes[ai], seedB0 = codes[bi];
957
+ const margin = new Float64Array(n);
958
+ for (let i = 0; i < n; i++) {
959
+ margin[i] = dist(codes[i], seedA0) - dist(codes[i], seedB0);
960
+ }
961
+ const order = new Array<number>(n);
962
+ for (let i = 0; i < n; i++) order[i] = i;
963
+ order.sort((x, y) => margin[x] - margin[y] || x - y);
964
+ const side = new Uint8Array(n);
965
+ for (let s = n >> 1; s < n; s++) side[order[s]] = 1;
966
+ // Fresh majority pivots for both halves — routing sharpens with the data.
967
+ const seedA = this.majorityCode(codes, side, 0) ?? seedA0;
968
+ const seedB = this.majorityCode(codes, side, 1) ?? seedB0;
969
+
970
+ // 4. Rewrite: side-0 stays in cid, side-1 becomes a fresh cluster.
971
+ const oldChunks = this.centChunks[cid];
972
+ const ncid = this.newCluster(seedB);
973
+ this.centCodes.set(seedA, cid * cb);
974
+ this.markSuperStale(cid);
975
+ this.rebuildCluster(cid, oldChunks, codes, exts, ids, side, 0);
976
+ this.rebuildCluster(ncid, 0, codes, exts, ids, side, 1);
977
+ // Dead slots vanished in the rewrite.
978
+ this.totalSlots -= slotsBefore - n;
979
+ this.metaDirty = true;
980
+ this.dirtyCents.add(cid);
981
+ this.dirtyCents.add(ncid);
982
+ }
983
+
984
+ /** Bit-majority sample size per split side. A pivot is a routing aid, not
985
+ * a stored value: the majority bit of a 512-member deterministic sample
986
+ * agrees with the full-population majority except on near-tied bits,
987
+ * where either choice routes equally well — and counting every member of
988
+ * a 4096-entry cluster was 25% of bulk-load CPU (profiled). */
989
+ private static readonly MAJORITY_SAMPLE = 512;
990
+
991
+ /** Majority-bit code of the side's members (the binary centroid), or null
992
+ * when the side is empty. Members are sampled on a deterministic stride
993
+ * when the side exceeds {@link MAJORITY_SAMPLE}. */
994
+ private majorityCode(
995
+ codes: Uint8Array[],
996
+ side: Uint8Array,
997
+ which: number,
998
+ ): Uint8Array | null {
999
+ const cb = this.codeBytes;
1000
+ let total = 0;
1001
+ for (let i = 0; i < codes.length; i++) if (side[i] === which) total++;
1002
+ if (total === 0) return null;
1003
+ const stride = Math.max(1, Math.ceil(total / IvfIndex.MAJORITY_SAMPLE));
1004
+ const counts = new Int32Array(cb * 8);
1005
+ let m = 0;
1006
+ let seen = 0;
1007
+ for (let i = 0; i < codes.length; i++) {
1008
+ if (side[i] !== which) continue;
1009
+ if (seen++ % stride !== 0) continue;
1010
+ m++;
1011
+ const c = codes[i];
1012
+ for (let p = 0; p < cb; p++) {
1013
+ const byte = c[p];
1014
+ if (byte === 0) continue;
1015
+ const base = p * 8;
1016
+ for (let k = 0; k < 8; k++) if (byte & (1 << k)) counts[base + k]++;
1017
+ }
1018
+ }
1019
+ const out = new Uint8Array(cb);
1020
+ const half = m / 2;
1021
+ for (let p = 0; p < cb; p++) {
1022
+ let byte = 0;
1023
+ const base = p * 8;
1024
+ for (let k = 0; k < 8; k++) if (counts[base + k] > half) byte |= 1 << k;
1025
+ out[p] = byte;
1026
+ }
1027
+ return out;
1028
+ }
1029
+
1030
+ /** Rewrite a cluster's chunks from a member list (one side of a split or a
1031
+ * compaction survivor set), updating vmap rows. `dropFrom` deletes any
1032
+ * leftover chunk rows beyond the new count. */
1033
+ private rebuildCluster(
1034
+ cid: number,
1035
+ dropFrom: number,
1036
+ codes: Uint8Array[],
1037
+ exts: number[],
1038
+ ids: number[],
1039
+ side: Uint8Array | null,
1040
+ which: number,
1041
+ ): void {
1042
+ const cb = this.codeBytes;
1043
+ let seq = 0;
1044
+ let c = new Chunk(new Uint8Array(this.chunkBytes));
1045
+ let slot = 0;
1046
+ let entries = 0;
1047
+ const vrows: number[] = []; // flat (ext, id, cid, seq, slot) tuples
1048
+ for (let i = 0; i < codes.length; i++) {
1049
+ if (side !== null && side[i] !== which) continue;
1050
+ if (slot === CHUNK_CAP) {
1051
+ this.sUpsChunk.run(cid * SEQ_SPAN + seq, c.buf);
1052
+ this.chunkCache.delete(cid * SEQ_SPAN + seq);
1053
+ this.dirtyChunks.delete(cid * SEQ_SPAN + seq);
1054
+ seq++;
1055
+ c = new Chunk(new Uint8Array(this.chunkBytes));
1056
+ slot = 0;
1057
+ }
1058
+ c.ids[slot] = ids[i];
1059
+ c.exts[slot] = exts[i];
1060
+ c.buf.set(codes[i], c.codeAt(slot, cb));
1061
+ c.n = slot + 1;
1062
+ // (ext, id, cid, seq, slot) — flushed below as multi-row replaces; one
1063
+ // statement dispatch per member made vmap rewriting the dominant split
1064
+ // cost (an entry is rewritten at every split it lives through).
1065
+ vrows.push(exts[i], ids[i], cid, seq, slot);
1066
+ slot++;
1067
+ entries++;
1068
+ }
1069
+ this.flushVmapRows(vrows);
1070
+ const usedChunks = entries === 0 ? 0 : seq + 1;
1071
+ if (entries > 0) {
1072
+ this.sUpsChunk.run(cid * SEQ_SPAN + seq, c.buf);
1073
+ this.chunkCache.delete(cid * SEQ_SPAN + seq);
1074
+ this.dirtyChunks.delete(cid * SEQ_SPAN + seq);
1075
+ }
1076
+ for (let s = usedChunks; s < dropFrom; s++) {
1077
+ this.sDelChunk.run(cid * SEQ_SPAN + s);
1078
+ this.chunkCache.delete(cid * SEQ_SPAN + s);
1079
+ this.dirtyChunks.delete(cid * SEQ_SPAN + s);
1080
+ }
1081
+ this.centChunks[cid] = usedChunks;
1082
+ this.centEntries[cid] = entries;
1083
+ this.dirtyCents.add(cid);
1084
+ }
1085
+
1086
+ /** Multi-row vmap statements, prepared per row count (powers of a fixed
1087
+ * batch width) — REPLACE semantics on the ext primary key. */
1088
+ private readonly vmapBatchStmts = new Map<number, StatementSync>();
1089
+ // 5 params/row → 320 params per dispatch. Wider batches lose more to the
1090
+ // per-argument spread/bind overhead than they save in dispatches (measured
1091
+ // at 256 rows: the spread itself became 15% of a bulk load).
1092
+ private static readonly VMAP_BATCH = 64;
1093
+
1094
+ private vmapBatchStmt(rows: number): StatementSync {
1095
+ let s = this.vmapBatchStmts.get(rows);
1096
+ if (s === undefined) {
1097
+ s = this.db.prepare(
1098
+ "INSERT OR REPLACE INTO vmap (ext, id, cid, seq, slot) VALUES " +
1099
+ "(?,?,?,?,?),".repeat(rows - 1) + "(?,?,?,?,?)",
1100
+ );
1101
+ this.vmapBatchStmts.set(rows, s);
1102
+ }
1103
+ return s;
1104
+ }
1105
+
1106
+ /** Write flat (ext, id, cid, seq, slot) tuples in wide batches. */
1107
+ private flushVmapRows(vrows: number[]): void {
1108
+ const W = IvfIndex.VMAP_BATCH * 5;
1109
+ let o = 0;
1110
+ for (; o + W <= vrows.length; o += W) {
1111
+ this.vmapBatchStmt(IvfIndex.VMAP_BATCH).run(...vrows.slice(o, o + W));
1112
+ }
1113
+ const rest = (vrows.length - o) / 5;
1114
+ if (rest > 0) this.vmapBatchStmt(rest).run(...vrows.slice(o));
1115
+ }
1116
+
1117
+ // ── query ───────────────────────────────────────────────────────────────
1118
+
1119
+ /** ef → clusters probed. ef is the familiar "candidate breadth" knob; a
1120
+ * probe scans one whole cluster, so nprobe = ef/4 keeps the default
1121
+ * (ef 64 → 16 probes) both accurate on trained stores and bounded. */
1122
+ private nprobeOf(ef: number): number {
1123
+ return Math.max(1, Math.ceil(ef / 4));
1124
+ }
1125
+
1126
+ /** k-NN with a full-precision query (accurate RaBitQ estimator). */
1127
+ searchKnn(vec: ArrayLike<number>, k: number, ef?: number): IvfHit[] {
1128
+ this.lastQueryDistComps = 0;
1129
+ this.resetReads();
1130
+ if (this.live === 0 || k <= 0) {
1131
+ this.lastQueryStorageReads = 0;
1132
+ return [];
1133
+ }
1134
+ const q = this.quantizer.prepareQuery(vec);
1135
+ const hits = this.scanClusters(
1136
+ this.probeOrder(q, this.nprobeOf(ef ?? this._efSearch)),
1137
+ k,
1138
+ (buf, off) => {
1139
+ this.lastQueryDistComps++;
1140
+ return this.quantizer.estimate(buf, off, q);
1141
+ },
1142
+ );
1143
+ this.lastQueryStorageReads = this.reads;
1144
+ return hits;
1145
+ }
1146
+
1147
+ /** k-NN with an already-quantized code (sign-bit Hamming distance). */
1148
+ searchKnnByCode(code: Uint8Array, k: number, ef?: number): IvfHit[] {
1149
+ this.lastQueryDistComps = 0;
1150
+ this.resetReads();
1151
+ if (this.live === 0 || k <= 0) {
1152
+ this.lastQueryStorageReads = 0;
1153
+ return [];
1154
+ }
1155
+ const cb = this.codeBytes;
1156
+ const hits = this.scanClusters(
1157
+ this.probeOrderByCode(code, this.nprobeOf(ef ?? this._efSearch)),
1158
+ k,
1159
+ (buf, off) => {
1160
+ this.lastQueryDistComps++;
1161
+ return this.quantizer.codeDistanceBytes(
1162
+ code,
1163
+ buf.subarray(off, off + cb),
1164
+ );
1165
+ },
1166
+ );
1167
+ this.lastQueryStorageReads = this.reads;
1168
+ return hits;
1169
+ }
1170
+
1171
+ private scanClusters(
1172
+ cids: number[],
1173
+ k: number,
1174
+ distAt: (buf: Uint8Array, off: number) => number,
1175
+ ): IvfHit[] {
1176
+ const cb = this.codeBytes;
1177
+ const top = new TopK(k);
1178
+ const retain = this.cacheEnabled; // reads stay honest with the budget off
1179
+ for (const cid of cids) {
1180
+ const chunks = this.centChunks[cid];
1181
+ for (let seq = 0; seq < chunks; seq++) {
1182
+ const c = this.chunkAt(cid, seq, retain);
1183
+ const n = c.n;
1184
+ // Tombstones are rare (updates/deletes only); when the bitmap is
1185
+ // clear the whole chunk scans without a per-slot dead test.
1186
+ const anyDead = c.dead[0] | c.dead[1] | c.dead[2] | c.dead[3] |
1187
+ c.dead[4] | c.dead[5] | c.dead[6] | c.dead[7];
1188
+ let off = CODES_OFF;
1189
+ if (anyDead === 0) {
1190
+ for (let s = 0; s < n; s++, off += cb) {
1191
+ const d = distAt(c.buf, off);
1192
+ if (d < top.worst) top.push(d, c.exts[s]);
1193
+ }
1194
+ } else {
1195
+ for (let s = 0; s < n; s++, off += cb) {
1196
+ if (c.isDead(s)) continue;
1197
+ const d = distAt(c.buf, off);
1198
+ if (d < top.worst) top.push(d, c.exts[s]);
1199
+ }
1200
+ }
1201
+ }
1202
+ }
1203
+ return top.drain();
1204
+ }
1205
+
1206
+ // ── maintenance ─────────────────────────────────────────────────────────
1207
+
1208
+ /** Stream live entries whose internal id is > `after`, in id order.
1209
+ * Internal ids are monotone at insert and PRESERVED by update/compact, so
1210
+ * the largest id a caller has seen is a durable incremental watermark. */
1211
+ *keysSince(
1212
+ after: number,
1213
+ batch = 1024,
1214
+ ): IterableIterator<{ ext: number; internal: number }> {
1215
+ const q = this.db.prepare(
1216
+ "SELECT ext, id FROM vmap WHERE id > ? ORDER BY id LIMIT ?",
1217
+ );
1218
+ let cursor = after;
1219
+ for (;;) {
1220
+ const rows = q.all(cursor, batch) as Array<{ ext: number; id: number }>;
1221
+ if (rows.length === 0) break;
1222
+ for (const r of rows) yield { ext: r.ext, internal: r.id };
1223
+ cursor = rows[rows.length - 1].id;
1224
+ }
1225
+ }
1226
+
1227
+ /** Stream every live external id. */
1228
+ *keys(): IterableIterator<number> {
1229
+ for (const { ext } of this.keysSince(0)) yield ext;
1230
+ }
1231
+
1232
+ /** Drop tombstoned slots by rewriting each cluster that carries any, then
1233
+ * VACUUM. Internal ids and cluster assignment are preserved — routing
1234
+ * quality is untouched, only dead space is reclaimed. */
1235
+ compact(): void {
1236
+ this.db.exec("BEGIN");
1237
+ try {
1238
+ const cb = this.codeBytes;
1239
+ for (let cid = 0; cid < this.K; cid++) {
1240
+ const chunks = this.centChunks[cid];
1241
+ if (chunks === 0) continue;
1242
+ let hasDead = false;
1243
+ const exts: number[] = [];
1244
+ const ids: number[] = [];
1245
+ const codes: Uint8Array[] = [];
1246
+ for (let seq = 0; seq < chunks; seq++) {
1247
+ const c = this.chunkAt(cid, seq);
1248
+ for (let s = 0; s < c.n; s++) {
1249
+ if (c.isDead(s)) {
1250
+ hasDead = true;
1251
+ continue;
1252
+ }
1253
+ exts.push(c.exts[s]);
1254
+ ids.push(c.ids[s]);
1255
+ const off = c.codeAt(s, cb);
1256
+ codes.push(c.buf.slice(off, off + cb));
1257
+ }
1258
+ }
1259
+ if (!hasDead) continue;
1260
+ this.rebuildCluster(cid, chunks, codes, exts, ids, null, 0);
1261
+ }
1262
+ this.totalSlots = this.live;
1263
+ this.metaDirty = true;
1264
+ this.flushAll();
1265
+ this.db.exec("COMMIT");
1266
+ } catch (e) {
1267
+ this.db.exec("ROLLBACK");
1268
+ throw e;
1269
+ }
1270
+ this.chunkCache.clear();
1271
+ this.db.exec("VACUUM");
1272
+ }
1273
+
1274
+ /** Heat SQLite's page cache with sequential scans of the chunk and vmap
1275
+ * tables — a cold session otherwise warms it through random point reads.
1276
+ * Purely a latency optimisation; returns rows touched. */
1277
+ warmCache(): number {
1278
+ let n = 0;
1279
+ // Selecting the blob itself (not just the key) pulls the overflow pages
1280
+ // through the page cache — a key-only scan touches only the B-tree.
1281
+ for (
1282
+ const _ of this.db.prepare("SELECT blob FROM chunk").iterate()
1283
+ ) n++;
1284
+ for (
1285
+ const _ of this.db.prepare("SELECT ext, id FROM vmap").iterate()
1286
+ ) n++;
1287
+ return n;
1288
+ }
1289
+
1290
+ begin(): void {
1291
+ this.db.exec("BEGIN");
1292
+ }
1293
+ commit(): void {
1294
+ this.flushAll();
1295
+ this.db.exec("COMMIT");
1296
+ }
1297
+ rollback(): void {
1298
+ // Buffered state may be ahead of the rolled-back rows — reload from disk.
1299
+ this.chunkCache.clear();
1300
+ this.dirtyChunks.clear();
1301
+ this.dirtyCents.clear();
1302
+ this.metaDirty = false;
1303
+ this.db.exec("ROLLBACK");
1304
+ const row = this.db.prepare(
1305
+ "SELECT live, total, next_id FROM meta WHERE id = 0",
1306
+ ).get() as { live: number; total: number; next_id: number };
1307
+ this.live = row.live;
1308
+ this.totalSlots = row.total;
1309
+ this.nextId = row.next_id;
1310
+ this.loadCents();
1311
+ }
1312
+
1313
+ /** Encode a raw vector to its 1-bit code bytes. */
1314
+ encodeToBytes(vec: ArrayLike<number>): Uint8Array {
1315
+ return this.quantizer.codeToBytes(this.quantizer.encode(vec));
1316
+ }
1317
+ codeToBytes(code: ArrayLike<number>): Uint8Array {
1318
+ return this.quantizer.codeToBytes(code);
1319
+ }
1320
+ bytesToCode(bytes: Uint8Array): Uint32Array {
1321
+ return this.quantizer.bytesToCode(bytes);
1322
+ }
1323
+ get codeWords(): number {
1324
+ return this.quantizer.codeWords;
1325
+ }
1326
+
1327
+ close(): void {
1328
+ this.db.close();
1329
+ }
1330
+ }