@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
@@ -115,6 +115,30 @@ export function edgeAncestors(
115
115
  const hit = memo?.get(id);
116
116
  if (hit !== undefined) return hit;
117
117
 
118
+ // BYTE-ATOM COMMONALITY. A single-byte leaf (implicit negative id) has no
119
+ // structural parents BY CONSTRUCTION — atoms are never linked into the kid
120
+ // or contain tables — so this climb cannot observe its containment at all.
121
+ // The walk below would see only the atom's own direct edges and report
122
+ // contextsReached ≈ 1, turning the MOST common content in the store into
123
+ // the MOST discriminative voter (observed on a 325K-context store: every
124
+ // recognised single-letter site voted full ln N for the one fact whose
125
+ // continuation is that letter, and their pooled sum out-voted every
126
+ // genuine anchor). An unmeasurable containment must not default to
127
+ // "maximally rare": it is bounded below by the uniform expectation over
128
+ // the byte alphabet — N contexts, each at least one chunk of up to W of
129
+ // the 256 possible atoms, reach ≥ N·W/256 contexts per atom on average
130
+ // (see {@link atomReach}). When that floor itself exceeds the hub bound
131
+ // √N the atom is a hub at this corpus scale and the climb abstains
132
+ // (saturated) — the atom's own edges remain fully traversable (tier-0
133
+ // exact recall, chooseNext, project); only its say as a consensus voter
134
+ // is withdrawn. On a small store the floor stays ≤ √N and the atom
135
+ // climbs exactly as before, so single-letter facts keep working.
136
+ if (id < 0 && atomIsHub(ctx, contextCount)) {
137
+ const reach = { roots: [], contextsReached: 0, saturated: true };
138
+ memo?.set(id, reach);
139
+ return reach;
140
+ }
141
+
118
142
  const bound = Math.ceil(Math.sqrt(contextCount));
119
143
  const roots: number[] = [];
120
144
  const seen = new Set<number>([id]);
@@ -257,6 +281,29 @@ export function prevOf(ctx: MindContext, id: number): number[] {
257
281
  return ctx.store.prev(id);
258
282
  }
259
283
 
284
+ /** The uniform-expectation floor on a byte atom's corpus commonality: N
285
+ * learnt contexts, each at least one perception chunk of up to W of the 256
286
+ * possible byte values, contain a given atom in ≥ N·W/256 contexts on
287
+ * average. An atom's TRUE containment is unmeasurable (atoms carry no
288
+ * kid/contain links by construction), so this floor is the honest stand-in:
289
+ * derived entirely from the corpus scale N, the perception window W, and
290
+ * the alphabet size — never tuned. */
291
+ export function atomReach(ctx: MindContext, contextCount: number): number {
292
+ return Math.max(
293
+ 1,
294
+ Math.ceil((contextCount * ctx.space.maxGroup) / 256),
295
+ );
296
+ }
297
+
298
+ /** Whether a byte atom is a hub at this corpus scale — its commonality floor
299
+ * {@link atomReach} exceeds the hub bound √N. Below it (small stores) an
300
+ * atom votes and is recognised exactly as any stored form; above it the
301
+ * alphabet is scaffolding everywhere and abstains. */
302
+ export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
303
+ return atomReach(ctx, contextCount) >
304
+ Math.ceil(Math.sqrt(Math.max(2, contextCount)));
305
+ }
306
+
260
307
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
261
308
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
262
309
  * §15.3): a form that leads nowhere contributes nothing to any derivation.
