pikelet-wasm 0.7.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.
@@ -0,0 +1,1040 @@
1
+ 'use strict';
2
+ // Sketch artifact profile (.pancake-sketch) — spec/SKETCH_PROFILE.md:
3
+ // PancakeSketchArtifact reader, the sketch-artifact builders, and the
4
+ // engine-backed resident scanner.
5
+ // Split out of pancake-artifact.js (the public entry, which re-exports the
6
+ // three parts); see that file for the module map.
7
+
8
+ const { pancakeError, PANCAKE_ERROR_CODES } = require('./pancake-errors.js');
9
+ const {
10
+ DEFAULT_OPEN_READ_BYTES,
11
+ MAX_COALESCED_RANGE_BYTES,
12
+ mapLimit,
13
+ readChecked,
14
+ resolveMaxReadBytes,
15
+ resolveMaxCacheBytes,
16
+ NodeFileRangeSource,
17
+ parseUint8Snapshot,
18
+ sha256Bytes,
19
+ sha256BytesAsync,
20
+ verifySegmentSha256,
21
+ } = require('./pancake-artifact-common.js');
22
+ const {
23
+ normalizeQuery: normalizeQueryVector,
24
+ resolveSearchK,
25
+ resolveOptionalPositiveInt,
26
+ } = require('./pancake-artifact-common.js');
27
+
28
+ // ============================================================================
29
+ // Sketch artifact profile (.pancake-sketch) — spec/SKETCH_PROFILE.md
30
+ // ============================================================================
31
+ //
32
+ // Layout: 256-byte header | scales f32[count] | offsets f32[count] |
33
+ // packed pooled sketches | zero padding to 16-byte alignment | raw u8 rows.
34
+ // Everything before vectorsOffset is the resident prefix; row addresses are
35
+ // computed (vectorsOffset + id*dim), never looked up.
36
+
37
+ const SKETCH_MAGIC = 0x31415350; // PSA1
38
+ const SKETCH_HEADER_BYTES = 256;
39
+ const SKETCH_KIND_U8 = 1;
40
+
41
+ function buildSketchArtifact(snapshotBytes, outPath, options = {}) {
42
+ if (typeof outPath !== 'string' || outPath.length === 0) {
43
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'buildSketchArtifact() requires an output path');
44
+ }
45
+ return exportSketchArtifact(parseUint8Snapshot(snapshotBytes), outPath, options);
46
+ }
47
+
48
+ // Accepts any index-like source of quantized rows ({ dim, count, metric,
49
+ // qdata, scales, offsets }) — the profile is derivable from a snapshot, a
50
+ // range artifact, or any producer that emits row-affine u8 vectors.
51
+ function exportSketchArtifact(index, outPath, options = {}) {
52
+ const dim = index.dim;
53
+ const count = index.count;
54
+ const sketchDims = options.sketchDims || (dim % 2 === 0 ? dim / 2 : dim);
55
+ const sketchBits = options.sketchBits || 4;
56
+ const recommendedRerank = options.recommendedRerank || 0;
57
+ if (![4, 8].includes(sketchBits)) {
58
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'sketchBits must be 4 or 8', { sketchBits });
59
+ }
60
+ if (!Number.isInteger(sketchDims) || sketchDims < 1 || dim % sketchDims !== 0) {
61
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'sketchDims must divide dim', { sketchDims, dim });
62
+ }
63
+ if (sketchBits === 4 && sketchDims % 2 !== 0) {
64
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'sketchDims must be even for 4-bit sketches', { sketchDims });
65
+ }
66
+ // Optional staged-boot micro tier: a second, coarser pooling of the same
67
+ // quantized rows, stored after the full sketches so v1 readers see it
68
+ // only as opaque resident tail bytes (their layout check permits a gap
69
+ // between sketches end and vectorsOffset, and the resident hash already
70
+ // covers it). Pooling commutes with the per-row affine map, so the micro
71
+ // tier reuses scales/offsets unchanged, exactly like the full tier.
72
+ const microDims = options.microDims || 0;
73
+ // 4-bit default: the 2026-08-04 sweep on the 456k wiki corpus measured
74
+ // bit depth as worthless at fixed bytes (48d/4b == 48d/8b within noise)
75
+ // while halved pooling at the same bytes gained +28 recall points
76
+ // (96d/4b 87.8% vs 48d/8b 59.4% capture at C=800). Micro tiers should
77
+ // spend their byte budget on dims, never on bits.
78
+ const microBits = microDims ? (options.microBits || 4) : 0;
79
+ if (microDims) {
80
+ if (!Number.isInteger(microDims) || microDims < 1 || sketchDims % microDims !== 0 || microDims >= sketchDims) {
81
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'microDims must divide sketchDims and be smaller', { microDims, sketchDims });
82
+ }
83
+ if (![4, 8].includes(microBits) || (microBits === 4 && microDims % 2 !== 0)) {
84
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'invalid micro sketch encoding', { microBits, microDims });
85
+ }
86
+ }
87
+ // Per-row integrity (format version 2, spec section 2.4): the vectors
88
+ // region is written as blocks of rowsPerBlock rows, each preceded by a
89
+ // digest page of truncated per-row SHA-256s, and a 32-byte-per-block
90
+ // page-hash table sits at the end of the resident prefix — so
91
+ // residentSha256 (and any identity that commits to the header) anchors
92
+ // every lazily fetched row. The 2026-08-25 measurement chose the
93
+ // defaults: 16 rows x 16-byte digests ride inside the reader's existing
94
+ // coalesced runs at ~0% latency cost. rowIntegrity:false emits v1.
95
+ const rowIntegrity = options.rowIntegrity !== false;
96
+ const rowsPerBlock = options.rowsPerBlock || 16;
97
+ const rowDigestBytes = options.rowDigestBytes || 16;
98
+ if (rowIntegrity) {
99
+ if (!Number.isInteger(rowsPerBlock) || rowsPerBlock < 1 || rowsPerBlock > 4096) {
100
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'rowsPerBlock must be an integer in [1, 4096]', { rowsPerBlock });
101
+ }
102
+ if (!Number.isInteger(rowDigestBytes) || rowDigestBytes < 8 || rowDigestBytes > 32) {
103
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'rowDigestBytes must be an integer in [8, 32]', { rowDigestBytes });
104
+ }
105
+ }
106
+ const pool = dim / sketchDims;
107
+ const sketchRowBytes = (sketchDims * sketchBits) / 8;
108
+ const microPool = microDims ? dim / microDims : 0;
109
+ const microRowBytes = microDims ? (microDims * microBits) / 8 : 0;
110
+
111
+ const scalesOffset = SKETCH_HEADER_BYTES;
112
+ const offsetsOffset = scalesOffset + count * 4;
113
+ const sketchesOffset = offsetsOffset + count * 4;
114
+ const sketchesEnd = sketchesOffset + count * sketchRowBytes;
115
+ const microOffset = microDims ? sketchesEnd : 0;
116
+ const tiersEnd = microDims ? microOffset + count * microRowBytes : sketchesEnd;
117
+ const numBlocks = rowIntegrity ? Math.ceil(count / rowsPerBlock) : 0;
118
+ const pageTableOffset = rowIntegrity ? tiersEnd : 0;
119
+ const residentEnd = rowIntegrity ? pageTableOffset + numBlocks * 32 : tiersEnd;
120
+ const vectorsOffset = Math.ceil(residentEnd / 16) * 16;
121
+ const pageBytes = rowIntegrity ? rowsPerBlock * rowDigestBytes : 0;
122
+ const blockBytes = rowIntegrity ? pageBytes + rowsPerBlock * dim : 0;
123
+ const vectorsRegionBytes = rowIntegrity
124
+ ? (numBlocks - 1) * blockBytes + pageBytes + (count - (numBlocks - 1) * rowsPerBlock) * dim
125
+ : count * dim;
126
+ const fileBytes = vectorsOffset + vectorsRegionBytes;
127
+
128
+ const out = Buffer.alloc(fileBytes);
129
+ out.writeUInt32LE(SKETCH_MAGIC, 0);
130
+ out.writeUInt32LE(rowIntegrity ? 2 : 1, 4);
131
+ out.writeUInt32LE(SKETCH_KIND_U8, 8);
132
+ out.writeUInt32LE(index.metric, 12);
133
+ out.writeUInt32LE(dim, 16);
134
+ out.writeUInt32LE(count, 20);
135
+ out.writeUInt32LE(sketchDims, 24);
136
+ out.writeUInt32LE(sketchBits, 28);
137
+ out.writeUInt32LE(scalesOffset, 32);
138
+ out.writeUInt32LE(offsetsOffset, 36);
139
+ out.writeUInt32LE(sketchesOffset, 40);
140
+ out.writeUInt32LE(vectorsOffset, 44);
141
+ out.writeBigUInt64LE(BigInt(fileBytes), 48);
142
+ out.writeUInt32LE(recommendedRerank, 120);
143
+ if (microDims) {
144
+ out.writeUInt32LE(microDims, 124);
145
+ out.writeUInt32LE(microBits, 128);
146
+ out.writeUInt32LE(microOffset, 132);
147
+ }
148
+ if (rowIntegrity) {
149
+ out.writeUInt32LE(rowsPerBlock, 168);
150
+ out.writeUInt32LE(rowDigestBytes, 172);
151
+ out.writeUInt32LE(pageTableOffset, 176);
152
+ }
153
+
154
+ for (let i = 0; i < count; i++) out.writeFloatLE(index.scales[i], scalesOffset + i * 4);
155
+ for (let i = 0; i < count; i++) out.writeFloatLE(index.offsets[i], offsetsOffset + i * 4);
156
+
157
+ for (let i = 0; i < count; i++) {
158
+ const rowBase = i * dim;
159
+ for (let sd = 0; sd < sketchDims; sd++) {
160
+ let acc = 0;
161
+ for (let j = 0; j < pool; j++) acc += index.qdata[rowBase + sd * pool + j];
162
+ const pooled = Math.round(acc / pool);
163
+ if (sketchBits === 8) {
164
+ out[sketchesOffset + i * sketchRowBytes + sd] = pooled;
165
+ } else {
166
+ const q = Math.min(15, Math.round(pooled / 17));
167
+ const byteIndex = sketchesOffset + i * sketchRowBytes + (sd >> 1);
168
+ if (sd % 2 === 0) out[byteIndex] = (out[byteIndex] & 0xf0) | q;
169
+ else out[byteIndex] = (out[byteIndex] & 0x0f) | (q << 4);
170
+ }
171
+ }
172
+ if (microDims) {
173
+ for (let sd = 0; sd < microDims; sd++) {
174
+ let acc = 0;
175
+ for (let j = 0; j < microPool; j++) acc += index.qdata[rowBase + sd * microPool + j];
176
+ const pooled = Math.round(acc / microPool);
177
+ if (microBits === 8) {
178
+ out[microOffset + i * microRowBytes + sd] = pooled;
179
+ } else {
180
+ const q = Math.min(15, Math.round(pooled / 17));
181
+ const byteIndex = microOffset + i * microRowBytes + (sd >> 1);
182
+ if (sd % 2 === 0) out[byteIndex] = (out[byteIndex] & 0xf0) | q;
183
+ else out[byteIndex] = (out[byteIndex] & 0x0f) | (q << 4);
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ if (rowIntegrity) {
190
+ // Interleaved vectors region: [digest page][rows] per block. Digest
191
+ // pages are written first per block so the page hash (into the
192
+ // resident page table) covers the final page bytes; unused digest
193
+ // slots in the last block stay zero.
194
+ for (let b = 0; b < numBlocks; b++) {
195
+ const blockStart = vectorsOffset + b * blockBytes;
196
+ const rowsInBlock = Math.min(rowsPerBlock, count - b * rowsPerBlock);
197
+ for (let j = 0; j < rowsInBlock; j++) {
198
+ const id = b * rowsPerBlock + j;
199
+ const row = Buffer.from(index.qdata.buffer, index.qdata.byteOffset + id * dim, dim);
200
+ out.set(row, blockStart + pageBytes + j * dim);
201
+ out.set(sha256Bytes(row).subarray(0, rowDigestBytes), blockStart + j * rowDigestBytes);
202
+ }
203
+ out.set(sha256Bytes(out.subarray(blockStart, blockStart + pageBytes)), pageTableOffset + b * 32);
204
+ }
205
+ } else {
206
+ Buffer.from(index.qdata.buffer, index.qdata.byteOffset, count * dim).copy(out, vectorsOffset);
207
+ }
208
+
209
+ sha256Bytes(out.subarray(SKETCH_HEADER_BYTES, vectorsOffset)).copy(out, 56);
210
+ sha256Bytes(out.subarray(vectorsOffset)).copy(out, 88);
211
+ if (microDims) {
212
+ // Stage-1 hash: scales+offsets plus the micro segment (and, on v2,
213
+ // the page-hash table, so staged readers can verify rows during the
214
+ // micro window) — exactly the bytes a staged open serves from,
215
+ // verifiable before the full sketches arrive. The full resident hash
216
+ // still covers everything.
217
+ const stage1Parts = [
218
+ out.subarray(SKETCH_HEADER_BYTES, sketchesOffset),
219
+ out.subarray(microOffset, microOffset + count * microRowBytes),
220
+ ];
221
+ if (rowIntegrity) stage1Parts.push(out.subarray(pageTableOffset, pageTableOffset + numBlocks * 32));
222
+ sha256Bytes(Buffer.concat(stage1Parts)).copy(out, 136);
223
+ }
224
+
225
+ const manifest = {
226
+ format: 'pancake-sketch-artifact',
227
+ formatVersion: rowIntegrity ? 2 : 1,
228
+ sizeBytes: fileBytes,
229
+ metric: index.metric === 1 ? 'cosine' : 'l2',
230
+ graph: { count, dim },
231
+ sketch: { sketchDims, sketchBits, pool, residentBytes: vectorsOffset },
232
+ micro: microDims ? { microDims, microBits, microPool, stage1Bytes: sketchesOffset + count * microRowBytes } : null,
233
+ rowIntegrity: rowIntegrity
234
+ ? { rowsPerBlock, rowDigestBytes, blocks: numBlocks, pageTableBytes: numBlocks * 32, regionBytes: vectorsRegionBytes }
235
+ : null,
236
+ addressing: { scalesOffset, offsetsOffset, sketchesOffset, vectorsOffset },
237
+ recommendedRerank,
238
+ };
239
+
240
+ if (outPath === null) return { bytes: out, manifest };
241
+
242
+ const fs = require('fs');
243
+ fs.writeFileSync(outPath, out);
244
+ return { ...manifest, file: outPath };
245
+ }
246
+
247
+ // Bytes-in/bytes-out variant for producers that assemble segments in memory
248
+ // (e.g. the complete-profile compiler): identical output to
249
+ // buildSketchArtifact, no filesystem involved. Returns { bytes, manifest }.
250
+ function buildSketchArtifactBytes(snapshotBytes, options = {}) {
251
+ return exportSketchArtifact(parseUint8Snapshot(snapshotBytes), null, options);
252
+ }
253
+
254
+ function buildSketchArtifactFile(snapshotPath, outPath, options = {}) {
255
+ const fs = require('fs');
256
+ return buildSketchArtifact(fs.readFileSync(snapshotPath), outPath, options);
257
+ }
258
+
259
+ class PancakeSketchArtifact {
260
+ constructor(source) {
261
+ this.source = source;
262
+ // Fetched rows live in a byte-budgeted LRU; search correctness never
263
+ // depends on retention because fetchRows returns rows directly.
264
+ this.cache = new Map();
265
+ this.cacheBytes = 0;
266
+ this.maxCacheBytes = 64 * 1024 * 1024;
267
+ this.rangeRequests = 0;
268
+ this.rangeBytes = 0;
269
+ this.maxReadBytes = DEFAULT_OPEN_READ_BYTES;
270
+ this.residentVerified = false;
271
+ this.vectorsVerified = false;
272
+ }
273
+
274
+ cachedRow(id) {
275
+ const row = this.cache.get(id);
276
+ if (row !== undefined) {
277
+ this.cache.delete(id);
278
+ this.cache.set(id, row);
279
+ }
280
+ return row;
281
+ }
282
+
283
+ // Drop all cached rows. Cache state never affects result semantics, so
284
+ // this only changes fetch behavior (every subsequent search starts cold).
285
+ clearCache() {
286
+ this.cache.clear();
287
+ this.cacheBytes = 0;
288
+ }
289
+
290
+ cacheRow(id, row) {
291
+ if (this.cache.has(id)) return;
292
+ this.cache.set(id, row);
293
+ this.cacheBytes += this.dim;
294
+ while (this.cacheBytes > this.maxCacheBytes && this.cache.size > 1) {
295
+ const oldest = this.cache.keys().next().value;
296
+ this.cache.delete(oldest);
297
+ this.cacheBytes -= this.dim;
298
+ }
299
+ }
300
+
301
+ static async open(source, options = {}) {
302
+ if (!source || typeof source.read !== 'function') {
303
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'SketchArtifact.open() requires a range source with read(offset, length)');
304
+ }
305
+ const artifact = new PancakeSketchArtifact(source);
306
+ artifact.maxReadBytes = resolveMaxReadBytes(options.maxReadBytes);
307
+ const header = await readChecked(source, 0, SKETCH_HEADER_BYTES, 'header');
308
+ if (header.byteLength !== SKETCH_HEADER_BYTES) {
309
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact header is truncated');
310
+ }
311
+ const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
312
+ if (view.getUint32(0, true) !== SKETCH_MAGIC) {
313
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Not a Pancake sketch artifact (bad magic)');
314
+ }
315
+ const version = view.getUint32(4, true);
316
+ if (version !== 1 && version !== 2) {
317
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported sketch artifact version', { version });
318
+ }
319
+ artifact.formatVersion = version;
320
+ if (view.getUint32(8, true) !== SKETCH_KIND_U8) {
321
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported sketch artifact kind');
322
+ }
323
+ artifact.metric = view.getUint32(12, true);
324
+ artifact.dim = view.getUint32(16, true);
325
+ artifact.count = view.getUint32(20, true);
326
+ artifact.sketchDims = view.getUint32(24, true);
327
+ artifact.sketchBits = view.getUint32(28, true);
328
+ const scalesOffset = view.getUint32(32, true);
329
+ const offsetsOffset = view.getUint32(36, true);
330
+ const sketchesOffset = view.getUint32(40, true);
331
+ artifact.vectorsOffset = view.getUint32(44, true);
332
+ const fileBytes = Number(view.getBigUint64(48, true));
333
+ artifact.recommendedRerank = view.getUint32(120, true);
334
+ // Retained so the lazy tier stays verifiable after open — see
335
+ // verifyVectors(). Copied out of the header read buffer.
336
+ artifact.vectorsSha256 = new Uint8Array(header.subarray(88, 120));
337
+ artifact.microDims = view.getUint32(124, true);
338
+ artifact.microBits = artifact.microDims ? view.getUint32(128, true) : 0;
339
+ const microOffset = artifact.microDims ? view.getUint32(132, true) : 0;
340
+ artifact.rowsPerBlock = version >= 2 ? view.getUint32(168, true) : 0;
341
+ artifact.rowDigestBytes = version >= 2 ? view.getUint32(172, true) : 0;
342
+ const pageTableOffset = version >= 2 ? view.getUint32(176, true) : 0;
343
+ artifact.maxCacheBytes = resolveMaxCacheBytes(options.maxCacheBytes, 256 * artifact.dim, artifact.maxCacheBytes);
344
+
345
+ const { metric, dim, count, sketchDims, sketchBits, vectorsOffset } = artifact;
346
+ if (metric !== 0 && metric !== 1) {
347
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported sketch artifact metric', { metric });
348
+ }
349
+ if (dim < 1 || count < 1 || sketchDims < 1 || dim % sketchDims !== 0) {
350
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Invalid sketch artifact geometry', { dim, count, sketchDims });
351
+ }
352
+ if (![4, 8].includes(sketchBits) || (sketchBits === 4 && sketchDims % 2 !== 0)) {
353
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Invalid sketch encoding', { sketchBits, sketchDims });
354
+ }
355
+ const sketchRowBytes = (sketchDims * sketchBits) / 8;
356
+ // Version 2 interleaves a digest page ahead of each rowsPerBlock rows
357
+ // and stores a 32-byte-per-block page-hash table at the end of the
358
+ // resident prefix; the vectors region size follows from that geometry.
359
+ if (version >= 2) {
360
+ const P = artifact.rowsPerBlock;
361
+ const D = artifact.rowDigestBytes;
362
+ if (!Number.isInteger(P) || P < 1 || P > 4096 || !Number.isInteger(D) || D < 8 || D > 32) {
363
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Invalid sketch row-integrity geometry', { rowsPerBlock: P, rowDigestBytes: D });
364
+ }
365
+ artifact.numBlocks = Math.ceil(count / P);
366
+ artifact.pageBytes = P * D;
367
+ artifact.blockBytes = artifact.pageBytes + P * dim;
368
+ artifact.vectorsRegionBytes = (artifact.numBlocks - 1) * artifact.blockBytes
369
+ + artifact.pageBytes + (count - (artifact.numBlocks - 1) * P) * dim;
370
+ if (!Number.isSafeInteger(artifact.vectorsRegionBytes)
371
+ || pageTableOffset < sketchesOffset + count * sketchRowBytes
372
+ || pageTableOffset + artifact.numBlocks * 32 > vectorsOffset) {
373
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact page table layout is inconsistent', { pageTableOffset });
374
+ }
375
+ } else {
376
+ artifact.numBlocks = 0;
377
+ artifact.vectorsRegionBytes = count * dim;
378
+ }
379
+ const expectVectors = artifact.vectorsRegionBytes;
380
+ if (!Number.isSafeInteger(fileBytes) || !Number.isSafeInteger(expectVectors)
381
+ || scalesOffset !== SKETCH_HEADER_BYTES
382
+ || offsetsOffset !== scalesOffset + count * 4
383
+ || sketchesOffset !== offsetsOffset + count * 4
384
+ || vectorsOffset < sketchesOffset + count * sketchRowBytes
385
+ || vectorsOffset % 16 !== 0
386
+ || fileBytes !== vectorsOffset + expectVectors) {
387
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact layout is inconsistent');
388
+ }
389
+ let microRowBytes = 0;
390
+ if (artifact.microDims) {
391
+ microRowBytes = (artifact.microDims * artifact.microBits) / 8;
392
+ if (sketchDims % artifact.microDims !== 0 || artifact.microDims >= sketchDims
393
+ || ![4, 8].includes(artifact.microBits) || (artifact.microBits === 4 && artifact.microDims % 2 !== 0)
394
+ || microOffset !== sketchesOffset + count * sketchRowBytes
395
+ || microOffset + count * microRowBytes > vectorsOffset) {
396
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact micro tier layout is inconsistent', { microDims: artifact.microDims });
397
+ }
398
+ }
399
+ artifact.microOffset = microOffset;
400
+ if (version >= 2) {
401
+ // The page table must sit exactly at the end of the sketch tiers,
402
+ // still inside the resident prefix residentSha256 covers.
403
+ const tiersEnd = artifact.microDims ? microOffset + count * microRowBytes : sketchesOffset + count * sketchRowBytes;
404
+ if (pageTableOffset !== tiersEnd) {
405
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact page table layout is inconsistent', { pageTableOffset, tiersEnd });
406
+ }
407
+ }
408
+ artifact.pageTableOffset = pageTableOffset;
409
+
410
+ const verify = options.verify !== false;
411
+ // Per-row verification on format 2 follows the same switch as the
412
+ // resident hash: on by default, verify:false opts the whole open out.
413
+ artifact.verifyRows = version >= 2 && verify;
414
+ const staged = options.staged === true && artifact.microDims > 0;
415
+ artifact.tier = 'full';
416
+ artifact.fullyResident = Promise.resolve(artifact);
417
+
418
+ if (!staged) {
419
+ const resident = await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
420
+ if (resident.byteLength !== vectorsOffset - SKETCH_HEADER_BYTES) {
421
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact resident prefix is truncated');
422
+ }
423
+ // Copy into an aligned buffer so the typed-array views are valid
424
+ // regardless of the source's byteOffset.
425
+ const residentCopy = new Uint8Array(resident.byteLength);
426
+ residentCopy.set(resident);
427
+ artifact._adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset);
428
+ if (verify) await artifact._verifyFullResident(residentCopy, header);
429
+ return artifact;
430
+ }
431
+
432
+ // Staged boot: serve queries from the coarse micro tier after
433
+ // fetching only scales+offsets and the micro segment (one parallel
434
+ // wave after the header), then complete residency in the background.
435
+ // Stage 1 is independently hash-verified; stage 2 re-verifies the
436
+ // full resident hash before the tier swap, so both states honor the
437
+ // integrity contract. Result semantics differ between tiers, so the
438
+ // tier is reported on every search result rather than hidden.
439
+ artifact.tier = 'micro';
440
+ // On v2 the page-hash table joins the stage-1 wave (and its hash), so
441
+ // rows fetched during the micro window are verifiable too.
442
+ const pageTableBytes = version >= 2 ? artifact.numBlocks * 32 : 0;
443
+ const [affineBytes, microBytes, pageTableRead] = await Promise.all([
444
+ readChecked(source, SKETCH_HEADER_BYTES, sketchesOffset - SKETCH_HEADER_BYTES, 'stage-1 affine', artifact.maxReadBytes),
445
+ readChecked(source, microOffset, count * microRowBytes, 'stage-1 micro', artifact.maxReadBytes),
446
+ version >= 2 ? readChecked(source, pageTableOffset, pageTableBytes, 'stage-1 page table', artifact.maxReadBytes) : null,
447
+ ]);
448
+ if (affineBytes.byteLength !== sketchesOffset - SKETCH_HEADER_BYTES || microBytes.byteLength !== count * microRowBytes
449
+ || (version >= 2 && pageTableRead.byteLength !== pageTableBytes)) {
450
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact stage-1 read is truncated');
451
+ }
452
+ const affineCopy = new Uint8Array(affineBytes.byteLength);
453
+ affineCopy.set(affineBytes);
454
+ const microCopy = new Uint8Array(microBytes.byteLength);
455
+ microCopy.set(microBytes);
456
+ const pageTableCopy = version >= 2 ? new Uint8Array(pageTableRead.byteLength) : null;
457
+ if (pageTableCopy) pageTableCopy.set(pageTableRead);
458
+ if (verify) {
459
+ const stage1 = new Uint8Array(affineCopy.byteLength + microCopy.byteLength + (pageTableCopy ? pageTableCopy.byteLength : 0));
460
+ stage1.set(affineCopy, 0);
461
+ stage1.set(microCopy, affineCopy.byteLength);
462
+ if (pageTableCopy) stage1.set(pageTableCopy, affineCopy.byteLength + microCopy.byteLength);
463
+ const digest = await sha256BytesAsync(stage1);
464
+ if (!digest) {
465
+ // Verification requested but no crypto backend: fail closed.
466
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
467
+ 'Sketch artifact verification requested but no crypto backend is available; pass verify:false to skip');
468
+ }
469
+ const expected = header.subarray(136, 168);
470
+ for (let b = 0; b < 32; b++) {
471
+ if (digest[b] !== expected[b]) {
472
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact stage-1 prefix failed hash verification');
473
+ }
474
+ }
475
+ artifact.residentVerified = true;
476
+ }
477
+ artifact.scales = new Float32Array(affineCopy.buffer, 0, count);
478
+ artifact.offsets = new Float32Array(affineCopy.buffer, count * 4, count);
479
+ artifact.sketches = null;
480
+ artifact.microSketches = microCopy;
481
+ if (pageTableCopy) artifact.pageTable = pageTableCopy;
482
+ artifact.residentBytes = SKETCH_HEADER_BYTES + affineCopy.byteLength + microCopy.byteLength + (pageTableCopy ? pageTableCopy.byteLength : 0);
483
+
484
+ const onStage = typeof options.onStage === 'function' ? options.onStage : null;
485
+ if (onStage) onStage({ tier: 'micro', residentBytes: artifact.residentBytes });
486
+ artifact.fullyResident = (async () => {
487
+ const rest = await readChecked(source, sketchesOffset, vectorsOffset - sketchesOffset, 'stage-2 sketches', artifact.maxReadBytes);
488
+ if (rest.byteLength !== vectorsOffset - sketchesOffset) {
489
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact stage-2 read is truncated');
490
+ }
491
+ const residentCopy = new Uint8Array((sketchesOffset - SKETCH_HEADER_BYTES) + rest.byteLength);
492
+ residentCopy.set(affineCopy, 0);
493
+ residentCopy.set(rest, sketchesOffset - SKETCH_HEADER_BYTES);
494
+ if (verify) await artifact._verifyFullResident(residentCopy, header);
495
+ artifact._adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset);
496
+ artifact.tier = 'full';
497
+ if (onStage) onStage({ tier: 'full', residentBytes: artifact.residentBytes });
498
+ return artifact;
499
+ })();
500
+ // Surfacing failures is the caller's job via the promise; an
501
+ // unobserved rejection must not crash the process mid-boot.
502
+ artifact.fullyResident.catch(() => {});
503
+ return artifact;
504
+ }
505
+
506
+ _adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset) {
507
+ this.scales = new Float32Array(residentCopy.buffer, 0, count);
508
+ this.offsets = new Float32Array(residentCopy.buffer, count * 4, count);
509
+ this.sketches = new Uint8Array(residentCopy.buffer, count * 8, count * sketchRowBytes);
510
+ if (this.microDims) {
511
+ this.microSketches = new Uint8Array(residentCopy.buffer, microOffset - SKETCH_HEADER_BYTES, count * microRowBytes);
512
+ }
513
+ if (this.formatVersion >= 2) {
514
+ this.pageTable = new Uint8Array(residentCopy.buffer, this.pageTableOffset - SKETCH_HEADER_BYTES, this.numBlocks * 32);
515
+ }
516
+ this.residentBytes = this.vectorsOffset;
517
+ }
518
+
519
+ async _verifyFullResident(residentCopy, header) {
520
+ const digest = await sha256BytesAsync(residentCopy);
521
+ if (!digest) {
522
+ // Verification was requested (callers gate this on verify) but no
523
+ // crypto backend is available. Fail closed rather than admit
524
+ // unverified bytes.
525
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
526
+ 'Sketch artifact verification requested but no crypto backend is available; pass verify:false to skip');
527
+ }
528
+ const expected = header.subarray(56, 88);
529
+ for (let b = 0; b < 32; b++) {
530
+ if (digest[b] !== expected[b]) {
531
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact resident prefix failed hash verification');
532
+ }
533
+ }
534
+ this.residentVerified = true;
535
+ }
536
+
537
+ static async openFile(filePath, options = {}) {
538
+ // The source owns a file descriptor from construction; a rejected
539
+ // open must release it, or every corrupt artifact leaks an fd.
540
+ const source = new NodeFileRangeSource(filePath);
541
+ try {
542
+ return await PancakeSketchArtifact.open(source, options);
543
+ } catch (err) {
544
+ await source.close().catch(() => {});
545
+ throw err;
546
+ }
547
+ }
548
+
549
+ stats() {
550
+ return {
551
+ rangeRequests: this.rangeRequests,
552
+ rangeBytes: this.rangeBytes,
553
+ cachedRows: this.cache.size,
554
+ cacheBytes: this.cacheBytes,
555
+ residentBytes: this.residentBytes,
556
+ residentVerified: this.residentVerified,
557
+ vectorsVerified: this.vectorsVerified,
558
+ };
559
+ }
560
+
561
+ // Verify the lazy vectors segment against the header's whole-segment
562
+ // hash. Reads the segment in bounded chunks with a streaming hash (Node);
563
+ // runtimes without one fall back to a single read bounded by the open
564
+ // budget. Intended for producers, CI, and hosts that materialize
565
+ // artifacts, not the query path. Verifying individual row ranges needs
566
+ // per-chunk commitments, which arrive with the contract's
567
+ // complete-profile manifest.
568
+ async verifyVectors(options = {}) {
569
+ const bytes = this.vectorsRegionBytes;
570
+ await verifySegmentSha256(this.source, this.vectorsOffset, bytes, this.vectorsSha256,
571
+ 'Sketch artifact vectors segment', { chunkBytes: options.chunkBytes, oneShotLimit: this.maxReadBytes });
572
+ this.vectorsVerified = true;
573
+ return true;
574
+ }
575
+
576
+ sketchValue(id, sd) {
577
+ if (this.sketchBits === 8) return this.sketches[id * this.sketchDims + sd];
578
+ const byte = this.sketches[id * (this.sketchDims >> 1) + (sd >> 1)];
579
+ const nibble = sd % 2 === 0 ? (byte & 0x0f) : (byte >> 4);
580
+ return nibble * 17;
581
+ }
582
+
583
+ microValue(id, sd) {
584
+ if (this.microBits === 8) return this.microSketches[id * this.microDims + sd];
585
+ const byte = this.microSketches[id * (this.microDims >> 1) + (sd >> 1)];
586
+ const nibble = sd % 2 === 0 ? (byte & 0x0f) : (byte >> 4);
587
+ return nibble * 17;
588
+ }
589
+
590
+ // Active resident tier for candidate selection. 'full' whenever the full
591
+ // sketches are adopted; 'micro' only during a staged boot window.
592
+ activeTier() {
593
+ return this.tier === 'micro'
594
+ ? { name: 'micro', dims: this.microDims, value: (i, sd) => this.microValue(i, sd) }
595
+ : { name: 'full', dims: this.sketchDims, value: (i, sd) => this.sketchValue(i, sd) };
596
+ }
597
+
598
+ async fetchRows(ids, options = {}) {
599
+ if (this.formatVersion >= 2) return this._fetchRowsVerified(ids, options);
600
+ const defaultGap = this.source.preferredGapBytes === undefined ? 2048 : this.source.preferredGapBytes;
601
+ const gap = Math.max(0, options.gap === undefined ? defaultGap : options.gap);
602
+ const preferredParallelism = this.source.preferredParallelism === undefined ? 32 : this.source.preferredParallelism;
603
+ const requestedParallelism = options.parallelism === undefined ? preferredParallelism : options.parallelism;
604
+ const parallelism = requestedParallelism === 0 ? Infinity : Math.max(1, Math.trunc(requestedParallelism || 32));
605
+ const rows = new Map();
606
+ const missing = [];
607
+ for (const id of ids) {
608
+ if (rows.has(id)) continue;
609
+ if (!Number.isInteger(id) || id < 0 || id >= this.count) {
610
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'row id out of range', { id });
611
+ }
612
+ const cached = this.cachedRow(id);
613
+ if (cached !== undefined) rows.set(id, cached);
614
+ else {
615
+ rows.set(id, null);
616
+ missing.push(id);
617
+ }
618
+ }
619
+ if (!missing.length) return rows;
620
+ missing.sort((a, b) => a - b);
621
+ const dim = this.dim;
622
+ // Coalesced runs are split at maxRangeBytes (row-aligned) so no gap
623
+ // setting — or a bulk fetch of every row — turns into one unbounded
624
+ // read and allocation.
625
+ const maxRunRows = Math.max(1, Math.floor(Math.trunc(options.maxRangeBytes || MAX_COALESCED_RANGE_BYTES) / dim));
626
+ const ranges = [];
627
+ const pushRun = (startId, endId) => {
628
+ for (let piece = startId; piece < endId; piece += maxRunRows) {
629
+ ranges.push([piece, Math.min(piece + maxRunRows, endId)]);
630
+ }
631
+ };
632
+ let runStartId = missing[0];
633
+ let runEndId = missing[0] + 1;
634
+ for (let i = 1; i < missing.length; i++) {
635
+ const id = missing[i];
636
+ if (id * dim <= runEndId * dim + gap) {
637
+ runEndId = id + 1;
638
+ } else {
639
+ pushRun(runStartId, runEndId);
640
+ runStartId = id;
641
+ runEndId = id + 1;
642
+ }
643
+ }
644
+ pushRun(runStartId, runEndId);
645
+ const buffers = await mapLimit(ranges, parallelism, async ([startId, endId]) => {
646
+ const offset = this.vectorsOffset + startId * dim;
647
+ const length = (endId - startId) * dim;
648
+ const bytes = await readChecked(this.source, offset, length, 'row');
649
+ if (bytes.byteLength !== length) {
650
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row read returned a truncated range', { offset, length });
651
+ }
652
+ this.rangeRequests++;
653
+ this.rangeBytes += length;
654
+ return { startId, endId, bytes };
655
+ });
656
+ for (const { startId, endId, bytes } of buffers) {
657
+ for (let id = startId; id < endId; id++) {
658
+ // Copy each row out of the coalesced fetch buffer: a view
659
+ // would pin the whole range in the LRU, so cacheBytes
660
+ // would count dim bytes while retaining the full fetch.
661
+ // (Explicit copy constructor — .slice() is unreliable here
662
+ // because Node Buffers override it with view semantics.)
663
+ const row = new Uint8Array(bytes.subarray((id - startId) * dim, (id - startId + 1) * dim));
664
+ if (rows.has(id)) rows.set(id, row);
665
+ this.cacheRow(id, row);
666
+ }
667
+ }
668
+ return rows;
669
+ }
670
+
671
+ // Format-2 row fetch: rows live in interleaved blocks
672
+ // [digest page][rowsPerBlock rows], so each needed block is fetched from
673
+ // its page through the last needed row (the page rides inside the same
674
+ // read), the page is verified against the resident page-hash table —
675
+ // which residentSha256, and any identity committing to the header,
676
+ // anchors — and each row against its truncated digest in the page,
677
+ // before anything is returned or cached. Cached rows are already
678
+ // verified, so repeat hits cost nothing.
679
+ async _fetchRowsVerified(ids, options = {}) {
680
+ const defaultGap = this.source.preferredGapBytes === undefined ? 2048 : this.source.preferredGapBytes;
681
+ const gap = Math.max(0, options.gap === undefined ? defaultGap : options.gap);
682
+ const preferredParallelism = this.source.preferredParallelism === undefined ? 32 : this.source.preferredParallelism;
683
+ const requestedParallelism = options.parallelism === undefined ? preferredParallelism : options.parallelism;
684
+ const parallelism = requestedParallelism === 0 ? Infinity : Math.max(1, Math.trunc(requestedParallelism || 32));
685
+ const rows = new Map();
686
+ const missing = [];
687
+ for (const id of ids) {
688
+ if (rows.has(id)) continue;
689
+ if (!Number.isInteger(id) || id < 0 || id >= this.count) {
690
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'row id out of range', { id });
691
+ }
692
+ const cached = this.cachedRow(id);
693
+ if (cached !== undefined) rows.set(id, cached);
694
+ else {
695
+ rows.set(id, null);
696
+ missing.push(id);
697
+ }
698
+ }
699
+ if (!missing.length) return rows;
700
+ missing.sort((a, b) => a - b);
701
+ const { dim, rowsPerBlock, rowDigestBytes, pageBytes, blockBytes, vectorsOffset } = this;
702
+ const blocks = new Map();
703
+ for (const id of missing) {
704
+ const b = Math.floor(id / rowsPerBlock);
705
+ let entry = blocks.get(b);
706
+ if (!entry) { entry = { maxIdx: 0, ids: [] }; blocks.set(b, entry); }
707
+ entry.ids.push(id);
708
+ const idx = id - b * rowsPerBlock;
709
+ if (idx > entry.maxIdx) entry.maxIdx = idx;
710
+ }
711
+ // Coalesce block spans into runs; a run always holds whole block
712
+ // spans and is split before exceeding maxRangeBytes (floor one block,
713
+ // so a single block is always fetchable).
714
+ const maxRunBytes = Math.max(blockBytes, Math.trunc(options.maxRangeBytes || MAX_COALESCED_RANGE_BYTES));
715
+ const runs = [];
716
+ let run = null;
717
+ for (const b of [...blocks.keys()].sort((x, y) => x - y)) {
718
+ const start = vectorsOffset + b * blockBytes;
719
+ const end = start + pageBytes + (blocks.get(b).maxIdx + 1) * dim;
720
+ if (run && start <= run.end + gap && end - run.start <= maxRunBytes) {
721
+ run.end = end;
722
+ run.blocks.push(b);
723
+ } else {
724
+ if (run) runs.push(run);
725
+ run = { start, end, blocks: [b] };
726
+ }
727
+ }
728
+ runs.push(run);
729
+ const buffers = await mapLimit(runs, parallelism, async (r) => {
730
+ const length = r.end - r.start;
731
+ const bytes = await readChecked(this.source, r.start, length, 'row block');
732
+ if (bytes.byteLength !== length) {
733
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row read returned a truncated range', { offset: r.start, length });
734
+ }
735
+ this.rangeRequests++;
736
+ this.rangeBytes += length;
737
+ return { r, bytes };
738
+ });
739
+ for (const { r, bytes } of buffers) {
740
+ for (const b of r.blocks) {
741
+ const rel = vectorsOffset + b * blockBytes - r.start;
742
+ const page = bytes.subarray(rel, rel + pageBytes);
743
+ if (this.verifyRows) {
744
+ const pageDigest = await sha256BytesAsync(page);
745
+ if (!pageDigest) {
746
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
747
+ 'Sketch artifact row verification requested but no crypto backend is available; pass verify:false to skip');
748
+ }
749
+ const expected = this.pageTable.subarray(b * 32, b * 32 + 32);
750
+ for (let i = 0; i < 32; i++) {
751
+ if (pageDigest[i] !== expected[i]) {
752
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row digest page failed hash verification', { block: b });
753
+ }
754
+ }
755
+ }
756
+ for (const id of blocks.get(b).ids) {
757
+ const idx = id - b * rowsPerBlock;
758
+ const row = new Uint8Array(bytes.subarray(rel + pageBytes + idx * dim, rel + pageBytes + (idx + 1) * dim));
759
+ if (this.verifyRows) {
760
+ const digest = await sha256BytesAsync(row);
761
+ const slot = page.subarray(idx * rowDigestBytes, (idx + 1) * rowDigestBytes);
762
+ for (let i = 0; i < rowDigestBytes; i++) {
763
+ if (digest[i] !== slot[i]) {
764
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row failed digest verification', { id });
765
+ }
766
+ }
767
+ }
768
+ if (rows.has(id)) rows.set(id, row);
769
+ this.cacheRow(id, row);
770
+ }
771
+ }
772
+ }
773
+ return rows;
774
+ }
775
+
776
+ async search(queryInput, k, options = {}) {
777
+ // Same query contract as the core engine and the range reader:
778
+ // numeric, finite, right dimension, usable cosine norm (normalized
779
+ // here for cosine; the pooling below then sees a unit vector).
780
+ const query = normalizeQueryVector(queryInput, this.dim, this.metric);
781
+ k = resolveSearchK(k, this.count);
782
+ if (k === 0) return { results: [], stats: this.stats() };
783
+ const tier = this.activeTier();
784
+ // The micro tier is coarser, so its candidate pool defaults wider:
785
+ // an explicit options.rerank always wins; otherwise recommendedRerank
786
+ // is scaled by microBoost while serving from the micro tier. The
787
+ // candidate pool C can never usefully exceed the row count, and it
788
+ // sizes the selection buffers below, so it is bounded by count before
789
+ // anything is allocated — a k or rerank of 1e9 over a small artifact
790
+ // must not try to allocate 1e9 slots.
791
+ const base = resolveOptionalPositiveInt(options.rerank, 'rerank', 0);
792
+ const boost = Math.max(1, Math.trunc(options.microBoost || 4));
793
+ const rec = this.recommendedRerank || Math.max(100, k * 10);
794
+ const C = Math.min(this.count, Math.max(k, base > 0 ? base : tier.name === 'micro' ? rec * boost : rec));
795
+ const { dim, count } = this;
796
+ const tierDims = tier.dims;
797
+ const pool = dim / tierDims;
798
+
799
+ let q = query;
800
+ if (this.metric === 1) {
801
+ let norm = 0;
802
+ for (let d = 0; d < dim; d++) norm += query[d] * query[d];
803
+ norm = Math.sqrt(norm);
804
+ if (!(norm > 0) || !Number.isFinite(norm)) {
805
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'cosine query must have a nonzero finite norm');
806
+ }
807
+ q = new Float32Array(dim);
808
+ for (let d = 0; d < dim; d++) q[d] = query[d] / norm;
809
+ }
810
+ const qPool = new Float32Array(tierDims);
811
+ for (let sd = 0; sd < tierDims; sd++) {
812
+ let acc = 0;
813
+ for (let j = 0; j < pool; j++) acc += q[sd * pool + j];
814
+ qPool[sd] = acc / pool;
815
+ }
816
+
817
+ let ids;
818
+ let scanner = options.scanner || null;
819
+ if (scanner && scanner.sketchDims !== undefined && scanner.sketchDims !== tierDims) scanner = null;
820
+ if (!scanner && options.microScanner && options.microScanner.sketchDims === tierDims) scanner = options.microScanner;
821
+ // A scanner's output buffers are sized at creation (maxRerank); a
822
+ // C beyond them would silently shrink the candidate pool — recall
823
+ // loss, not an error. Fall back to the JS scan for that query.
824
+ if (scanner && scanner.maxRerank !== undefined && C > scanner.maxRerank) scanner = null;
825
+ if (scanner) {
826
+ // A scanner scores the resident sketches itself, so it must
827
+ // implement this artifact's metric. Cosine requires an explicit
828
+ // declaration: a metric-blind scanner silently loses recall there.
829
+ const scannerMetric = scanner.metric;
830
+ if (scannerMetric !== undefined && scannerMetric !== this.metric) {
831
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'scanner metric does not match artifact metric', { scannerMetric, metric: this.metric });
832
+ }
833
+ if (this.metric === 1 && scannerMetric !== 1) {
834
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'cosine sketch artifacts require a metric-aware scanner (scanner.metric === 1)', { metric: this.metric });
835
+ }
836
+ ids = scanner.scan(qPool, C);
837
+ } else if (C >= count) {
838
+ // Every row is a candidate: the exact rerank below scores them
839
+ // all, so the resident scan has nothing to select. Without this
840
+ // the selection loop degenerates to 2*count^2 slot operations
841
+ // (candMax stays Infinity until the last slot fills).
842
+ ids = Array.from({ length: count }, (_, i) => i);
843
+ } else {
844
+ const candDist = new Float64Array(C).fill(Infinity);
845
+ const candId = new Int32Array(C).fill(-1);
846
+ let candMax = Infinity;
847
+ for (let i = 0; i < count; i++) {
848
+ const s = this.scales[i];
849
+ const o = this.offsets[i];
850
+ let metricAcc = 0;
851
+ if (this.metric === 1) {
852
+ for (let sd = 0; sd < tierDims; sd++) {
853
+ metricAcc += qPool[sd] * (o + s * tier.value(i, sd));
854
+ }
855
+ metricAcc = 1 - Math.max(-1, Math.min(1, metricAcc * pool));
856
+ } else {
857
+ for (let sd = 0; sd < tierDims; sd++) {
858
+ const diff = qPool[sd] - (o + s * tier.value(i, sd));
859
+ metricAcc += diff * diff;
860
+ }
861
+ }
862
+ if (metricAcc < candMax) {
863
+ let worst = 0;
864
+ for (let j = 1; j < C; j++) if (candDist[j] > candDist[worst]) worst = j;
865
+ candDist[worst] = metricAcc;
866
+ candId[worst] = i;
867
+ candMax = 0;
868
+ for (let j = 0; j < C; j++) if (candDist[j] > candMax) candMax = candDist[j];
869
+ }
870
+ }
871
+ ids = [];
872
+ for (let j = 0; j < C; j++) if (candId[j] >= 0) ids.push(candId[j]);
873
+ }
874
+
875
+ // Externally supplied candidates (e.g. a lexical index's matches in
876
+ // a hybrid query) join the exact rerank alongside the sketch scan's
877
+ // selection: they are scored by true distance like any candidate,
878
+ // so a lexically-found row that is also a near neighbor surfaces
879
+ // even when the resident scan's top-C missed it.
880
+ if (options.extraCandidates !== undefined) {
881
+ if (!Array.isArray(options.extraCandidates)) {
882
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'extraCandidates must be an array of row ids');
883
+ }
884
+ if (!Array.isArray(ids)) ids = Array.from(ids); // a custom scanner may return a typed array
885
+ const have = new Set(ids);
886
+ for (const id of options.extraCandidates) {
887
+ if (!Number.isInteger(id) || id < 0 || id >= count) {
888
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'extraCandidates ids must be integers in [0, count)', { id, count });
889
+ }
890
+ if (!have.has(id)) {
891
+ have.add(id);
892
+ ids.push(id);
893
+ }
894
+ }
895
+ }
896
+
897
+ const rows = await this.fetchRows(ids, options);
898
+
899
+ const exact = [];
900
+ for (const id of ids) {
901
+ const row = rows.get(id);
902
+ const s = this.scales[id];
903
+ const o = this.offsets[id];
904
+ let acc = 0;
905
+ if (this.metric === 1) {
906
+ for (let d = 0; d < dim; d++) acc += q[d] * (o + s * row[d]);
907
+ acc = 1 - Math.max(-1, Math.min(1, acc));
908
+ } else {
909
+ for (let d = 0; d < dim; d++) {
910
+ const diff = q[d] - (o + s * row[d]);
911
+ acc += diff * diff;
912
+ }
913
+ }
914
+ exact.push([acc, id]);
915
+ }
916
+ exact.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
917
+ // Rerank accumulates squared L2; the API contract (README "Distance
918
+ // values") reports Euclidean, matching PancakeIndex.search.
919
+ const sqrtL2 = this.metric !== 1;
920
+ // fullRerankOutput returns every reranked candidate in distance
921
+ // order instead of the top k — the candidates are already fetched
922
+ // and exactly scored, so this changes only the output slice. Hybrid
923
+ // callers use it so rank fusion sees the true distance of every
924
+ // lexical candidate.
925
+ const selected = options.fullRerankOutput ? exact : exact.slice(0, k);
926
+ return {
927
+ results: selected.map(([distance, id]) => ({ id, distance: sqrtL2 ? Math.sqrt(distance) : distance })),
928
+ rerank: ids.length,
929
+ tier: tier.name,
930
+ };
931
+ }
932
+
933
+ async close() {
934
+ if (this.source && typeof this.source.close === 'function') await this.source.close();
935
+ }
936
+ }
937
+
938
+ // Build a WASM-backed scanner for a sketch artifact's resident tier, usable
939
+ // as the `scanner` option to PancakeSketchArtifact.search(). The engine's
940
+ // pancake_sketch_scan SIMD kernel runs the O(count*sketchDims) resident scan
941
+ // that is otherwise the browser query bottleneck. `loadEngine` is the
942
+ // entrypoint's own async engine loader (Node/web/workerd all supply one);
943
+ // the sketch tier is staged in the engine heap once at creation.
944
+ async function createSketchScanner(loadEngine, artifact, options = {}) {
945
+ if (typeof loadEngine !== 'function') {
946
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'createSketchScanner() requires an engine loader');
947
+ }
948
+ if (!artifact || !(artifact instanceof PancakeSketchArtifact)) {
949
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'createSketchScanner() requires a sketch artifact');
950
+ }
951
+ const engine = await loadEngine();
952
+ const { count, metric } = artifact;
953
+ const tierName = options.tier === 'micro' ? 'micro' : 'full';
954
+ if (tierName === 'micro' && !artifact.microDims) {
955
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'artifact has no micro tier');
956
+ }
957
+ if (tierName === 'full' && !artifact.sketches) {
958
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'full sketches are not resident yet (staged open still in stage 1; await artifact.fullyResident)');
959
+ }
960
+ const sketchDims = tierName === 'micro' ? artifact.microDims : artifact.sketchDims;
961
+ const tierBits = tierName === 'micro' ? artifact.microBits : artifact.sketchBits;
962
+ const maxC = Math.max(1, Math.trunc(options.maxRerank || 1024));
963
+
964
+ // The kernel reads u8 sketch values; expand 4-bit nibbles to their
965
+ // reconstructed u8 once so the scan needs no per-element unpacking.
966
+ const expanded = new Uint8Array(count * sketchDims);
967
+ if (tierBits === 4) {
968
+ for (let i = 0; i < count; i++) {
969
+ for (let sd = 0; sd < sketchDims; sd++) expanded[i * sketchDims + sd] = tierName === 'micro' ? artifact.microValue(i, sd) : artifact.sketchValue(i, sd);
970
+ }
971
+ } else {
972
+ expanded.set(tierName === 'micro' ? artifact.microSketches : artifact.sketches);
973
+ }
974
+
975
+ const sketchesPtr = engine._emsc_malloc(expanded.length);
976
+ const scalesPtr = engine._emsc_malloc(count * 4);
977
+ const offsetsPtr = engine._emsc_malloc(count * 4);
978
+ const queryPtr = engine._emsc_malloc(sketchDims * 4);
979
+ const outIdsPtr = engine._emsc_malloc(maxC * 4);
980
+ const outDistsPtr = engine._emsc_malloc(maxC * 4);
981
+ if (!sketchesPtr || !scalesPtr || !offsetsPtr || !queryPtr || !outIdsPtr || !outDistsPtr) {
982
+ // Free whichever allocations succeeded before one failed, so a
983
+ // partial failure does not leak WASM heap (matches create()).
984
+ for (const ptr of [sketchesPtr, scalesPtr, offsetsPtr, queryPtr, outIdsPtr, outDistsPtr]) {
985
+ if (ptr) engine._emsc_free(ptr);
986
+ }
987
+ throw pancakeError(PANCAKE_ERROR_CODES.WASM_ALLOCATION_FAILED, 'sketch scanner heap allocation failed');
988
+ }
989
+ engine.HEAPU8.set(expanded, sketchesPtr);
990
+ engine.HEAPF32.set(artifact.scales, scalesPtr >> 2);
991
+ engine.HEAPF32.set(artifact.offsets, offsetsPtr >> 2);
992
+
993
+ let disposed = false;
994
+ return {
995
+ maxRerank: maxC,
996
+ metric,
997
+ sketchDims,
998
+ tier: tierName,
999
+ scan(pooledQuery, c) {
1000
+ if (disposed) throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'sketch scanner disposed');
1001
+ const query = pooledQuery instanceof Float32Array ? pooledQuery : Float32Array.from(pooledQuery);
1002
+ // The query is copied into a queryPtr sized for exactly sketchDims
1003
+ // floats: an over- or under-sized input would read/write outside
1004
+ // that buffer in the WASM heap. Validate before the copy.
1005
+ if (query.length !== sketchDims) {
1006
+ throw pancakeError(PANCAKE_ERROR_CODES.DIMENSION_MISMATCH,
1007
+ `scan() pooled query has ${query.length} values, expected ${sketchDims}`,
1008
+ { expected: sketchDims, actual: query.length });
1009
+ }
1010
+ for (let i = 0; i < query.length; i++) {
1011
+ if (!Number.isFinite(query[i])) {
1012
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR,
1013
+ 'scan() pooled query contains a non-finite value', { index: i });
1014
+ }
1015
+ }
1016
+ engine.HEAPF32.set(query, queryPtr >> 2);
1017
+ const topC = Math.min(Math.max(1, Math.trunc(c)), maxC);
1018
+ const n = engine._pancake_sketch_scan(
1019
+ sketchesPtr, scalesPtr, offsetsPtr, count, sketchDims, queryPtr, metric, topC, outIdsPtr, outDistsPtr
1020
+ );
1021
+ return Array.from(engine.HEAPU32.subarray(outIdsPtr >> 2, (outIdsPtr >> 2) + n));
1022
+ },
1023
+ dispose() {
1024
+ if (disposed) return;
1025
+ disposed = true;
1026
+ for (const ptr of [sketchesPtr, scalesPtr, offsetsPtr, queryPtr, outIdsPtr, outDistsPtr]) {
1027
+ engine._emsc_free(ptr);
1028
+ }
1029
+ },
1030
+ };
1031
+ }
1032
+
1033
+ module.exports = {
1034
+ PancakeSketchArtifact,
1035
+ createSketchScanner,
1036
+ buildSketchArtifact,
1037
+ buildSketchArtifactBytes,
1038
+ buildSketchArtifactFile,
1039
+ exportSketchArtifact,
1040
+ };