@hviana/sema 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +6 -5
  2. package/CITATION.cff +49 -0
  3. package/HOW_IT_WORKS.md +11 -12
  4. package/README.md +7 -5
  5. package/dist/example/train_base.js +13 -10
  6. package/dist/src/alu/src/index.js +1 -1
  7. package/dist/src/config.d.ts +16 -19
  8. package/dist/src/config.js +3 -8
  9. package/dist/src/index.d.ts +2 -2
  10. package/dist/src/index.js +2 -6
  11. package/dist/src/rabitq-ivf/src/database.d.ts +113 -0
  12. package/dist/src/rabitq-ivf/src/database.js +201 -0
  13. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.d.ts +2 -5
  14. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.js +1 -3
  15. package/dist/src/rabitq-ivf/src/ivf.d.ts +200 -0
  16. package/dist/src/rabitq-ivf/src/ivf.js +1165 -0
  17. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.d.ts +1 -1
  18. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.js +1 -1
  19. package/dist/src/store-sqlite.d.ts +26 -1
  20. package/dist/src/store-sqlite.js +190 -8
  21. package/dist/src/store.d.ts +2 -9
  22. package/dist/src/store.js +6 -23
  23. package/example/train_base.ts +13 -10
  24. package/package.json +2 -2
  25. package/src/alu/README.md +1 -1
  26. package/src/alu/src/index.ts +1 -1
  27. package/src/config.ts +19 -27
  28. package/src/index.ts +6 -11
  29. package/src/rabitq-ivf/README.md +56 -0
  30. package/src/rabitq-ivf/src/database.ts +276 -0
  31. package/src/{rabitq-hnsw → rabitq-ivf}/src/index.ts +2 -5
  32. package/src/rabitq-ivf/src/ivf.ts +1330 -0
  33. package/src/{rabitq-hnsw → rabitq-ivf}/src/prng.ts +1 -1
  34. package/src/store-sqlite.ts +196 -9
  35. package/src/store.ts +8 -32
  36. package/test/08-storage.test.mjs +3 -3
  37. package/test/14-scaling.test.mjs +2 -2
  38. package/test/35-ivf.test.mjs +263 -0
  39. package/test/36-bloom.test.mjs +123 -0
  40. package/dist/src/rabitq-hnsw/src/database.d.ts +0 -200
  41. package/dist/src/rabitq-hnsw/src/database.js +0 -388
  42. package/dist/src/rabitq-hnsw/src/heap.d.ts +0 -22
  43. package/dist/src/rabitq-hnsw/src/heap.js +0 -89
  44. package/dist/src/rabitq-hnsw/src/hnsw.d.ts +0 -125
  45. package/dist/src/rabitq-hnsw/src/hnsw.js +0 -474
  46. package/dist/src/rabitq-hnsw/src/store.d.ts +0 -162
  47. package/dist/src/rabitq-hnsw/src/store.js +0 -825
  48. package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +0 -1
  49. package/dist/src/rabitq-hnsw/test/hnsw.test.js +0 -948
  50. package/src/rabitq-hnsw/README.md +0 -303
  51. package/src/rabitq-hnsw/src/database.ts +0 -492
  52. package/src/rabitq-hnsw/src/heap.ts +0 -90
  53. package/src/rabitq-hnsw/src/hnsw.ts +0 -514
  54. package/src/rabitq-hnsw/src/store.ts +0 -994
  55. package/src/rabitq-hnsw/test/hnsw.test.ts +0 -1213
  56. /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.d.ts +0 -0
  57. /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.js +0 -0
  58. /package/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.ts +0 -0
@@ -2,7 +2,7 @@
2
2
  * Small deterministic pseudo-random number generator (mulberry32).
3
3
  *
4
4
  * Pure ECMAScript. It is used so that the random orthogonal rotation of the
5
- * RaBitQ quantizer and the HNSW level assignment are reproducible and can be
5
+ * RaBitQ quantizer rotation is reproducible and can be
6
6
  * regenerated after (de)serialization without storing large matrices.
7
7
  */
