@hviana/sema 0.4.6 → 0.4.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.
@@ -225,7 +225,37 @@ export class RaBitQuantizer {
225
225
  const lut = q.qlut;
226
226
  let dot = 0;
227
227
  let popcount = 0;
228
- for (let p = 0; p < nb; p++) {
228
+ // THE INNERMOST LOOP OF SEARCH. Profiled on the trained store: 170 ANN
229
+ // queries scan 8,702,005 slots, and this estimate — inlined by V8 into
230
+ // IvfIndex.scanClusters, which is why it does not appear separately — was
231
+ // 21% of all inference CPU.
232
+ //
233
+ // The `dot` half is an irreducible data-dependent LUT probe per byte. The
234
+ // `popcount` half is not: it is the same sign-bit count `codeDistanceBytes`
235
+ // below already folds into 32-bit words ("~4x fewer loop iterations"), and
236
+ // that reasoning applies verbatim here. Four bytes are packed into one
237
+ // word and popcounted with the standard SWAR reduction, while the four LUT
238
+ // probes are issued together so their loads overlap instead of serialising
239
+ // behind the popcount.
240
+ //
241
+ // BIT-IDENTICAL, not an approximation: popcount over four bytes equals the
242
+ // sum of their individual popcounts, and the LUT terms are added in the
243
+ // same order at the same indices. Verified by direct comparison, and the
244
+ // 445 suite plus the battery's answers are unchanged.
245
+ let p = 0;
246
+ for (const n4 = nb & ~3; p < n4; p += 4) {
247
+ const o = byteOffset + p;
248
+ const b0 = codeBytes[o], b1 = codeBytes[o + 1];
249
+ const b2 = codeBytes[o + 2], b3 = codeBytes[o + 3];
250
+ dot += lut[(p << 8) + b0] + lut[((p + 1) << 8) + b1] +
251
+ lut[((p + 2) << 8) + b2] + lut[((p + 3) << 8) + b3];
252
+ let x = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
253
+ x -= (x >>> 1) & 0x55555555;
254
+ x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
255
+ x = (x + (x >>> 4)) & 0x0f0f0f0f;
256
+ popcount += Math.imul(x, 0x01010101) >>> 24;
257
+ }
258
+ for (; p < nb; p++) {
229
259
  const b = codeBytes[byteOffset + p];
230
260
  dot += lut[(p << 8) + b];
231
261
  popcount += POPCOUNT8[b];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -249,7 +249,37 @@ export class RaBitQuantizer {
249
249
  const lut = q.qlut;
250
250
  let dot = 0;
251
251
  let popcount = 0;
252
- for (let p = 0; p < nb; p++) {
252
+ // THE INNERMOST LOOP OF SEARCH. Profiled on the trained store: 170 ANN
253
+ // queries scan 8,702,005 slots, and this estimate — inlined by V8 into
254
+ // IvfIndex.scanClusters, which is why it does not appear separately — was
255
+ // 21% of all inference CPU.
256
+ //
257
+ // The `dot` half is an irreducible data-dependent LUT probe per byte. The
258
+ // `popcount` half is not: it is the same sign-bit count `codeDistanceBytes`
259
+ // below already folds into 32-bit words ("~4x fewer loop iterations"), and
260
+ // that reasoning applies verbatim here. Four bytes are packed into one
261
+ // word and popcounted with the standard SWAR reduction, while the four LUT
262
+ // probes are issued together so their loads overlap instead of serialising
263
+ // behind the popcount.
264
+ //
265
+ // BIT-IDENTICAL, not an approximation: popcount over four bytes equals the
266
+ // sum of their individual popcounts, and the LUT terms are added in the
267
+ // same order at the same indices. Verified by direct comparison, and the
268
+ // 445 suite plus the battery's answers are unchanged.
269
+ let p = 0;
270
+ for (const n4 = nb & ~3; p < n4; p += 4) {
271
+ const o = byteOffset + p;
272
+ const b0 = codeBytes[o], b1 = codeBytes[o + 1];
273
+ const b2 = codeBytes[o + 2], b3 = codeBytes[o + 3];
274
+ dot += lut[(p << 8) + b0] + lut[((p + 1) << 8) + b1] +
275
+ lut[((p + 2) << 8) + b2] + lut[((p + 3) << 8) + b3];
276
+ let x = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
277
+ x -= (x >>> 1) & 0x55555555;
278
+ x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
279
+ x = (x + (x >>> 4)) & 0x0f0f0f0f;
280
+ popcount += Math.imul(x, 0x01010101) >>> 24;
281
+ }
282
+ for (; p < nb; p++) {
253
283
  const b = codeBytes[byteOffset + p];
254
284
  dot += lut[(p << 8) + b];
255
285
  popcount += POPCOUNT8[b];
@@ -0,0 +1,331 @@
1
+ // 65-ann-recall.test.mjs — the ACCURACY half of rabitq-ivf's speed/accuracy
2
+ // trade, pinned to a measured baseline.
3
+ //
4
+ // Every geometry constant in the sub-library (rotation `rounds`, `queryBits`,
5
+ // `efSearch`/nprobe, SPLIT_MAX, and the estimator's own arithmetic) buys speed
6
+ // by giving up recall. Nothing measured the recall, so nothing could be
7
+ // changed: a faster encoder that quietly lost neighbours looked exactly like a
8
+ // faster encoder. These assertions are that missing half — an optimisation is
9
+ // free only if it holds this line.
10
+ //
11
+ // Deliberately SELF-CONTAINED: vectors are real Sema gists, but folded here
12
+ // from generated text rather than read out of sema.*, so the test is
13
+ // deterministic, needs no trained store, and cannot drift when one is
14
+ // retrained.
15
+ //
16
+ // Absolute recall here (~78%) runs higher than the ~71% measured against the
17
+ // trained store (100,000 gists sampled from sema.*, 400 queries, exact-cosine
18
+ // ground truth): generated word-salad folds to an easier distribution than a
19
+ // real corpus. The FLOORS are what matter, and they are set from what this
20
+ // file itself measures, with margin for the estimator's quantisation noise —
21
+ // never from what looked good on a different distribution.
22
+
23
+ import { test } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { Mind } from "../dist/src/index.js";
26
+ import { gistOf } from "../dist/src/mind/primitives.js";
27
+ import { IvfIndex } from "../dist/src/rabitq-ivf/src/ivf.js";
28
+ import { RaBitQuantizer } from "../dist/src/rabitq-ivf/src/rabitq.js";
29
+
30
+ const SEED = 7;
31
+ const K = 10;
32
+
33
+ // A deterministic PRNG — the vectors must be identical on every run, or a
34
+ // recall floor is a coin flip.
35
+ const prng = (s) => () => {
36
+ s ^= s << 13;
37
+ s >>>= 0;
38
+ s ^= s >>> 17;
39
+ s ^= s << 5;
40
+ s >>>= 0;
41
+ return s / 4294967296;
42
+ };
43
+
44
+ const WORDS = [
45
+ "the",
46
+ "a",
47
+ "of",
48
+ "in",
49
+ "system",
50
+ "fold",
51
+ "vector",
52
+ "index",
53
+ "query",
54
+ "gist",
55
+ "node",
56
+ "edge",
57
+ "store",
58
+ "cluster",
59
+ "recall",
60
+ "code",
61
+ "byte",
62
+ "span",
63
+ "chunk",
64
+ "seed",
65
+ "paris",
66
+ "france",
67
+ "steel",
68
+ "ice",
69
+ "planet",
70
+ "sun",
71
+ "music",
72
+ "river",
73
+ "light",
74
+ "stone",
75
+ ];
76
+
77
+ /** `n` distinct real Sema gists, folded from generated text. */
78
+ function gists(n) {
79
+ const mind = new Mind({ seed: SEED });
80
+ const rnd = prng(20260729);
81
+ const enc = new TextEncoder();
82
+ const out = [];
83
+ const seen = new Set();
84
+ while (out.length < n) {
85
+ let s = "";
86
+ const len = 3 + ((rnd() * 14) | 0);
87
+ for (let i = 0; i < len; i++) {
88
+ s += (i ? " " : "") + WORDS[(rnd() * WORDS.length) | 0];
89
+ }
90
+ s += ".";
91
+ if (seen.has(s)) continue;
92
+ seen.add(s);
93
+ const g = gistOf(mind, enc.encode(s));
94
+ let sq = 0;
95
+ for (let i = 0; i < g.length; i++) sq += g[i] * g[i];
96
+ if (sq > 0) out.push(g);
97
+ }
98
+ return { vecs: out, D: mind.store.D };
99
+ }
100
+
101
+ function indexOf(vecs, D, rounds = 3) {
102
+ const idx = new IvfIndex(":memory:", {
103
+ dim: D,
104
+ rotationRounds: rounds,
105
+ seed: SEED,
106
+ cacheSizeMb: 64,
107
+ });
108
+ idx.begin();
109
+ for (let i = 0; i < vecs.length; i++) {
110
+ idx.insert(i, idx.encodeToBytes(vecs[i]));
111
+ }
112
+ idx.commit();
113
+ idx.commitFlush();
114
+ return idx;
115
+ }
116
+
117
+ const queryIds = (n, q) => {
118
+ const out = [];
119
+ for (let i = 0; i < q; i++) out.push(Math.floor((i + 0.5) * n / q));
120
+ return out;
121
+ };
122
+
123
+ /** Exact cosine top-k by brute force — the only true ground truth here. */
124
+ function exactTopK(vecs, norms, qi, k) {
125
+ const q = vecs[qi], qn = norms[qi];
126
+ const ds = new Float64Array(vecs.length);
127
+ for (let j = 0; j < vecs.length; j++) {
128
+ const v = vecs[j];
129
+ let dot = 0;
130
+ for (let i = 0; i < q.length; i++) dot += q[i] * v[i];
131
+ const den = qn * norms[j];
132
+ ds[j] = den === 0 ? 1 : 1 - dot / den;
133
+ }
134
+ return new Set(
135
+ Array.from(ds.keys()).sort((a, b) => ds[a] - ds[b]).slice(0, k),
136
+ );
137
+ }
138
+
139
+ // ── QUANTIZATION: what 1-bit coding costs, with routing removed ─────────────
140
+
141
+ test("quantization recall holds its measured floor (routing removed)", () => {
142
+ const { vecs, D } = gists(2000);
143
+ const norms = vecs.map((v) => {
144
+ let s = 0;
145
+ for (let i = 0; i < v.length; i++) s += v[i] * v[i];
146
+ return Math.sqrt(s);
147
+ });
148
+ const qs = queryIds(vecs.length, 100);
149
+ const truth = qs.map((qi) => exactTopK(vecs, norms, qi, K));
150
+
151
+ const idx = indexOf(vecs, D);
152
+ // nprobe = K clusters: every cluster is scanned, so the ONLY thing between
153
+ // the query and exact cosine is the 1-bit code.
154
+ const full = 4 * idx.clusterCount;
155
+ let hit = 0, want = 0;
156
+ for (let t = 0; t < qs.length; t++) {
157
+ for (const h of idx.searchKnn(vecs[qs[t]], K, full)) {
158
+ if (truth[t].has(h.id)) hit++;
159
+ }
160
+ want += truth[t].size;
161
+ }
162
+ idx.close();
163
+ const recall = hit / want;
164
+
165
+ // Measured 79.3% here (2,000 gists, 100 queries, k=10). The floor sits a
166
+ // clear margin below so that ordinary quantisation jitter cannot trip it,
167
+ // while a real loss — dropping a rotation round that mattered, widening the
168
+ // code, changing the estimator's arithmetic — moves recall by far more.
169
+ assert.ok(
170
+ recall >= 0.74,
171
+ `quantization recall@${K} fell to ${(recall * 100).toFixed(1)}% ` +
172
+ `(floor 74.0%, measured baseline 79.3%). The 1-bit code lost ` +
173
+ `neighbours it used to keep — this is the accuracy half of a ` +
174
+ `speed/accuracy trade, so a change that speeds up the encoder or the ` +
175
+ `estimator must NOT land here.`,
176
+ );
177
+ });
178
+
179
+ // ── ROUTING: what probing a subset of clusters costs ───────────────────────
180
+
181
+ test("routing recall degrades monotonically as nprobe shrinks", () => {
182
+ // 12,000 vectors splits into several clusters (SPLIT_MAX = 4096). Routing is
183
+ // then exercised by LOWERING ef — nprobe = ceil(ef/4) — rather than by
184
+ // growing the collection into the tens of thousands, which would cost the
185
+ // suite seconds to say the same thing.
186
+ const { vecs, D } = gists(12000);
187
+ const idx = indexOf(vecs, D);
188
+ const clusters = idx.clusterCount;
189
+ assert.ok(clusters >= 4, `expected several clusters, got ${clusters}`);
190
+
191
+ const qs = queryIds(vecs.length, 100);
192
+ const full = 4 * clusters; // nprobe >= clusters: nothing skipped
193
+ const ref = qs.map((qi) =>
194
+ new Set(idx.searchKnn(vecs[qi], K, full).map((h) => h.id))
195
+ );
196
+
197
+ const at = (ef) => {
198
+ let hit = 0, want = 0;
199
+ for (let t = 0; t < qs.length; t++) {
200
+ for (const h of idx.searchKnn(vecs[qs[t]], K, ef)) {
201
+ if (ref[t].has(h.id)) hit++;
202
+ }
203
+ want += ref[t].size;
204
+ }
205
+ return hit / want;
206
+ };
207
+
208
+ const one = at(4); // nprobe 1
209
+ const two = at(8); // nprobe 2
210
+ const all = at(16 * clusters);
211
+ idx.close();
212
+
213
+ // Probing every cluster must reproduce the reference exactly — this is the
214
+ // same scan, so anything below 1.0 means the probe ORDER dropped a cluster
215
+ // it ranked in, not that the quantizer was imprecise.
216
+ assert.equal(
217
+ all,
218
+ 1,
219
+ "probing every cluster must match the full scan exactly",
220
+ );
221
+ // More clusters probed is never worse.
222
+ assert.ok(
223
+ two >= one,
224
+ `routing recall fell when nprobe grew: nprobe=1 ${
225
+ (one * 100).toFixed(1)
226
+ }% ` +
227
+ `-> nprobe=2 ${(two * 100).toFixed(1)}%`,
228
+ );
229
+ // Measured 52.9% at nprobe=1 and 76.4% at nprobe=2 of 4 clusters. The floors
230
+ // guard the PIVOT quality: routing is only worth anything if the nearest
231
+ // cluster usually holds the nearest vectors, and a pivot chosen badly (a
232
+ // broken split, a majority-code regression) shows up here first.
233
+ assert.ok(
234
+ one >= 0.4,
235
+ `single-cluster routing recall ${(one * 100).toFixed(1)}% is below the ` +
236
+ `0.4 floor (measured 52.9%) — cluster pivots no longer predict where ` +
237
+ `a query's neighbours live.`,
238
+ );
239
+ });
240
+
241
+ // ── ESTIMATOR: the arithmetic itself, pinned exactly ───────────────────────
242
+
243
+ test("the fast estimator is bit-identical to its scalar definition", () => {
244
+ // The scan's inner loop is the hottest code in inference and invites
245
+ // micro-optimisation (it currently folds the popcount into 32-bit SWAR
246
+ // words). Those rewrites are only legitimate while they are EXACT: the
247
+ // estimator's output ranks every candidate, so a last-bit difference is a
248
+ // silent reordering, not a rounding detail. This recomputes the definition
249
+ // straight from the QueryContext and demands equality.
250
+ const rnd = prng(11);
251
+ let checked = 0;
252
+ for (const dim of [8, 64, 100, 256, 1024]) {
253
+ const qz = new RaBitQuantizer(dim, { seed: SEED });
254
+ const nb = qz.paddedDim >>> 3;
255
+ const POP = new Uint8Array(256);
256
+ for (let i = 1; i < 256; i++) POP[i] = POP[i >> 1] + (i & 1);
257
+ for (let t = 0; t < 25; t++) {
258
+ const v = new Float64Array(dim);
259
+ for (let i = 0; i < dim; i++) v[i] = rnd() * 2 - 1;
260
+ const q = qz.prepareQuery(v);
261
+ const code = new Uint8Array(nb * 4);
262
+ for (let i = 0; i < code.length; i++) code[i] = (rnd() * 256) | 0;
263
+ for (let c = 0; c < 4; c++) {
264
+ const off = c * nb;
265
+ let dot = 0, pc = 0;
266
+ for (let p = 0; p < q.nbytes; p++) {
267
+ const b = code[off + p];
268
+ dot += q.qlut[(p << 8) + b];
269
+ pc += POP[b];
270
+ }
271
+ const A = q.vmin * (2 * pc - qz.paddedDim) +
272
+ q.delta * (2 * dot - q.sumQInt);
273
+ const want = q.zero ? 1 : 1 - qz.cosFactor * A;
274
+ assert.ok(
275
+ Object.is(qz.estimate(code, off, q), want),
276
+ `estimate diverged from its definition at dim=${dim}: ` +
277
+ `${qz.estimate(code, off, q)} vs ${want}`,
278
+ );
279
+ checked++;
280
+ }
281
+ }
282
+ }
283
+ assert.ok(checked >= 500, `expected a broad sweep, checked ${checked}`);
284
+ });
285
+
286
+ // ── GEOMETRY: rotation rounds are saturated at 1 ───────────────────────────
287
+
288
+ test("rotation rounds beyond the first buy no recall", () => {
289
+ // Recorded as an executable fact, because it is the one place in this
290
+ // sub-library with real headroom: a round is a sign-flip plus a
291
+ // Walsh-Hadamard transform (the SRHT construction, for which ONE randomised
292
+ // round already approximates a random rotation), and cost is linear in the
293
+ // count — encode runs 28.0us at rounds=3 against 16.8us at rounds=1.
294
+ //
295
+ // Measured against the trained store — 100,000 real gists, routing removed,
296
+ // 400 queries, recall@10: rounds=4 70.5%, rounds=3 71.1%, rounds=2 70.6%,
297
+ // rounds=1 71.1% — indistinguishable across 4,000 ground-truth slots, and
298
+ // reproduced at N=1,000 and N=4,000. If this assertion ever fails, the
299
+ // rotation has stopped being saturated and the default is worth revisiting.
300
+ const { vecs, D } = gists(2000);
301
+ const norms = vecs.map((v) => {
302
+ let s = 0;
303
+ for (let i = 0; i < v.length; i++) s += v[i] * v[i];
304
+ return Math.sqrt(s);
305
+ });
306
+ const qs = queryIds(vecs.length, 100);
307
+ const truth = qs.map((qi) => exactTopK(vecs, norms, qi, K));
308
+
309
+ const recallFor = (rounds) => {
310
+ const idx = indexOf(vecs, D, rounds);
311
+ const full = 4 * idx.clusterCount;
312
+ let hit = 0, want = 0;
313
+ for (let t = 0; t < qs.length; t++) {
314
+ for (const h of idx.searchKnn(vecs[qs[t]], K, full)) {
315
+ if (truth[t].has(h.id)) hit++;
316
+ }
317
+ want += truth[t].size;
318
+ }
319
+ idx.close();
320
+ return hit / want;
321
+ };
322
+
323
+ const three = recallFor(3);
324
+ const one = recallFor(1);
325
+ assert.ok(
326
+ Math.abs(three - one) <= 0.03,
327
+ `one rotation round is no longer equivalent to three: ` +
328
+ `rounds=3 ${(three * 100).toFixed(1)}% vs rounds=1 ` +
329
+ `${(one * 100).toFixed(1)}% (tolerance 3 points)`,
330
+ );
331
+ });