package/src/mind/types.ts CHANGED
@@ -144,28 +144,27 @@ export interface MindContext extends GraphSearchHost {
144
144
  cfg: MindConfig;
145
145
  search: GraphSearch;
146
146
  trace: Rationale | null;
147
- climbMemo: WeakMap<Uint8Array, Map<string, AttentionRead>> | null;
148
- /** Per-response memo of {@link recognise} keyed by the byte-array OBJECT
149
- * (think, articulate, and the post-grounding pre-consume all recognise the
150
- * same query/answer objects). Valid because the store is read-only while
151
- * a response is in flight; bypassed when a trace is attached so every
152
- * recognise still emits its rationale step. Null outside respond(). */
153
- recogniseMemo: WeakMap<Uint8Array, Recognition> | null;
154
- /** Per-response memo of {@link perceive} keyed by the byte-array OBJECT —
155
- * the GENERAL memo the result-level ones (recogniseMemo, climbMemo,
156
- * _gistCache) each partially compensate for: resolve(), gistOf(), and
157
- * every mechanism's re-perception of the same query/answer object hit it
158
- * (a reason hop used to fold the same answer three times). Valid because
159
- * the store is read-only while a response is in flight and perception is
160
- * a pure function of bytes; only inference-shaped calls (plain Uint8Array,
161
- * no leafAt/lookup capabilities) are memoised, so the deposit path never
162
- * sees it. Keyed by CONTENT (latin1 of the bytes), not object identity —
163
- * mechanisms materialise the same span in fresh subarrays constantly
164
- * (measured on a trained store: 46% of one response's perceptions were
165
- * byte-identical repeats an identity key missed). NOT bypassed under
166
- * trace — perception emits no rationale steps, so there is nothing a memo
167
- * hit could swallow. Null outside respond(). */
147
+ /** Memo of the consensus climb — content-keyed (latin1) so results
148
+ * persist across conversation turns where the same byte spans recur.
149
+ * Null outside respond(); during respondTurn() the conversation's
150
+ * persistent map is swapped in. */
151
+ climbMemo: Map<string, Map<string, AttentionRead>> | null;
152
+ /** Memo of {@link recognise} content-keyed (latin1) so recognised
153
+ * forms carry forward across conversation turns. Bypassed while a
154
+ * trace is attached. Null outside respond(). */
155
+ recogniseMemo: Map<string, Recognition> | null;
156
+ /** Memo of {@link perceive} content-keyed (latin1). The general
157
+ * cache the result-level memos each partially compensate for. NOT
158
+ * bypassed under trace perception emits no rationale steps.
159
+ * Null outside respond(). */
168
160
  perceiveMemo: Map<string, Sema> | null;
161
+ /** Subtree-resolution cache: Sema node → its store id and byte length.
162
+ * Populated by {@link foldTree} during inference; checked before
163
+ * walking children. When a conversation's pyramid reuses prefix
164
+ * subtrees, this cache lets {@link recognise} skip them entirely —
165
+ * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
166
+ * the Sema objects the pyramid keeps alive). */
167
+ _resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
169
168
  _edgeGuide: Vec | null;
170
169
  _edgeChoice: Map<number, number>;
171
170
  _prevSeen: Set<number> | null;
@@ -0,0 +1,56 @@
1
+ # rabitq-ivf
2
+
3
+ A small TypeScript library for **approximate nearest-neighbour search** using an
4
+ **adaptive partitioned (IVF) index** over **1-bit RaBitQ codes**, with
5
+ **cosine** distance. The entire index lives in **SQLite** — the durable copy of
6
+ every code is on disk, resident memory is bounded (the pivot table plus a capped
7
+ page cache, never the whole collection), and the index survives process
8
+ restarts.
9
+
10
+ The vectors are stored **only** as 1-bit RaBitQ codes — the original vectors are
11
+ never kept — so the index is both fast _and_ tiny on disk:
12
+
13
+ ```
14
+ a 1024-d vector:
15
+ Float32 : 1024 × 4 bytes = 4096 bytes
16
+ RaBitQ 1-bit (code) : 1024 × 1 bit = 128 bytes (32× smaller)
17
+ ```
18
+
19
+ The code _is_ the entire per-vector representation. `get` returns the bare code,
20
+ and `insert`, `update`, and `query` all accept either a raw vector or a code —
21
+ detected by length.
22
+
23
+ ## The index
24
+
25
+ - **Clusters, not a graph.** The collection is partitioned into clusters, each
26
+ with a binary pivot code and its member codes packed in fixed-size chunk
27
+ blobs.
28
+ - **Insert = route + append.** Find the nearest pivot (one linear Hamming scan
29
+ of the RAM-resident pivot table) and append to that cluster's tail chunk. No
30
+ beam search, no neighbour rewiring — per-insert cost is essentially flat in
31
+ collection size.
32
+ - **Query = probe + scan.** Rank all pivots with the accurate RaBitQ estimator,
33
+ scan the `ceil(efSearch/4)` nearest clusters with the same estimator, keep the
34
+ top k. Per-query storage reads are bounded by nprobe × chunks-per-cluster —
35
+ constant once the collection has split.
36
+ - **Adaptive, deterministic splits.** A cluster reaching 4096 entries is
37
+ median-split on the margin between two farthest-point seeds (two exact halves,
38
+ cascade-proof), and both halves get fresh majority-bit pivots. There is no
39
+ RNG: the index is a pure function of the insertion sequence.
40
+ - **Same durability discipline as the rest of Sema**: WAL, batched caller-owned
41
+ transactions (`upsertMany`), 1 KiB pages, 64 MiB WAL autocheckpoint.
42
+
43
+ ## Usage
44
+
45
+ ```ts
46
+ import { VectorDatabase } from "./src/index.js";
47
+
48
+ const db = new VectorDatabase({ dbPath: "vectors.db", dim: 1024 });
49
+ db.upsertMany([{ id: 1, vector: v1 }, { id: 2, vector: v2 }]);
50
+ const hits = db.query(q, 10); // [{ id, distance }]
51
+ db.close();
52
+ ```
53
+
54
+ `lastQueryStorageReads` reports the cache-independent storage-read count of the
55
+ most recent query — the honest scalability witness the test suite asserts on
56
+ (`test/35-ivf.test.mjs` at the repo root).
@@ -0,0 +1,276 @@
1
+ import { IvfHit, IvfIndex } from "./ivf.js";
2
+
3
+ /** External ids are integers only. */
4
+ export type ExternalId = number;
5
+
6
+ export interface DatabaseOptions {
7
+ /** Path to the SQLite database file (use ":memory:" for a transient store). */
8
+ dbPath: string;
9
+ /** Vector dimensionality. Required for a new database; ignored when reopening. */
10
+ dim?: number;
11
+ /** Query breadth: clusters probed per query = ceil(efSearch / 4). Default
12
+ * 64 (16 probes). Tunable at runtime and at reopen. */
13
+ efSearch?: number;
14
+ /** Bits used to scalar-quantise the query for the RaBitQ estimator.
15
+ * Query-side only (stored codes are independent of it), so it is tunable
16
+ * at reopen, like efSearch. Default 8. */
17
+ queryBits?: number;
18
+ /** Number of sign-flip + Hadamard rounds in the random rotation. Default 3. */
19
+ rotationRounds?: number;
20
+ /** Seed for the rotation. Default fixed. */
21
+ seed?: number;
22
+ /** Optional centroid the vectors are centered by before quantisation. */
23
+ centroid?: ArrayLike<number>;
24
+ /** SQLite page-cache budget in MiB. Purely a latency knob: correctness and
25
+ * the per-operation storage-read count are identical with it at 0. */
26
+ cacheSizeMb?: number;
27
+ }
28
+
29
+ export interface QueryResult {
30
+ id: ExternalId;
31
+ /** Estimated cosine distance (1 - cosine). */
32
+ distance: number;
33
+ }
34
+
35
+ export interface StorageStats {
36
+ float32BytesPerVector: number;
37
+ codeBytesPerVector: number;
38
+ bytesPerVector: number;
39
+ compressionRatio: number;
40
+ }
41
+
42
+ /**
43
+ * A persistent vector database: an adaptive PARTITIONED (IVF) index over
44
+ * 1-bit RaBitQ codes (cosine), stored entirely in SQLite at `dbPath`. See
45
+ * ivf.ts for the index design. The original float vectors are never
46
+ * retained — only the sign codes — so a 1024-d vector costs 128 bytes.
47
+ *
48
+ * Inserting is route-and-append: cost is essentially FLAT in collection size
49
+ * (one RAM scan of the pivot table + one chunk append — no per-insert graph
50
+ * walk). Query cost is bounded by nprobe × cluster size — constant in N
51
+ * once the collection is past its first splits.
52
+ */
53
+ export class VectorDatabase {
54
+ private readonly index: IvfIndex;
55
+
56
+ constructor(options: DatabaseOptions) {
57
+ if (
58
+ !options || typeof options.dbPath !== "string" ||
59
+ options.dbPath.length === 0
60
+ ) {
61
+ throw new Error("DatabaseOptions.dbPath (string) is required");
62
+ }
63
+ this.index = new IvfIndex(options.dbPath, options);
64
+ }
65
+
66
+ get dim(): number {
67
+ return this.index.dim;
68
+ }
69
+
70
+ /** Number of live (non-deleted) vectors. */
71
+ get size(): number {
72
+ return this.index.size;
73
+ }
74
+
75
+ /** Physical slot count including tombstones from deletes/updates. */
76
+ get physicalSize(): number {
77
+ return this.index.physicalSize;
78
+ }
79
+
80
+ /** Number of clusters (partitions) currently in the index. */
81
+ get clusterCount(): number {
82
+ return this.index.clusterCount;
83
+ }
84
+
85
+ get efSearch(): number {
86
+ return this.index.efSearch;
87
+ }
88
+ set efSearch(value: number) {
89
+ this.index.efSearch = value;
90
+ }
91
+
92
+ /** Distance computations performed during the most recent query. */
93
+ get lastQueryDistanceComputations(): number {
94
+ return this.index.lastQueryDistComps;
95
+ }
96
+
97
+ /** Storage row reads issued by the most recent query — the honest,
98
+ * cache-independent scalability metric. */
99
+ get lastQueryStorageReads(): number {
100
+ return this.index.lastQueryStorageReads;
101
+ }
102
+
103
+ get storage(): StorageStats {
104
+ const f32 = this.dim * 4;
105
+ const bpv = this.index.bytesPerVector;
106
+ return {
107
+ float32BytesPerVector: f32,
108
+ codeBytesPerVector: bpv,
109
+ bytesPerVector: bpv,
110
+ compressionRatio: f32 / bpv,
111
+ };
112
+ }
113
+
114
+ private checkId(id: ExternalId): number {
115
+ // 32-bit signed range: chunk blobs store exts as int32 — a wider id
116
+ // would silently truncate, so it is rejected at the door instead.
117
+ if (!Number.isInteger(id) || id > 0x7fffffff || id < -0x80000000) {
118
+ throw new Error(`External id must be a 32-bit integer, got ${id}`);
119
+ }
120
+ return id;
121
+ }
122
+
123
+ /** Convert a value to code bytes, selecting by length: `codeWords`
124
+ * elements → an existing 1-bit code; otherwise a raw `dim`-vector. */
125
+ private toCodeBytes(value: ArrayLike<number>): Uint8Array {
126
+ if (value.length === this.index.codeWords) {
127
+ return this.index.codeToBytes(value);
128
+ }
129
+ if (value.length !== this.dim) {
130
+ throw new Error(
131
+ `Vector dimension mismatch: expected ${this.dim}, got ${value.length}`,
132
+ );
133
+ }
134
+ return this.index.encodeToBytes(value);
135
+ }
136
+
137
+ has(id: ExternalId): boolean {
138
+ return this.index.has(this.checkId(id));
139
+ }
140
+
141
+ /** Stream every live external id (bounded memory). */
142
+ *keys(): IterableIterator<ExternalId> {
143
+ yield* this.index.keys();
144
+ }
145
+
146
+ /** Stream live entries whose INTERNAL id is > `after` — a durable
147
+ * incremental watermark (internal ids are monotone at insert and preserved
148
+ * by update and compact). */
149
+ *keysSince(
150
+ after: number,
151
+ ): IterableIterator<{ ext: ExternalId; internal: number }> {
152
+ yield* this.index.keysSince(after);
153
+ }
154
+
155
+ /** Read the stored 1-bit code for an id (a copy as a Uint32Array), or null. */
156
+ get(id: ExternalId): Uint32Array | null {
157
+ const bytes = this.index.codeOf(this.checkId(id));
158
+ return bytes ? this.index.bytesToCode(bytes) : null;
159
+ }
160
+
161
+ insert(id: ExternalId, value: ArrayLike<number>): void {
162
+ this.index.begin();
163
+ try {
164
+ this.index.insert(this.checkId(id), this.toCodeBytes(value));
165
+ this.index.commit();
166
+ } catch (e) {
167
+ this.index.rollback();
168
+ throw e;
169
+ }
170
+ }
171
+
172
+ update(id: ExternalId, value: ArrayLike<number>): void {
173
+ this.index.begin();
174
+ try {
175
+ this.index.update(this.checkId(id), this.toCodeBytes(value));
176
+ this.index.commit();
177
+ } catch (e) {
178
+ this.index.rollback();
179
+ throw e;
180
+ }
181
+ }
182
+
183
+ upsert(id: ExternalId, value: ArrayLike<number>): void {
184
+ this.upsertMany([{ id, vector: value }]);
185
+ }
186
+
187
+ /**
188
+ * Upsert many vectors under ONE transaction — one WAL commit for the whole
189
+ * batch instead of one per vector, with chunk appends to the same cluster
190
+ * coalesced in the index's write-back buffer.
191
+ */
192
+ upsertMany(
193
+ entries: Array<{ id: ExternalId; vector: ArrayLike<number> }>,
194
+ ): void {
195
+ if (entries.length === 0) return;
196
+ this.index.begin();
197
+ try {
198
+ for (const e of entries) {
199
+ // One shared vmap probe decides insert vs update — the per-row point
200
+ // query is the dominant bulk-load cost, so it is never duplicated.
201
+ this.index.upsert(this.checkId(e.id), this.toCodeBytes(e.vector));
202
+ }
203
+ this.index.commit();
204
+ } catch (e) {
205
+ this.index.rollback();
206
+ throw e;
207
+ }
208
+ }
209
+
210
+ /** Delete many ids under ONE transaction. Absent ids are skipped.
211
+ * Returns the number of vectors actually removed. */
212
+ deleteMany(ids: ExternalId[]): number {
213
+ if (ids.length === 0) return 0;
214
+ let removed = 0;
215
+ this.index.begin();
216
+ try {
217
+ for (const id of ids) {
218
+ if (this.index.remove(this.checkId(id))) removed++;
219
+ }
220
+ this.index.commit();
221
+ } catch (e) {
222
+ this.index.rollback();
223
+ throw e;
224
+ }
225
+ return removed;
226
+ }
227
+
228
+ /** Delete the vector bound to an id. Returns false if absent. */
229
+ delete(id: ExternalId): boolean {
230
+ return this.deleteMany([id]) === 1;
231
+ }
232
+
233
+ /** Heat the SQLite page cache with sequential scans (latency only). */
234
+ warmCache(): number {
235
+ return this.index.warmCache();
236
+ }
237
+
238
+ /** Reclaim tombstoned slots by rewriting the clusters that carry any, then
239
+ * VACUUM. Internal ids and cluster assignment are preserved. */
240
+ compact(): void {
241
+ this.index.compact();
242
+ }
243
+
244
+ /**
245
+ * k-NN search. The argument's length selects the mode:
246
+ * - `dim` elements → a raw vector (accurate estimator)
247
+ * - `codeWords` elements → a 1-bit code, by sign-bit Hamming
248
+ */
249
+ query(
250
+ query: ArrayLike<number>,
251
+ k = 10,
252
+ opts?: { ef?: number },
253
+ ): QueryResult[] {
254
+ let hits: IvfHit[];
255
+ if (query.length === this.index.codeWords) {
256
+ hits = this.index.searchKnnByCode(
257
+ this.index.codeToBytes(query),
258
+ k,
259
+ opts?.ef,
260
+ );
261
+ } else {
262
+ if (query.length !== this.dim) {
263
+ throw new Error(
264
+ `Vector dimension mismatch: expected ${this.dim}, got ${query.length}`,
265
+ );
266
+ }
267
+ hits = this.index.searchKnn(query, k, opts?.ef);
268
+ }
269
+ return hits;
270
+ }
271
+
272
+ /** Close the underlying database. The instance must not be used afterwards. */
273
+ close(): void {
274
+ this.index.close();
275
+ }
276
+ }
@@ -5,11 +5,8 @@ export type {
5
5
  QueryResult,
6
6
  StorageStats,
7
7
  } from "./database.js";
8
- export { HnswIndex } from "./hnsw.js";
9
- export type { HnswParams, KnnHit } from "./hnsw.js";
8
+ export { IvfIndex } from "./ivf.js";
9
+ export type { IvfConfig, IvfHit } from "./ivf.js";
10
10
  export { RaBitQuantizer } from "./rabitq.js";
11
11
  export type { QueryContext, RaBitQOptions } from "./rabitq.js";
12
- export { Store } from "./store.js";
13
- export type { GlobalState, NodeRec, StoreConfig } from "./store.js";
14
- export { Heap } from "./heap.js";
15
12
  export { Prng } from "./prng.js";