8
8
  export declare class Prng {
@@ -2,7 +2,7 @@
2
2
  * Small deterministic pseudo-random number generator (mulberry32).
3
3
  *
4
4
  * Pure ECMAScript. It is used so that the random orthogonal rotation of the
5
- * RaBitQ quantizer and the HNSW level assignment are reproducible and can be
5
+ * RaBitQ quantizer rotation is reproducible and can be
6
6
  * regenerated after (de)serialization without storing large matrices.
7
7
  */
8
8
  export class Prng {
@@ -13,11 +13,21 @@ export interface SQliteStoreOptions extends Partial<StoreConfig> {
13
13
  export declare class SQliteStore extends AbstractStore implements Store {
14
14
  private readonly opts;
15
15
  private readonly vectorCacheMb;
16
+ private readonly sqliteCacheMb;
16
17
  private readonly vectorSeed;
17
18
  private content;
18
19
  private halos;
19
20
  private sqlite;
20
21
  private _inTx;
22
+ /** Bloom filter over every node row's content hash `h`. The dedup probes
23
+ * consult it first: "definitely absent" answers the training hot path's
24
+ * dominant miss case in RAM (profiled: the SQLite probes were 38.7% of
25
+ * ingest wall at 2.9M nodes). INVARIANT: every inserted h is added
26
+ * before the probe that could see it — a false negative would mint a
27
+ * duplicate node and silently break hash-consing. */
28
+ private _bloom;
29
+ /** Dedup probes answered by the filter alone this session (observability). */
30
+ bloomSkips: number;
21
31
  private _insertNode;
22
32
  private _insertKid;
23
33
  private _selContain;
@@ -40,6 +50,22 @@ export declare class SQliteStore extends AbstractStore implements Store {
40
50
  private vectorDbPath;
41
51
  private openVectorDB;
42
52
  protected _dbOpen(): Promise<void>;
53
+ /** Restore the persisted filter and top up the rows a crash left uncovered
54
+ * (id ≥ stored `upto`); a missing/undersized blob forces a full rebuild.
55
+ * Clean-close sessions restart with a 0-row top-up. */
56
+ private _bloomLoad;
57
+ /** Re-add the hashes of rows with id ≥ `from` (the tail a crash cut off).
58
+ * Re-adding a hash twice only re-sets its bits — never a correctness
59
+ * issue — so overlap with the persisted coverage is harmless. */
60
+ private _bloomTopUp;
61
+ /** Build a fresh filter sized for the current node count from one
62
+ * sequential scan of the h column. Called at open when no usable blob is
63
+ * stored, and again whenever growth saturates the bits-per-key budget. */
64
+ private _bloomRebuild;
65
+ /** Persist the filter with its coverage watermark. Runs inside the
66
+ * caller's transaction when one is open (checkpoint/close both flush
67
+ * first). */
68
+ private _bloomPersist;
43
69
  protected _dbClose(): void;
44
70
  protected _dbBeginTx(): void;
45
71
  protected _dbCommitTx(): void;
@@ -109,7 +135,6 @@ export declare class SQliteStore extends AbstractStore implements Store {
109
135
  protected _vecContentUpsert(entries: Array<{
110
136
  id: NodeId;
111
137
  vector: Float32Array;
112
- ef?: number;
113
138
  }>): void;
114
139
  protected _vecContentQuery(v: Float32Array, k: number, ef: number): Array<{
115
140
  id: number;
@@ -1,4 +1,4 @@
1
- // store-sqlite.ts — SQLite + rabitq-hnsw persistence adapter.
1
+ // store-sqlite.ts — SQLite + rabitq-ivf persistence adapter.
2
2
  //
3
3
  // SQliteStore extends AbstractStore and implements only the bare essentials
4
4
  // required for database communication: SQL schema, prepared statements, and
@@ -29,7 +29,7 @@
29
29
  import { DatabaseSync } from "node:sqlite";
30
30
  import { AbstractStore, flatBytesKids, packKids, unpackKids, } from "./store.js";
31
31
  import { DEFAULT_CONFIG } from "./config.js";
32
- import { VectorDatabase } from "./rabitq-hnsw/src/index.js";
32
+ import { VectorDatabase } from "./rabitq-ivf/src/index.js";
33
33
  const SCHEMA = `
34
34
  -- Content-addressed lookup goes through ONE small integer index: h is the
35
35
  -- FNV-1a hash of the row's content key (leaf bytes, or the packed kid ids of a
@@ -44,6 +44,21 @@ CREATE TABLE IF NOT EXISTS node (
44
44
  h INTEGER NOT NULL
45
45
  );
46
46
  CREATE INDEX IF NOT EXISTS idx_node_h ON node(h);
47
+ -- Negative-lookup BLOOM FILTER over node.h — the RAM structure that lets the
48
+ -- content-addressed dedup probes (findLeaf/findBranch, the training hot path)
49
+ -- answer "definitely new content" without an idx_node_h descent. Persisted
50
+ -- here at checkpoint/close with upto = the next node id NOT covered; on
51
+ -- open, rows with id >= upto (a crash's tail) are re-added by an incremental
52
+ -- rowid-range scan, so the filter can NEVER be missing an inserted hash — a
53
+ -- false negative would silently break hash-consing (duplicate mints), while
54
+ -- a false positive only costs the SQLite probe that was paid on every call
55
+ -- before the filter existed.
56
+ CREATE TABLE IF NOT EXISTS bloom (
57
+ id INTEGER PRIMARY KEY CHECK (id = 1),
58
+ bits BLOB NOT NULL,
59
+ n INTEGER NOT NULL,
60
+ upto INTEGER NOT NULL
61
+ );
47
62
  -- The reverse structural edge child→parent. A WITHOUT ROWID table clustered on
48
63
  -- (child, parent) IS the lookup index: parents(child) is one B-tree descent to a
49
64
  -- contiguous run, with no separate rowid heap and no secondary index to maintain
@@ -106,9 +121,65 @@ CREATE TABLE IF NOT EXISTS meta (
106
121
  val TEXT NOT NULL
107
122
  );
108
123
  `;
124
+ /** Blocked Bloom filter over 32-bit content hashes. k=4 bit positions per
125
+ * key via double hashing; at the maintained ≥12 bits/key the false-positive
126
+ * rate stays under ~1%, so ≥99% of miss-probes skip SQLite entirely.
127
+ * No deletions (nodes are never deleted), so growth is a rebuild. */
128
+ class NodeBloom {
129
+ bits;
130
+ mask;
131
+ n = 0;
132
+ constructor(log2bits) {
133
+ this.bits = new Uint8Array(1 << (log2bits - 3));
134
+ this.mask = (1 << log2bits) - 1 >>> 0;
135
+ }
136
+ add(h) {
137
+ const h2 = (Math.imul(h, 0x9e3779b1) | 1) >>> 0;
138
+ let x = h >>> 0;
139
+ const bits = this.bits;
140
+ const mask = this.mask;
141
+ for (let i = 0; i < 4; i++) {
142
+ const b = x & mask;
143
+ bits[b >> 3] |= 1 << (b & 7);
144
+ x = (x + h2) >>> 0;
145
+ }
146
+ this.n++;
147
+ }
148
+ mightContain(h) {
149
+ const h2 = (Math.imul(h, 0x9e3779b1) | 1) >>> 0;
150
+ let x = h >>> 0;
151
+ const bits = this.bits;
152
+ const mask = this.mask;
153
+ for (let i = 0; i < 4; i++) {
154
+ const b = x & mask;
155
+ if ((bits[b >> 3] & (1 << (b & 7))) === 0)
156
+ return false;
157
+ x = (x + h2) >>> 0;
158
+ }
159
+ return true;
160
+ }
161
+ /** Whether the filter is due for a bigger rebuild (< 12 bits/key). */
162
+ get saturated() {
163
+ return this.n * 12 > this.bits.length * 8;
164
+ }
165
+ }
166
+ /** log2 of the bloom bit count sized for `n` keys at 16 bits/key, clamped to
167
+ * [17, 30] — 16 KiB floor, 128 MiB ceiling (past ~90M nodes the FPR is
168
+ * allowed to drift up rather than RAM). */
169
+ function bloomLog2For(n) {
170
+ let log2 = 17;
171
+ while (log2 < 30 && (1 << log2) < n * 16)
172
+ log2++;
173
+ return log2;
174
+ }
175
+ /** Below this node count the filter is NOT persisted: a rebuild scan at open
176
+ * is instant at this size, and the blob would dominate a small store's
177
+ * on-disk footprint. */
178
+ const BLOOM_PERSIST_MIN_NODES = 65_536;
109
179
  export class SQliteStore extends AbstractStore {
110
180
  opts;
111
181
  vectorCacheMb;
182
+ sqliteCacheMb;
112
183
  vectorSeed;
113
184
  // ── the two SEPARATE vector indices ───────────────────────────────────
114
185
  content = null; // STORAGE: node gists
@@ -116,6 +187,16 @@ export class SQliteStore extends AbstractStore {
116
187
  sqlite = null;
117
188
  // Deferred write transaction guard.
118
189
  _inTx = false;
190
+ // ── negative dedup filter ──────────────────────────────────────────────
191
+ /** Bloom filter over every node row's content hash `h`. The dedup probes
192
+ * consult it first: "definitely absent" answers the training hot path's
193
+ * dominant miss case in RAM (profiled: the SQLite probes were 38.7% of
194
+ * ingest wall at 2.9M nodes). INVARIANT: every inserted h is added
195
+ * before the probe that could see it — a false negative would mint a
196
+ * duplicate node and silently break hash-consing. */
197
+ _bloom = null;
198
+ /** Dedup probes answered by the filter alone this session (observability). */
199
+ bloomSkips = 0;
119
200
  _insertNode = null;
120
201
  _insertKid = null;
121
202
  _selContain = null;
@@ -139,10 +220,6 @@ export class SQliteStore extends AbstractStore {
139
220
  // Resolve config from opts, falling back to defaults.
140
221
  const config = {
141
222
  minHaloMass: opts.minHaloMass ?? d.minHaloMass,
142
- m: opts.m ?? d.m,
143
- efConstruction: opts.efConstruction ?? d.efConstruction,
144
- efConstructionInterior: opts.efConstructionInterior ??
145
- d.efConstructionInterior,
146
223
  efSearch: opts.efSearch ?? d.efSearch,
147
224
  compactEveryNWrites: opts.compactEveryNWrites ?? d.compactEveryNWrites,
148
225
  overfetch: opts.overfetch ?? d.overfetch,
@@ -154,6 +231,7 @@ export class SQliteStore extends AbstractStore {
154
231
  pendingGistBytes: opts.pendingGistBytes ?? d.pendingGistBytes,
155
232
  haloCacheBytes: opts.haloCacheBytes ?? d.haloCacheBytes,
156
233
  vectorCacheMb: opts.vectorCacheMb ?? d.vectorCacheMb,
234
+ sqliteCacheMb: opts.sqliteCacheMb ?? d.sqliteCacheMb,
157
235
  coveredIdsMax: opts.coveredIdsMax ?? d.coveredIdsMax,
158
236
  chainCacheBytes: opts.chainCacheBytes ?? d.chainCacheBytes,
159
237
  };
@@ -162,6 +240,7 @@ export class SQliteStore extends AbstractStore {
162
240
  super(config, D, maxGroup);
163
241
  this.opts = opts;
164
242
  this.vectorCacheMb = opts.vectorCacheMb ?? d.vectorCacheMb;
243
+ this.sqliteCacheMb = opts.sqliteCacheMb ?? d.sqliteCacheMb;
165
244
  this.vectorSeed = (0x51f15e ^ 0x9e3779b9) >>> 0;
166
245
  this._ready = this._dbOpen();
167
246
  }
@@ -176,8 +255,6 @@ export class SQliteStore extends AbstractStore {
176
255
  return new VectorDatabase({
177
256
  dbPath: this.vectorDbPath(name),
178
257
  dim: this.D,
179
- M: this.m,
180
- efConstruction: this.efConstruction,
181
258
  efSearch: this.efSearch,
182
259
  // Query-side estimator precision. 8 bits: 4 bits measurably misranks
183
260
  // tight gist clusters (mixture recall@10 37.5% vs 39.0%, self-recall
@@ -207,6 +284,26 @@ export class SQliteStore extends AbstractStore {
207
284
  // WAL + NORMAL sync: crash-safe (WAL commits are atomic) without a full
208
285
  // fsync per commit — the same discipline the vector databases use.
209
286
  this.sqlite.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
287
+ // SQLite's autocheckpoint default is 1000 PAGES — 1 MiB at this store's
288
+ // 1 KiB page size — so at training write rates nearly every batch commit
289
+ // crossed the threshold and paid a synchronous checkpoint: random writes
290
+ // into a GB-scale main file, with the hottest B-tree pages copied out on
291
+ // every commit instead of being coalesced across commits in the WAL.
292
+ // Profiled on a 7.5M-node store: ~114 ms PER COMMIT, ~19% of ingest
293
+ // wall-clock. 16 MiB of WAL between checkpoints coalesces hot-page
294
+ // rewrites and amortizes the checkpoint's fixed cost; crash-safety is
295
+ // unchanged (WAL recovery replays it), the only trade is a bounded,
296
+ // fixed-size WAL file.
297
+ this.sqlite.exec("PRAGMA wal_autocheckpoint = 65536;");
298
+ // Page cache for the DAG tables. Content-addressed dedup (findLeaf /
299
+ // findBranch), parent probes and contain appends are millions of point
300
+ // queries per session against a file that outgrows SQLite's ~2 MiB
301
+ // default cache almost immediately; every miss is a file read on the
302
+ // ingest hot path. Negative = KiB budget (size-based, page-size
303
+ // independent). Latency only — results identical at any value.
304
+ const cacheKb = Math.max(0, Math.floor(this.sqliteCacheMb * 1024));
305
+ if (cacheKb > 0)
306
+ this.sqlite.exec(`PRAGMA cache_size = -${cacheKb};`);
210
307
  this.sqlite.exec(SCHEMA);
211
308
  // Recover D from a previous run so loadFromStore() can bootstrap with any
212
309
  // default — the real value is read from meta before vector indices load.
@@ -244,8 +341,71 @@ export class SQliteStore extends AbstractStore {
244
341
  // the same value but scans every leaf page, so open time grew linearly
245
342
  // with the store; a per-row scan into a Set was worse still.)
246
343
  this._nextId = (this.sqlite.prepare("SELECT MAX(id) AS m FROM node").get().m ?? -1) + 1;
344
+ this._bloomLoad();
345
+ }
346
+ // ── negative dedup filter: load / rebuild / persist ────────────────────
347
+ /** Restore the persisted filter and top up the rows a crash left uncovered
348
+ * (id ≥ stored `upto`); a missing/undersized blob forces a full rebuild.
349
+ * Clean-close sessions restart with a 0-row top-up. */
350
+ _bloomLoad() {
351
+ const row = this.sqlite.prepare("SELECT bits, n, upto FROM bloom WHERE id = 1").get();
352
+ const wantLog2 = bloomLog2For(this._nextId);
353
+ if (row && row.bits.length * 8 >= (1 << wantLog2)) {
354
+ const log2 = 31 - Math.clz32(row.bits.length * 8);
355
+ const b = new NodeBloom(log2);
356
+ b.bits.set(row.bits);
357
+ b.n = row.n;
358
+ this._bloom = b;
359
+ this._bloomTopUp(row.upto);
360
+ }
361
+ else {
362
+ this._bloomRebuild();
363
+ }
364
+ }
365
+ /** Re-add the hashes of rows with id ≥ `from` (the tail a crash cut off).
366
+ * Re-adding a hash twice only re-sets its bits — never a correctness
367
+ * issue — so overlap with the persisted coverage is harmless. */
368
+ _bloomTopUp(from) {
369
+ if (from >= this._nextId)
370
+ return;
371
+ const b = this._bloom;
372
+ const scan = this.sqlite.prepare("SELECT h FROM node WHERE id >= ?");
373
+ scan.setReturnArrays(true);
374
+ for (const r of scan.iterate(from)) {
375
+ b.add(r[0]);
376
+ }
377
+ }
378
+ /** Build a fresh filter sized for the current node count from one
379
+ * sequential scan of the h column. Called at open when no usable blob is
380
+ * stored, and again whenever growth saturates the bits-per-key budget. */
381
+ _bloomRebuild() {
382
+ const b = new NodeBloom(bloomLog2For(this._nextId));
383
+ b.n = 0;
384
+ if (this._nextId > 0) {
385
+ const scan = this.sqlite.prepare("SELECT h FROM node");
386
+ scan.setReturnArrays(true);
387
+ for (const r of scan.iterate()) {
388
+ // add() counts n itself.
389
+ b.add(r[0]);
390
+ }
391
+ }
392
+ this._bloom = b;
393
+ }
394
+ /** Persist the filter with its coverage watermark. Runs inside the
395
+ * caller's transaction when one is open (checkpoint/close both flush
396
+ * first). */
397
+ _bloomPersist() {
398
+ if (!this._bloom || !this.sqlite)
399
+ return;
400
+ if (this._nextId < BLOOM_PERSIST_MIN_NODES)
401
+ return;
402
+ this.sqlite.prepare("INSERT INTO bloom (id, bits, n, upto) VALUES (1, ?, ?, ?) " +
403
+ "ON CONFLICT(id) DO UPDATE SET bits = excluded.bits, " +
404
+ "n = excluded.n, upto = excluded.upto").run(this._bloom.bits, this._bloom.n, this._nextId);
247
405
  }
248
406
  _dbClose() {
407
+ if (this.sqlite)
408
+ this._bloomPersist();
249
409
  if (this.content) {
250
410
  this.content.close();
251
411
  this.content = null;
@@ -278,6 +438,12 @@ export class SQliteStore extends AbstractStore {
278
438
  this._insertNode = this.sqlite.prepare("INSERT INTO node (id, leaf, kids, h) VALUES (?, ?, ?, ?)");
279
439
  }
280
440
  this._insertNode.run(id, leaf, kids, h);
441
+ // The filter must see every inserted hash BEFORE any probe could — the
442
+ // add sits in the same synchronous call as the row insert.
443
+ const b = this._bloom;
444
+ b.add(h);
445
+ if (b.saturated)
446
+ this._bloomRebuild();
281
447
  }
282
448
  _dbGetNode(id) {
283
449
  if (!this._selNode) {
@@ -298,6 +464,10 @@ export class SQliteStore extends AbstractStore {
298
464
  };
299
465
  }
300
466
  _dbFindLeaf(h, bytes) {
467
+ if (this._bloom && !this._bloom.mightContain(h)) {
468
+ this.bloomSkips++;
469
+ return null;
470
+ }
301
471
  if (!this._selLeaf) {
302
472
  this._selLeaf = this.sqlite.prepare("SELECT id FROM node WHERE h = ? AND leaf = ? AND kids IS NULL LIMIT 1");
303
473
  }
@@ -305,6 +475,10 @@ export class SQliteStore extends AbstractStore {
305
475
  return row ? row.id : null;
306
476
  }
307
477
  _dbFindBranchByLeaf(h, bytes) {
478
+ if (this._bloom && !this._bloom.mightContain(h)) {
479
+ this.bloomSkips++;
480
+ return null;
481
+ }
308
482
  if (!this._selFlat) {
309
483
  this._selFlat = this.sqlite.prepare("SELECT id FROM node WHERE h = ? AND leaf = ? AND kids IS NOT NULL LIMIT 1");
310
484
  }
@@ -312,6 +486,10 @@ export class SQliteStore extends AbstractStore {
312
486
  return row ? row.id : null;
313
487
  }
314
488
  _dbFindBranchByKids(h, packed) {
489
+ if (this._bloom && !this._bloom.mightContain(h)) {
490
+ this.bloomSkips++;
491
+ return null;
492
+ }
315
493
  if (!this._selKids) {
316
494
  this._selKids = this.sqlite.prepare("SELECT id FROM node WHERE h = ? AND kids = ? LIMIT 1");
317
495
  }
@@ -626,6 +804,10 @@ export class SQliteStore extends AbstractStore {
626
804
  "ON CONFLICT(id) DO UPDATE SET data = excluded.data");
627
805
  }
628
806
  this._insSnapshot.run(bytes);
807
+ // Checkpoint cadence: persisting the dedup filter here keeps the crash
808
+ // top-up (the id ≥ upto rescan at next open) bounded by one checkpoint
809
+ // interval instead of the whole session.
810
+ this._bloomPersist();
629
811
  }
630
812
  _dbLoadSnapshot() {
631
813
  if (!this._selSnapshot) {
@@ -324,7 +324,6 @@ export declare abstract class AbstractStore implements Store {
324
324
  protected abstract _vecContentUpsert(entries: Array<{
325
325
  id: NodeId;
326
326
  vector: Float32Array;
327
- ef?: number;
328
327
  }>): void;
329
328
  protected abstract _vecContentQuery(v: Float32Array, k: number, ef: number): Array<{
330
329
  id: number;
@@ -376,9 +375,6 @@ export declare abstract class AbstractStore implements Store {
376
375
  protected _maxGroup: number;
377
376
  protected readonly minHaloMass: number;
378
377
  protected readonly efSearch: number;
379
- protected readonly m: number;
380
- protected readonly efConstruction: number;
381
- protected readonly efConstructionInterior: number;
382
378
  protected readonly overfetch: number;
383
379
  protected readonly batchSize: number;
384
380
  protected readonly compactEveryNWrites: number;
@@ -429,20 +425,17 @@ export declare abstract class AbstractStore implements Store {
429
425
  protected _coveredIds: BoundedMap<NodeId, true>;
430
426
  /** Live content-index id set, LRU-bounded so a massive ingest never leaks
431
427
  * memory; an evicted entry is still indexed (the row is durable), so the
432
- * only cost of an eviction is a duplicate HNSW probe on next visit. */
428
+ * only cost of an eviction is a duplicate index probe on next visit. */
433
429
  protected _indexedIds: BoundedMap<NodeId, true>;
434
430
  /** ANN read cache for {@link resonate} — keyed by vecKey(v) + ":" + k;
435
431
  * lazily initialised, dropped on any index mutation. */
436
432
  protected _resonateCache: Map<string, Hit[]> | null;
437
433
  /** ANN read cache for {@link resonateHalo} — same scheme. */
438
434
  protected _resonateHaloCache: Map<string, Hit[]> | null;
439
- /** Content (gist) index write buffer. `ef` is the per-entry HNSW
440
- * construction budget: reach-only interiors carry the reduced
441
- * `efConstructionInterior`; dedup targets omit it (full budget). */
435
+ /** Content (gist) index write buffer. */
442
436
  protected _contentBuffer: Array<{
443
437
  id: NodeId;
444
438
  vector: Float32Array;
445
- ef?: number;
446
439
  }>;
447
440
  /** Halo index write buffer — keyed by id so repeats within a batch coalesce. */
448
441
  protected _haloBuffer: Map<number, Float32Array<ArrayBufferLike>>;
package/dist/src/store.js CHANGED
@@ -364,9 +364,6 @@ export class AbstractStore {
364
364
  _maxGroup;
365
365
  minHaloMass;
366
366
  efSearch;
367
- m;
368
- efConstruction;
369
- efConstructionInterior;
370
367
  overfetch;
371
368
  batchSize;
372
369
  compactEveryNWrites;
@@ -419,7 +416,7 @@ export class AbstractStore {
419
416
  _coveredIds;
420
417
  /** Live content-index id set, LRU-bounded so a massive ingest never leaks
421
418
  * memory; an evicted entry is still indexed (the row is durable), so the
422
- * only cost of an eviction is a duplicate HNSW probe on next visit. */
419
+ * only cost of an eviction is a duplicate index probe on next visit. */
423
420
  _indexedIds;
424
421
  // ── ANN read cache ─────────────────────────────────────────────────────
425
422
  // The index is read-only between writes, so the same (v,k) always returns
@@ -432,9 +429,7 @@ export class AbstractStore {
432
429
  /** ANN read cache for {@link resonateHalo} — same scheme. */
433
430
  _resonateHaloCache = null;
434
431
  // ── Write buffers ──────────────────────────────────────────────────────
435
- /** Content (gist) index write buffer. `ef` is the per-entry HNSW
436
- * construction budget: reach-only interiors carry the reduced
437
- * `efConstructionInterior`; dedup targets omit it (full budget). */
432
+ /** Content (gist) index write buffer. */
438
433
  _contentBuffer = [];
439
434
  /** Halo index write buffer — keyed by id so repeats within a batch coalesce. */
440
435
  _haloBuffer = new Map();
@@ -466,9 +461,6 @@ export class AbstractStore {
466
461
  this._maxGroup = maxGroup;
467
462
  this.minHaloMass = config.minHaloMass;
468
463
  this.efSearch = config.efSearch;
469
- this.m = config.m;
470
- this.efConstruction = config.efConstruction;
471
- this.efConstructionInterior = Math.max(1, Math.min(config.efConstructionInterior, config.efConstruction));
472
464
  this.overfetch = config.overfetch;
473
465
  this.batchSize = config.batchSize;
474
466
  this.compactEveryNWrites = config.compactEveryNWrites;
@@ -875,7 +867,7 @@ export class AbstractStore {
875
867
  // leaf, and near-merging distinct leaves only corrupts bytes for no real
876
868
  // saving. Real near-dedup compression lives in subtree (branch) fusion.
877
869
  //
878
- // There is deliberately NO HNSW probe of the FLUSHED index here. It used
870
+ // There is deliberately NO ANN probe of the FLUSHED index here. It used
879
871
  // to fire for EVERY new branch that the buffer scan didn't settle — i.e.
880
872
  // ~every interior branch, since interiors are never dedup targets —
881
873
  // making one ANN query per branch the dominant training cost (it dwarfed
@@ -981,7 +973,7 @@ export class AbstractStore {
981
973
  // Capture the gist; it is pushed into the content index lazily, the first
982
974
  // time this node becomes a resonance target (link / pourHalo). A node that
983
975
  // never does (a pure intermediate DAG node — ~99.5% of them) is never
984
- // indexed: it costs one persistence row, no HNSW slot and no merge probe.
976
+ // indexed: it costs one persistence row, no vector-index slot and no merge probe.
985
977
  this._pendingGist.set(id, normalize(copy(gist)));
986
978
  await this.maybeFlush();
987
979
  return id;
@@ -1056,12 +1048,7 @@ export class AbstractStore {
1056
1048
  return;
1057
1049
  }
1058
1050
  this._indexedIds.set(id, true);
1059
- // Reach-only interiors build with the reduced construction budget — the
1060
- // one deliberate speed-for-quality trade of ingestion (see
1061
- // {@link StoreConfig.efConstructionInterior}); targets keep the full one.
1062
- this._contentBuffer.push(dedupTarget
1063
- ? { id, vector: v }
1064
- : { id, vector: v, ef: this.efConstructionInterior });
1051
+ this._contentBuffer.push({ id, vector: v });
1065
1052
  this._bufferedIds.add(id);
1066
1053
  // A node indexed AS a dedup target enters the candidate set immediately.
1067
1054
  if (dedupTarget)
@@ -1344,11 +1331,7 @@ export class AbstractStore {
1344
1331
  // Index it — same code path as indexGist, but the vector is injected
1345
1332
  // directly rather than read from the (empty) pending-gist cache.
1346
1333
  this._indexedIds.set(id, true);
1347
- this._contentBuffer.push({
1348
- id,
1349
- vector: gist,
1350
- ef: this.efConstructionInterior,
1351
- });
1334
+ this._contentBuffer.push({ id, vector: gist });
1352
1335
  this._bufferedIds.add(id);
1353
1336
  // Repaired nodes are reach-indexed, never dedup targets: their gist is
1354
1337
  // regenerated (may differ numerically from the original) and they are
@@ -1610,20 +1610,23 @@ async function saveProgress(store: Store, p: SavedProgress): Promise<void> {
1610
1610
  // ═══════════════════════════════════════════════════════════════════════
1611
1611
 
1612
1612
  async function main(): Promise<void> {
1613
- // The vector indices' single memory knob (MiB) — sizes both the SQLite page
1614
- // cache and the decoded-code LRU of each index. Training is HNSW-insert
1615
- // bound at scale (profiled at >80% of ingest wall-clock on a 5.5M-vector
1616
- // store, nearly all of it cache-missing code reads), and inserts walk the
1617
- // graph, so a cache that covers the indexed codes cuts wall-clock directly
1618
- // (measured ~1. at 1 GB vs 64 MB on a 2M-node store). 1 GB caches ~5.8M
1619
- // D=1024 codes the whole content index of a 350 MB-corpus store — and is
1620
- // the default; override with VECTOR_CACHE_MB (e.g. 256 on a small-RAM
1621
- // machine, 64 for the library default).
1622
- const VECTOR_CACHE_MB = Math.max(0, Number(env("VECTOR_CACHE_MB", "1024")));
1613
+ // The vector indices' memory knob (MiB) — each index's SQLite page cache.
1614
+ // The IVF index routes inserts through a RAM-resident pivot table and
1615
+ // appends to chunk blobs, so this cache mostly serves query-time cluster
1616
+ // scans; 256 MiB comfortably covers the probed working set of a trained
1617
+ // store. Override with VECTOR_CACHE_MB (64 is the library default).
1618
+ const VECTOR_CACHE_MB = Math.max(0, Number(env("VECTOR_CACHE_MB", "256")));
1619
+ // Page cache for the MAIN DAG database (node/kid/edge/contain tables).
1620
+ // Training issues millions of content-addressed point probes per session
1621
+ // against a GB-scale file; the library default (64 MiB) is sized for a
1622
+ // small machine — a training box affords more. Override with
1623
+ // SQLITE_CACHE_MB.
1624
+ const SQLITE_CACHE_MB = Math.max(0, Number(env("SQLITE_CACHE_MB", "256")));
1623
1625
  const store = new SQliteStore({
1624
1626
  path: DB_PATH,
1625
1627
  D,
1626
1628
  vectorCacheMb: VECTOR_CACHE_MB,
1629
+ sqliteCacheMb: SQLITE_CACHE_MB,
1627
1630
  });
1628
1631
 
1629
1632
  // The store IS the model: memories, progress, and metadata all persist in
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.1.4",
4
- "description": "An elementary, recursive, weight-free multimodal mind: one structure, two verbs, one memory. Zero runtime dependencies, pure web standards.",
3
+ "version": "0.1.6",
4
+ "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
7
7
  "types": "dist/src/index.d.ts",
package/src/alu/README.md CHANGED
@@ -15,7 +15,7 @@ search as authoritative axioms (at `STEP` cost, like a learned edge).
15
15
 
16
16
  It has no dependency on the rest of the codebase except the pure byte helpers in
17
17
  `../bytes.ts`, and is intended to be reused as a self-contained sublibrary in
18
- the spirit of `derive/` and `rabitq-hnsw/`.
18
+ the spirit of `derive/` and `rabitq-ivf/`.
19
19
 
20
20
  ## The thesis: one tiny kernel, everything else is a rewrite
21
21
 
@@ -1,7 +1,7 @@
1
1
  // alu — the ALU sub-library: a tiny irreducible kernel from which arithmetic,
2
2
  // logic, and numerical computation are all DERIVED.
3
3
  //
4
- // Self-contained in the spirit of derive/ and rabitq-hnsw/: it imports only the
4
+ // Self-contained in the spirit of derive/ and rabitq-ivf/: it imports only the
5
5
  // pure byte helpers (../../bytes.js) and nothing else from SEMA. The host
6
6
  // reaches meaning (operator synonymy, the symbolic inverse) through the injected
7
7
  // AluResonance, pre-resolved into a synchronous snapshot before the search runs.