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.
- package/CHANGELOG.md +868 -0
- package/LICENSE +191 -0
- package/README.md +590 -0
- package/complete/builder.d.ts +82 -0
- package/complete/builder.mjs +527 -0
- package/complete/encoder-kernels/encoder.mjs +2 -0
- package/complete/encoder-kernels/encoder.node.mjs +2 -0
- package/complete/encoder-kernels/encoder.node.wasm +0 -0
- package/complete/encoder-kernels/encoder.wasm +0 -0
- package/complete/format.mjs +8 -0
- package/complete/index.d.ts +289 -0
- package/complete/index.mjs +1124 -0
- package/complete/inline-transformer.mjs +205 -0
- package/complete/lexical.mjs +264 -0
- package/complete/retrieval-abstention.mjs +119 -0
- package/complete/sources.mjs +186 -0
- package/complete/student-abstention.mjs +102 -0
- package/complete/student-embedder.mjs +231 -0
- package/complete/wordpiece.mjs +68 -0
- package/dist/engine.js +2 -0
- package/dist/engine.scalar.js +2 -0
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +168 -0
- package/pancake-artifact-common.js +561 -0
- package/pancake-artifact-range.js +799 -0
- package/pancake-artifact-sketch.js +1040 -0
- package/pancake-artifact.d.ts +109 -0
- package/pancake-artifact.js +31 -0
- package/pancake-core.js +1268 -0
- package/pancake-errors.js +40 -0
- package/pancake-loader.js +66 -0
- package/pancake.d.ts +691 -0
- package/pancake.js +254 -0
- package/pancake.node.mjs +259 -0
- package/pancake.web.d.ts +50 -0
- package/pancake.web.mjs +88 -0
- package/pancake.workerd.mjs +72 -0
|
@@ -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
|
+
};
|