@hviana/sema 0.1.0
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.
- package/AGENTS.md +470 -0
- package/AUTHORS.md +12 -0
- package/COMMERCIAL-LICENSE.md +19 -0
- package/CONTRIBUTING.md +15 -0
- package/HOW_IT_WORKS.md +4210 -0
- package/LICENSE.md +117 -0
- package/README.md +290 -0
- package/TRADEMARKS.md +19 -0
- package/example/demo.ts +46 -0
- package/example/train_base.ts +2675 -0
- package/index.html +893 -0
- package/package.json +25 -0
- package/src/alphabet.ts +34 -0
- package/src/alu/README.md +332 -0
- package/src/alu/src/alu.ts +541 -0
- package/src/alu/src/expr.ts +339 -0
- package/src/alu/src/index.ts +115 -0
- package/src/alu/src/kernel-arith.ts +377 -0
- package/src/alu/src/kernel-bits.ts +199 -0
- package/src/alu/src/kernel-logic.ts +102 -0
- package/src/alu/src/kernel-nd.ts +235 -0
- package/src/alu/src/kernel-numeric.ts +466 -0
- package/src/alu/src/operation.ts +344 -0
- package/src/alu/src/parser.ts +574 -0
- package/src/alu/src/resonance.ts +161 -0
- package/src/alu/src/text.ts +83 -0
- package/src/alu/src/value.ts +327 -0
- package/src/alu/test/alu.test.ts +1004 -0
- package/src/bytes.ts +62 -0
- package/src/config.ts +227 -0
- package/src/derive/README.md +295 -0
- package/src/derive/src/deduction.ts +263 -0
- package/src/derive/src/index.ts +24 -0
- package/src/derive/src/priority-queue.ts +70 -0
- package/src/derive/src/rewrite.ts +127 -0
- package/src/derive/src/trie.ts +242 -0
- package/src/derive/test/derive.test.ts +137 -0
- package/src/extension.ts +42 -0
- package/src/geometry.ts +511 -0
- package/src/index.ts +38 -0
- package/src/ingest-cache.ts +224 -0
- package/src/mind/articulation.ts +137 -0
- package/src/mind/attention.ts +1061 -0
- package/src/mind/canonical.ts +107 -0
- package/src/mind/graph-search.ts +1100 -0
- package/src/mind/index.ts +18 -0
- package/src/mind/junction.ts +347 -0
- package/src/mind/learning.ts +246 -0
- package/src/mind/match.ts +507 -0
- package/src/mind/mechanisms/alu.ts +33 -0
- package/src/mind/mechanisms/cast.ts +568 -0
- package/src/mind/mechanisms/confluence.ts +268 -0
- package/src/mind/mechanisms/cover.ts +248 -0
- package/src/mind/mechanisms/extraction.ts +422 -0
- package/src/mind/mechanisms/recall.ts +241 -0
- package/src/mind/mind.ts +483 -0
- package/src/mind/pipeline-mechanism.ts +326 -0
- package/src/mind/pipeline.ts +300 -0
- package/src/mind/primitives.ts +201 -0
- package/src/mind/rationale.ts +275 -0
- package/src/mind/reasoning.ts +198 -0
- package/src/mind/recognition.ts +234 -0
- package/src/mind/resonance.ts +0 -0
- package/src/mind/trace.ts +108 -0
- package/src/mind/traverse.ts +556 -0
- package/src/mind/types.ts +281 -0
- package/src/rabitq-hnsw/README.md +303 -0
- package/src/rabitq-hnsw/src/database.ts +492 -0
- package/src/rabitq-hnsw/src/heap.ts +90 -0
- package/src/rabitq-hnsw/src/hnsw.ts +514 -0
- package/src/rabitq-hnsw/src/index.ts +15 -0
- package/src/rabitq-hnsw/src/prng.ts +39 -0
- package/src/rabitq-hnsw/src/rabitq.ts +308 -0
- package/src/rabitq-hnsw/src/store.ts +994 -0
- package/src/rabitq-hnsw/test/hnsw.test.ts +1213 -0
- package/src/store-sqlite.ts +928 -0
- package/src/store.ts +2148 -0
- package/src/vec.ts +124 -0
- package/test/00-extract.test.mjs +151 -0
- package/test/01-floor.test.mjs +20 -0
- package/test/02-roundtrip.test.mjs +83 -0
- package/test/03-recall.test.mjs +98 -0
- package/test/04-think.test.mjs +627 -0
- package/test/05-concepts.test.mjs +84 -0
- package/test/08-storage.test.mjs +948 -0
- package/test/09-edges.test.mjs +65 -0
- package/test/11-universality.test.mjs +180 -0
- package/test/13-conversation.test.mjs +228 -0
- package/test/14-scaling.test.mjs +503 -0
- package/test/15-decomposition-gap.test.mjs +0 -0
- package/test/16-bridge.test.mjs +250 -0
- package/test/17-intelligence.test.mjs +240 -0
- package/test/18-alu.test.mjs +209 -0
- package/test/19-nd.test.mjs +254 -0
- package/test/20-stability.test.mjs +489 -0
- package/test/21-partial.test.mjs +158 -0
- package/test/22-multihop.test.mjs +130 -0
- package/test/23-rationale.test.mjs +316 -0
- package/test/24-generalization.test.mjs +416 -0
- package/test/25-embedding.test.mjs +75 -0
- package/test/26-cooperative.test.mjs +89 -0
- package/test/27-saturation-drop.test.mjs +637 -0
- package/test/28-unknowable.test.mjs +88 -0
- package/test/29-counterfactual.test.mjs +476 -0
- package/test/30-conflict-resolution.test.mjs +166 -0
- package/test/31-audit.test.mjs +288 -0
- package/test/32-confluence.test.mjs +173 -0
- package/test/33-multi-candidate.test.mjs +222 -0
- package/test/34-cross-region.test.mjs +252 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// types.ts — all interfaces, types, and free functions for the mind.
|
|
2
|
+
//
|
|
3
|
+
// GraphSearchHost is defined first (minimal imports) so GraphSearch can import
|
|
4
|
+
// it without pulling in the full MindContext.
|
|
5
|
+
|
|
6
|
+
import type { Vec } from "../vec.js";
|
|
7
|
+
import type { Sema } from "../sema.js";
|
|
8
|
+
import type { BoundedMap, Store } from "../store.js";
|
|
9
|
+
import type { Space } from "../sema.js";
|
|
10
|
+
import type { Alphabet } from "../alphabet.js";
|
|
11
|
+
import type { MindConfig } from "../config.js";
|
|
12
|
+
import type {
|
|
13
|
+
ComputedResult,
|
|
14
|
+
DerivationItem,
|
|
15
|
+
DerivationStep,
|
|
16
|
+
GraphSearch,
|
|
17
|
+
Leaf,
|
|
18
|
+
Seg,
|
|
19
|
+
Site,
|
|
20
|
+
} from "./graph-search.js";
|
|
21
|
+
import type { Rationale } from "./rationale.js";
|
|
22
|
+
import type { FoldPyramid, Grid } from "../geometry.js";
|
|
23
|
+
import { concatBytes } from "../bytes.js";
|
|
24
|
+
import { dominates } from "../geometry.js";
|
|
25
|
+
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
// PUBLIC TYPES (exported from the package)
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
|
|
30
|
+
export type Input = string | Uint8Array | Grid | Grid[];
|
|
31
|
+
|
|
32
|
+
// NOTE: the public `Response` interface lives in mind/mind.ts (it carries the
|
|
33
|
+
// `provenance` read-out). A second copy briefly lived here and drifted —
|
|
34
|
+
// keep exactly one definition.
|
|
35
|
+
|
|
36
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
37
|
+
// GraphSearchHost — the contract GraphSearch needs (no closures)
|
|
38
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
/** The host capabilities GraphSearch consults during a cover. MindContext
|
|
41
|
+
* extends this so the Mind can pass itself as the host. */
|
|
42
|
+
export interface GraphSearchHost {
|
|
43
|
+
resolve(bytes: Uint8Array): number | null;
|
|
44
|
+
recogniseSpan?(bytes: Uint8Array): {
|
|
45
|
+
sites: ReadonlyArray<Site>;
|
|
46
|
+
leaves: ReadonlyArray<Leaf>;
|
|
47
|
+
splits: ReadonlySet<number>;
|
|
48
|
+
};
|
|
49
|
+
chooseNext?(node: number): number | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
53
|
+
// INTERNAL TYPES
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
export interface Recognition {
|
|
57
|
+
/** Forms that can lead somewhere — they have an edge or a halo. */
|
|
58
|
+
sites: Site[];
|
|
59
|
+
/** The query's perceived leaves (the search's covering axioms). */
|
|
60
|
+
leaves: Leaf[];
|
|
61
|
+
/** Sub-leaf positions where a form boundary falls between leaf edges. */
|
|
62
|
+
splits: Set<number>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How the consensus climb weights a region's Document-Frequency reach. */
|
|
66
|
+
export type DFMode = "inverse" | "direct" | "combined";
|
|
67
|
+
|
|
68
|
+
/** One POINT OF ATTENTION the consensus climb resolved. */
|
|
69
|
+
export interface Attention {
|
|
70
|
+
/** The learnt context this point resolves to. */
|
|
71
|
+
anchor: number;
|
|
72
|
+
/** IDF-weighted consensus vote — the strength that orders points. */
|
|
73
|
+
vote: number;
|
|
74
|
+
/** The union of the query byte-spans whose evidence supports this point. */
|
|
75
|
+
start: number;
|
|
76
|
+
end: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Both read-outs of one consensus climb. */
|
|
80
|
+
export interface AttentionRead {
|
|
81
|
+
roots: Attention[];
|
|
82
|
+
ranked: Attention[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A positioned region of a byte stream paired with its gist. */
|
|
86
|
+
export interface Segment {
|
|
87
|
+
start: number;
|
|
88
|
+
end: number;
|
|
89
|
+
v: Vec;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A region of the query's perceived tree for the consensus climb. */
|
|
93
|
+
export interface Region {
|
|
94
|
+
v: Vec;
|
|
95
|
+
start: number;
|
|
96
|
+
end: number;
|
|
97
|
+
chunk: boolean;
|
|
98
|
+
/** Whether the region's bytes resolve to a KNOWN node (content-addressed,
|
|
99
|
+
* exact). Exact regions vote with full weight; approximate ones pay the
|
|
100
|
+
* contrastive margin (see voteRegions) — under the linear fold a raw
|
|
101
|
+
* resonance score is byte-overlap, evidence only in excess of its best
|
|
102
|
+
* rival conclusion. */
|
|
103
|
+
known: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Per-region vote data from the consensus climb's resonance pass. */
|
|
107
|
+
export interface RegionVote {
|
|
108
|
+
start: number;
|
|
109
|
+
end: number;
|
|
110
|
+
canonicalFailed: boolean;
|
|
111
|
+
roots: readonly number[];
|
|
112
|
+
w: number;
|
|
113
|
+
wFocus: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The edge-bearing contexts reached by climbing from a node, plus saturation info. */
|
|
117
|
+
export interface AncestorReach {
|
|
118
|
+
roots: number[];
|
|
119
|
+
contextsReached: number;
|
|
120
|
+
saturated: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Saturated-interval information for the noise-drop gate. */
|
|
124
|
+
export interface SaturationInfo {
|
|
125
|
+
leadingEnd: number;
|
|
126
|
+
hasLeading: boolean;
|
|
127
|
+
intervals: Array<{ start: number; end: number }>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The items of poolVotes' deduction system. */
|
|
131
|
+
export type AItem =
|
|
132
|
+
| { kind: "region"; ri: number }
|
|
133
|
+
| { kind: "anchor"; id: number }
|
|
134
|
+
| { kind: "anchorFocus"; id: number };
|
|
135
|
+
|
|
136
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
137
|
+
// MindContext — bundles all state the mind's functions need
|
|
138
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
139
|
+
|
|
140
|
+
export interface MindContext extends GraphSearchHost {
|
|
141
|
+
store: Store;
|
|
142
|
+
space: Space;
|
|
143
|
+
alphabet: Alphabet;
|
|
144
|
+
cfg: MindConfig;
|
|
145
|
+
search: GraphSearch;
|
|
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(). */
|
|
168
|
+
perceiveMemo: Map<string, Sema> | null;
|
|
169
|
+
_edgeGuide: Vec | null;
|
|
170
|
+
_edgeChoice: Map<number, number>;
|
|
171
|
+
_prevSeen: Set<number> | null;
|
|
172
|
+
/** Session cache of node-id → perceived gist, for candidate scoring
|
|
173
|
+
* ({@link chooseAmong} in the reverse projection's recall path re-gists up to
|
|
174
|
+
* √N contexts per pick — the measured bottleneck there). `chooseNext` does
|
|
175
|
+
* NOT use this cache; forward-edge disambiguation uses prevOf counts
|
|
176
|
+
* (distributional evidence) instead of gist comparison, because for short
|
|
177
|
+
* answer candidates the gist is dominated by accidental byte-pattern
|
|
178
|
+
* correlations. A node's bytes are immutable and perception is a pure
|
|
179
|
+
* function of bytes, so an entry stays valid for the store's lifetime —
|
|
180
|
+
* never invalidated. Bounded LRU (byte-sized); a miss only re-perceives,
|
|
181
|
+
* never a correctness risk. */
|
|
182
|
+
_gistCache: BoundedMap<number, Vec>;
|
|
183
|
+
/** DEPOSIT-path stable-prefix tree cache: content key (latin1) of a
|
|
184
|
+
* deposited input → its plain-fold level PYRAMID, so the NEXT deposit
|
|
185
|
+
* whose prefix it is (a conversation's accumulated context) reuses every
|
|
186
|
+
* full aligned block and refolds only the right edge of each level —
|
|
187
|
+
* O(turn) per deposit instead of O(context), with the tree BIT-IDENTICAL
|
|
188
|
+
* to a from-scratch fold (see FoldPyramid). Purely a performance cache.
|
|
189
|
+
* Entry-count bounded (a pyramid is ~KB/byte of content; only the few
|
|
190
|
+
* live conversation chains matter). */
|
|
191
|
+
_depositTrees: BoundedMap<string, FoldPyramid>;
|
|
192
|
+
/** The byte lengths present in {@link _depositTrees} — the candidate
|
|
193
|
+
* prefix lengths probed (longest first). Drifts on eviction (a stale
|
|
194
|
+
* length only costs a miss); cleared with the map when it outgrows the
|
|
195
|
+
* probe budget. */
|
|
196
|
+
_depositLens: Set<number>;
|
|
197
|
+
/** Mind-lifetime intern memo by NODE IDENTITY: perceived-tree node → its
|
|
198
|
+
* content-addressed id. Valid forever (ids are permanent, Sema nodes
|
|
199
|
+
* immutable); WeakMap, so entries live exactly as long as the pyramid
|
|
200
|
+
* cache keeps the shared subtrees alive. Lets internTreeIds skip whole
|
|
201
|
+
* shared subtrees and indexSubSpans keep its seenBefore window skip. */
|
|
202
|
+
_internIds: WeakMap<Sema, number>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
206
|
+
// FREE FUNCTIONS (pure, no state)
|
|
207
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
208
|
+
|
|
209
|
+
/** Read a whole node's bytes. */
|
|
210
|
+
export const ALL = 0x7fffffff;
|
|
211
|
+
|
|
212
|
+
/** Splice every chosen span in order — the whole cover as one byte string. */
|
|
213
|
+
export function spliceAll(segs: Seg[]): Uint8Array | null {
|
|
214
|
+
if (!segs.some((s) => s.rec)) return null;
|
|
215
|
+
return concatBytes(segs.map((s) => s.bytes));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Lift the answer out of the cover for think: the recognised region, free of
|
|
219
|
+
* the asker's surrounding (unrecognised) framing. */
|
|
220
|
+
export function liftAnswer(segs: Seg[], queryLen: number): Uint8Array | null {
|
|
221
|
+
const recognised: number[] = [];
|
|
222
|
+
for (let k = 0; k < segs.length; k++) if (segs[k].rec) recognised.push(k);
|
|
223
|
+
if (recognised.length === 0) return null;
|
|
224
|
+
|
|
225
|
+
if (recognised.length === 1) {
|
|
226
|
+
const s = segs[recognised[0]];
|
|
227
|
+
if (dominates(s.j - s.i, queryLen)) {
|
|
228
|
+
return concatBytes(segs.map((x) => x.bytes));
|
|
229
|
+
}
|
|
230
|
+
return s.bytes;
|
|
231
|
+
}
|
|
232
|
+
const lo = recognised[0];
|
|
233
|
+
const hi = recognised[recognised.length - 1];
|
|
234
|
+
return concatBytes(segs.slice(lo, hi + 1).map((x) => x.bytes));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
|
|
238
|
+
* tracked deposit interned (`prevSeen`). */
|
|
239
|
+
export function changedNodes(
|
|
240
|
+
tree: Sema,
|
|
241
|
+
ids: Map<Sema, number>,
|
|
242
|
+
prevSeen: Set<number>,
|
|
243
|
+
): Sema[] {
|
|
244
|
+
const newCount = new Map<Sema, number>();
|
|
245
|
+
const count = (n: Sema): number => {
|
|
246
|
+
const memo = newCount.get(n);
|
|
247
|
+
if (memo !== undefined) return memo;
|
|
248
|
+
const id = ids.get(n);
|
|
249
|
+
// PRUNE: a node whose id the previous deposit already interned is old,
|
|
250
|
+
// and content addressing makes that transitive — the same id names the
|
|
251
|
+
// same content, so every descendant was interned then too. The whole
|
|
252
|
+
// subtree counts 0 without walking it; with the pyramid fold sharing a
|
|
253
|
+
// conversation's prefix subtree, this is what keeps the changed-nodes
|
|
254
|
+
// read O(new nodes) instead of O(context). (A node internTreeIds
|
|
255
|
+
// memo-skipped has an id here exactly when it is such a shared root.)
|
|
256
|
+
if (id !== undefined && prevSeen.has(id)) {
|
|
257
|
+
newCount.set(n, 0);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
let c = 1; // reachable only when NOT pruned above ⇒ this node is new
|
|
261
|
+
if (n.kids) { for (const k of n.kids) c += count(k); }
|
|
262
|
+
newCount.set(n, c);
|
|
263
|
+
return c;
|
|
264
|
+
};
|
|
265
|
+
const total = count(tree);
|
|
266
|
+
if (total === 0) return [tree];
|
|
267
|
+
|
|
268
|
+
let n = tree;
|
|
269
|
+
for (;;) {
|
|
270
|
+
if (n.kids === null) return [n];
|
|
271
|
+
let holder: Sema | null = null;
|
|
272
|
+
for (const k of n.kids) {
|
|
273
|
+
if (newCount.get(k)! === total) {
|
|
274
|
+
holder = k;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (holder === null) return [n];
|
|
279
|
+
n = holder;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# rabitq-hnsw
|
|
2
|
+
|
|
3
|
+
A small, well-organised TypeScript library for **approximate nearest-neighbour
|
|
4
|
+
search** using **HNSW** (Hierarchical Navigable Small World graphs) over **1-bit
|
|
5
|
+
RaBitQ codes**, with **cosine** distance. The entire index lives in **SQLite** —
|
|
6
|
+
the durable copy of every code and link is on disk, resident memory is bounded
|
|
7
|
+
by one capped memory budget (never the whole collection), and the index survives
|
|
8
|
+
process 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 256-d vector:
|
|
15
|
+
Float32 : 256 × 4 bytes = 1024 bytes
|
|
16
|
+
RaBitQ 1-bit (code) : 256 × 1 bit = 32 bytes (32× smaller)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The code _is_ the entire per-vector representation — no norms, multipliers, or
|
|
20
|
+
popcounts are stored alongside it. A 1-bit code is therefore a first-class
|
|
21
|
+
value: `get` returns the bare code, and `insert`, `update`, and `query` all
|
|
22
|
+
accept either a raw vector or a code — detected by length, so the same call
|
|
23
|
+
works for a `Uint32Array` or a plain `number[]`.
|
|
24
|
+
|
|
25
|
+
Highlights:
|
|
26
|
+
|
|
27
|
+
- Faithful HNSW: probabilistic level assignment, greedy layer descent, the
|
|
28
|
+
`SEARCH-LAYER` routine and the neighbour-selection **heuristic** (Algorithm
|
|
29
|
+
4), bidirectional edges with pruning, dynamic entry point.
|
|
30
|
+
- **Quantize-first build**: a vector is encoded to its 1-bit code immediately,
|
|
31
|
+
and the graph is built entirely from codes (`XOR` + `popcount`). Queries still
|
|
32
|
+
use the accurate full-precision estimator. There is **no exact re-ranking step
|
|
33
|
+
and no original-vector storage**.
|
|
34
|
+
- `compact()` reclaims deleted/updated space with a **graph-preserving splice**:
|
|
35
|
+
live nodes keep their internal ids and wiring, and each dead neighbour is
|
|
36
|
+
replaced by its own live neighbours (a 1-hop bridge). Codes are copied
|
|
37
|
+
verbatim — nothing is re-quantised — so compaction never loses precision, and
|
|
38
|
+
it runs as one streaming pass instead of a full O(N·ef·log N) rebuild.
|
|
39
|
+
- At query time each distance is a handful of byte lookups in a small per-query
|
|
40
|
+
table (no popcounts).
|
|
41
|
+
- **Truly sub-linear** query work (≈ `O(log N)`), demonstrated by the
|
|
42
|
+
test/benchmark.
|
|
43
|
+
- Vector **CRUD** keyed by integer external ids. `get` returns the stored 1-bit
|
|
44
|
+
code, which can be fed straight back into `insert`, `update`, or `query`.
|
|
45
|
+
- **Bounded RAM footprint**: codes and adjacency lists live in SQLite tables and
|
|
46
|
+
are read/written on demand. The only resident state is one BOUNDED budget,
|
|
47
|
+
`cacheSizeMb`, shared by SQLite's page cache and a derived immutable-code LRU
|
|
48
|
+
— both capped, so the index can grow to millions of vectors without resident
|
|
49
|
+
memory growing with it.
|
|
50
|
+
- **Scalability is cache-independent**: the caches are pure speed layers.
|
|
51
|
+
Correctness and the per-operation _storage-read_ count are identical with the
|
|
52
|
+
budget off (`cacheSizeMb: 0`). `lastQueryStorageReads` exposes that honest,
|
|
53
|
+
cache-independent cost — it is set by the work a query does (the nodes it
|
|
54
|
+
visits, ~log N), never by the collection size — so a poor access pattern can
|
|
55
|
+
never hide behind a warm cache.
|
|
56
|
+
- Reopening the same `dbPath` restores the exact configuration (the rotation is
|
|
57
|
+
regenerated from the persisted seed), so codes already on disk remain valid
|
|
58
|
+
across process restarts.
|
|
59
|
+
- **`node:sqlite`** is the only runtime dependency — no native add-ons.
|
|
60
|
+
|
|
61
|
+
## Install / run
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install # installs dev tooling (typescript, tsx, @types/node)
|
|
65
|
+
npm test # the single test file: CRUD, memory, recall + sub-linearity benchmark
|
|
66
|
+
npm run example # runs examples/quickstart.ts
|
|
67
|
+
npm run typecheck # tsc --noEmit
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
No build step is required to use or test the library — `tsx` runs the TypeScript
|
|
71
|
+
directly. Requires Node.js ≥ 22.5 (for `node:sqlite`).
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { VectorDatabase } from "./src/index";
|
|
77
|
+
|
|
78
|
+
// dbPath is required — use ":memory:" for a transient index
|
|
79
|
+
const db = new VectorDatabase({
|
|
80
|
+
dbPath: ":memory:",
|
|
81
|
+
dim: 256,
|
|
82
|
+
M: 16,
|
|
83
|
+
efConstruction: 200,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// CREATE — associate a vector with an integer id (encoded to 1-bit code immediately)
|
|
87
|
+
db.insert(42, embedding); // embedding: number[] | Float32Array of length dim
|
|
88
|
+
|
|
89
|
+
// BULK CREATE/UPDATE — one transaction around the whole batch (far fewer fsyncs
|
|
90
|
+
// on disk; the graph and its result are identical to the per-item path).
|
|
91
|
+
db.upsertMany([{ id: 1, vector: v1 }, { id: 2, vector: v2 }]);
|
|
92
|
+
|
|
93
|
+
// READ — returns the stored 1-bit code (NOT the original vector)
|
|
94
|
+
const code = db.get(42); // -> Uint32Array (codeWords words) | null
|
|
95
|
+
|
|
96
|
+
// A code is the whole representation: insert / update / query all accept one too,
|
|
97
|
+
// detected by length (codeWords vs dim) — works for Uint32Array or a number[].
|
|
98
|
+
db.insert(999, code!); // store an existing code under a new id
|
|
99
|
+
db.query(code!, 10); // search by code (sign-bit / cosine distance)
|
|
100
|
+
db.update(7, db.get(9)!); // move id-7 onto id-9's code
|
|
101
|
+
|
|
102
|
+
// UPDATE / DELETE with raw vectors
|
|
103
|
+
db.update(42, newEmbedding);
|
|
104
|
+
db.delete(42); // -> boolean
|
|
105
|
+
|
|
106
|
+
// COMPACT — reclaim space from deleted/updated vectors (lossless rebuild)
|
|
107
|
+
db.physicalSize; // physical nodes incl. tombstones
|
|
108
|
+
if (db.physicalSize > db.size * 1.5) db.compact();
|
|
109
|
+
|
|
110
|
+
// SEARCH — k nearest neighbours (ids + estimated cosine distances)
|
|
111
|
+
const hits = db.query(queryVector, 10); // -> [{ id, distance }, ...]
|
|
112
|
+
db.query(queryVector, 10, { ef: 200 }); // override efSearch per query
|
|
113
|
+
|
|
114
|
+
// MEMORY — per-vector storage footprint vs a Float32 baseline
|
|
115
|
+
db.storage; // { float32BytesPerVector, codeBytesPerVector, bytesPerVector, compressionRatio }
|
|
116
|
+
|
|
117
|
+
// PERSIST — just reference the same path again; the SQLite file IS the database
|
|
118
|
+
db.close();
|
|
119
|
+
const db2 = new VectorDatabase({ dbPath: "vectors.db" }); // reopens, exact same state
|
|
120
|
+
|
|
121
|
+
// Transient in-memory index
|
|
122
|
+
const mem = new VectorDatabase({ dbPath: ":memory:", dim: 256 });
|
|
123
|
+
// ... use, then close — data is discarded
|
|
124
|
+
mem.close();
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Distance is **cosine**. Options: `efSearch`, `queryBits` (query
|
|
128
|
+
scalar-quantisation bits, default 4), `rotationRounds` (default 3), `seed`, an
|
|
129
|
+
optional `centroid` the vectors are centered by before quantisation, and the one
|
|
130
|
+
bounded memory knob `cacheSizeMb` (default 64) — it sizes SQLite's page cache
|
|
131
|
+
and the derived immutable-code LRU together. A pure speed enhancement: pass 0 to
|
|
132
|
+
disable all caching, which changes neither results nor the per-operation
|
|
133
|
+
storage-read count.
|
|
134
|
+
|
|
135
|
+
## How it works
|
|
136
|
+
|
|
137
|
+
**RaBitQ (1-bit).** Each vector is centered by an optional centroid, normalised,
|
|
138
|
+
rotated by a fast random orthogonal transform (random sign flips +
|
|
139
|
+
Walsh–Hadamard, `O(D log D)`), and reduced to one sign bit per padded dimension.
|
|
140
|
+
The packed sign bits are the **only** thing kept per vector. The random rotation
|
|
141
|
+
makes the quantisation error essentially uniform across vectors, so the cosine
|
|
142
|
+
estimate needs only a single fixed scale rather than any per-vector correction.
|
|
143
|
+
|
|
144
|
+
**Two estimators, both on codes.**
|
|
145
|
+
|
|
146
|
+
- _query → code_ (accurate): RaBitQ's inner-product estimator between a
|
|
147
|
+
**full-precision** vector and a stored 1-bit code. The query is
|
|
148
|
+
scalar-quantised to `queryBits` bits, and the only data-dependent quantity in
|
|
149
|
+
the estimate is the dot product of the code's sign bits with the quantised
|
|
150
|
+
query — which is exactly **the sum of the quantised query values at the
|
|
151
|
+
coordinates where the code bit is 1**. So at query time we build one small
|
|
152
|
+
per-query **byte lookup table** (`paddedDim/8 × 256`, ≈8 KB, L1-resident) and
|
|
153
|
+
each distance is just `paddedDim/8` table lookups over the code's bytes — e.g.
|
|
154
|
+
32 lookups for a 256-d vector, no popcounts at all.
|
|
155
|
+
- _code → code_ (coarse): the distance between two stored codes from their
|
|
156
|
+
Hamming distance, `cos ≈ 1 − 2·hamming/D` (`XOR` + `popcount`). Both sides are
|
|
157
|
+
1-bit. **The entire build runs on this estimator** — finding a new node's
|
|
158
|
+
neighbours and pruning lists — so insertion needs nothing but the code (one
|
|
159
|
+
rotation at encode time, then cheap popcounts), and a rebuild from codes is a
|
|
160
|
+
faithful HNSW construction.
|
|
161
|
+
|
|
162
|
+
**HNSW.** A multi-layer navigable graph. Levels are drawn from
|
|
163
|
+
`⌊-ln(U)·(1/ln M)⌋`; the search descends greedily through the upper layers and
|
|
164
|
+
runs an `ef`-bounded best-first search at layer 0. New nodes connect to `M`
|
|
165
|
+
neighbours chosen by the diversity heuristic, with neighbour lists pruned back
|
|
166
|
+
to `2M` at layer 0. Insertion searches the graph with the new node's **code**;
|
|
167
|
+
queries search it with the **full-precision** estimator. There is no exact
|
|
168
|
+
re-ranking — the top-k is taken directly from the codes, which keeps query work
|
|
169
|
+
tiny and sub-linear.
|
|
170
|
+
|
|
171
|
+
**SQLite-native graph.** Codes live in the `nodes` table (one BLOB per row,
|
|
172
|
+
indexed by integer primary key); adjacency lists live in the `links` table
|
|
173
|
+
(`WITHOUT ROWID`, clustered on `(node, layer)`) so a neighbour-list fetch — the
|
|
174
|
+
hot path of search — is a single B-tree descent landing on an inline BLOB.
|
|
175
|
+
Global state (entry point, max level, counts, RNG state) and the quantizer
|
|
176
|
+
configuration (dim, seed, centroid, etc.) are in a single-row `meta` table.
|
|
177
|
+
There is no separate load/save step: the SQLite file **is** the database.
|
|
178
|
+
|
|
179
|
+
**Performance details.** WAL journal mode with NORMAL synchronous for
|
|
180
|
+
crash-safe, fast writes; a page cache sized by `cacheSizeMb` (with mmap tied to
|
|
181
|
+
it) for zero-copy reads of hot pages; a per-_operation_ working set (the records
|
|
182
|
+
the current insert/query is actively comparing, bounded by `efConstruction` /
|
|
183
|
+
`efSearch`, cleared between operations and never the collection size) so within
|
|
184
|
+
one op each touched node is read once; visited tracking and binary heaps on
|
|
185
|
+
parallel arrays with no per-candidate object allocation in the build/search hot
|
|
186
|
+
paths. The `compact()` operation streams the live rows into fresh tables
|
|
187
|
+
(preserving internal ids, splicing dead neighbours out of the adjacency lists)
|
|
188
|
+
in batched transactions — the WAL stays bounded by the batch, never the table —
|
|
189
|
+
then swaps the tables atomically and `VACUUM`s the freed pages back to the
|
|
190
|
+
filesystem.
|
|
191
|
+
|
|
192
|
+
**The immutable-code LRU.** A node's 1-bit code never changes once written, yet
|
|
193
|
+
building and searching the graph re-read the same hub nodes across operations —
|
|
194
|
+
and on clustered data consecutive inserts touch overlapping neighbourhoods, so a
|
|
195
|
+
code is fetched again and again. Each fetch is a SQLite point-query that decodes
|
|
196
|
+
the row and copies the BLOB (≈1.4 µs even when the page is already cached — the
|
|
197
|
+
cost is the statement, not disk I/O), while the distance it feeds is ≈0.04 µs.
|
|
198
|
+
So a **bounded LRU of decoded `NodeRec`s** (keyed by internal id) serves a
|
|
199
|
+
repeatedly-touched code from RAM and skips both the query and the copy. Two
|
|
200
|
+
properties keep it honest:
|
|
201
|
+
|
|
202
|
+
- It is a **latency layer only.** `lastQueryStorageReads` (and the underlying
|
|
203
|
+
`Store.reads`) counts SQLite fall-throughs — cache MISSES — so the
|
|
204
|
+
per-operation storage-read count, the cache-independent scalability witness,
|
|
205
|
+
is identical whether the cache is full or off. The index's _scaling_ never
|
|
206
|
+
depends on it; only the wall-clock constant does.
|
|
207
|
+
- It has **no separate knob.** Its entry capacity is DERIVED from the one memory
|
|
208
|
+
budget, `cacheSizeMb`, and the actual per-`NodeRec` byte cost (code + fixed
|
|
209
|
+
fields + Map overhead), so it shares that budget and resident memory is capped
|
|
210
|
+
regardless of collection size — it never grows into "the whole index in RAM".
|
|
211
|
+
On a clustered build even a modest budget captures essentially all the
|
|
212
|
+
locality, so it buys the speed-up without scaling with N.
|
|
213
|
+
|
|
214
|
+
Two refinements share the same budget and the same honesty rules (hits are never
|
|
215
|
+
counted as reads; correctness never depends on them):
|
|
216
|
+
|
|
217
|
+
- **Upper-layer pinning.** Every insert and query descends from the entry point
|
|
218
|
+
through the upper layers before fanning out on layer 0, so level ≥ 1 nodes (a
|
|
219
|
+
1/M fraction of the collection) are touched by every operation — yet layer-0
|
|
220
|
+
fan-out traffic keeps evicting them from a plain LRU. Their codes pin into a
|
|
221
|
+
section capped at half the budget that layer-0 traffic cannot evict, keeping
|
|
222
|
+
the descent RAM-resident however far the collection outgrows the budget.
|
|
223
|
+
- **A decoded neighbour-list LRU.** Hub adjacency lists are re-read across
|
|
224
|
+
consecutive operations just like hub codes; a bounded LRU (a quarter of the
|
|
225
|
+
budget) serves the decoded list, written through on every `setNeighbors` so a
|
|
226
|
+
hit always equals a fresh row read, and dropped at compaction.
|
|
227
|
+
|
|
228
|
+
The cache stays correct because the only mutable fields — the tombstone flag and
|
|
229
|
+
external id — are updated in place on the cached record by `tombstone()`, and
|
|
230
|
+
the whole cache is dropped at compaction (whose table swap invalidates any
|
|
231
|
+
cached row). A miss only repeats work, never changes a result. `cacheSizeMb: 0`
|
|
232
|
+
disables it (along with the page cache), which the tests use to measure the
|
|
233
|
+
honest read count.
|
|
234
|
+
|
|
235
|
+
Deletes (and the old version after an `update`) are tombstones: still routed
|
|
236
|
+
through for navigation, never returned. `compact()` splices them out of the
|
|
237
|
+
graph (each dead neighbour replaced by its own live neighbours) and drops their
|
|
238
|
+
rows; codes are copied untouched — no original vectors needed, no precision
|
|
239
|
+
lost.
|
|
240
|
+
|
|
241
|
+
## Accuracy trade-off
|
|
242
|
+
|
|
243
|
+
1-bit codes are extremely compact but lossy: the index reliably finds the right
|
|
244
|
+
_neighbourhood_, but it cannot perfectly resolve the order of points that are
|
|
245
|
+
all roughly equidistant from the query. So recall is high when the true
|
|
246
|
+
neighbours are **separable** (the regime where ANN is meaningful —
|
|
247
|
+
near-duplicate retrieval, clustered embeddings, etc.) and lower inside a dense,
|
|
248
|
+
ambiguous cluster. The classic way to recover exact ordering is to re-rank the
|
|
249
|
+
top candidates with the original vectors; this build intentionally omits that to
|
|
250
|
+
maximise speed and minimise memory.
|
|
251
|
+
|
|
252
|
+
## Benchmark (from `npm test`)
|
|
253
|
+
|
|
254
|
+
`dim = 256`, well-separated groups of 10 near-duplicates, 200 queries, `k = 10`,
|
|
255
|
+
`efSearch = 150`. Representative run (disk-backed, SQLite):
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
Per-vector storage (vector payload only, excludes the HNSW graph):
|
|
259
|
+
Float32 baseline : 1024 B
|
|
260
|
+
1-bit RaBitQ code : 32 B (32.0x smaller)
|
|
261
|
+
code (kept) : 32 B (32.0x smaller overall)
|
|
262
|
+
payload at N=32000 : Float32 31.25 MB vs index 0.98 MB
|
|
263
|
+
|
|
264
|
+
N | HNSW q (us) | dist comps | Brute q (us) | recall@10 | speedup
|
|
265
|
+
-------+-------------+------------+--------------+-----------+--------
|
|
266
|
+
1000 | 1930.3 | 863 | 308.2 | 100.0% | 0.2x
|
|
267
|
+
2000 | 3142.8 | 1298 | 549.6 | 100.0% | 0.2x
|
|
268
|
+
4000 | 3763.7 | 1723 | 1073.3 | 100.0% | 0.3x
|
|
269
|
+
8000 | 4603.3 | 2165 | 2186.0 | 100.0% | 0.5x
|
|
270
|
+
16000 | 5315.8 | 2440 | 4398.0 | 100.0% | 0.8x
|
|
271
|
+
32000 | 6058.2 | 2668 | 8886.4 | 100.0% | 1.5x
|
|
272
|
+
|
|
273
|
+
Empirical scaling over a 32x increase in N (work ~ N^exponent):
|
|
274
|
+
HNSW distance computations : N^0.320 <- sub-linear (target < 1)
|
|
275
|
+
Brute-force query : N^0.831 (linear reference ~ 1.0)
|
|
276
|
+
Mean recall@10 : 100.00%
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The distance-computation count is a deterministic, machine-independent measure
|
|
280
|
+
of work: it grows sub-linearly (≈ `N^0.32`, near-logarithmic) while the
|
|
281
|
+
brute-force baseline grows near-linearly. The per-query latency is higher than a
|
|
282
|
+
purely in-RAM index (the SQLite round-trips add ~constant overhead), but it
|
|
283
|
+
**scales** — at 32,000 vectors the disk-backed HNSW is already 1.5× faster than
|
|
284
|
+
brute force, and the gap widens as N grows (brute is `O(N)` per query; HNSW is
|
|
285
|
+
`O(log N)`). The test asserts the 32× storage compression, the sub-linear
|
|
286
|
+
exponent, perfect recall, and the speed-up over brute force.
|
|
287
|
+
|
|
288
|
+
## Layout
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
src/
|
|
292
|
+
prng.ts deterministic seedable RNG (mulberry32)
|
|
293
|
+
heap.ts binary heap on parallel arrays
|
|
294
|
+
rabitq.ts 1-bit RaBitQ quantizer + fast rotation + query/code estimators
|
|
295
|
+
store.ts SQLite-backed graph storage: codes, adjacency, meta, compaction
|
|
296
|
+
hnsw.ts HNSW graph over codes (build, search, delete, compact)
|
|
297
|
+
database.ts VectorDatabase: CRUD, external ids, storage stats
|
|
298
|
+
index.ts public exports
|
|
299
|
+
test/
|
|
300
|
+
hnsw.test.ts the single test file (correctness + memory + benchmark)
|
|
301
|
+
examples/
|
|
302
|
+
quickstart.ts
|
|
303
|
+
```
|