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,1124 @@
|
|
|
1
|
+
// The one-file reader: open a .pancake (spec/COMPLETE_PROFILE.md) and query
|
|
2
|
+
// it. Environment-neutral — the same module runs in Node (file path or
|
|
3
|
+
// range source) and the browser (HTTP range source, via a bundler for the
|
|
4
|
+
// CJS sketch-reader dependency). The query path needs no core HNSW engine:
|
|
5
|
+
// encoder, sketch reference scan, hydration, and calibration all run in
|
|
6
|
+
// pure JS; kind-3 artifacts load the reader-owned transformer WASM kernels,
|
|
7
|
+
// and at scale the resident scan optionally accelerates through the
|
|
8
|
+
// engine's SIMD scan kernel (options.sketchScanner — auto-staged in Node,
|
|
9
|
+
// injected in browsers), which only ever changes speed, never results.
|
|
10
|
+
//
|
|
11
|
+
// const search = await openPancakeFile('pancake-docs.pancake'); // Node
|
|
12
|
+
// const search = await openPancakeFile(httpRangeSource(url)); // browser
|
|
13
|
+
// const out = await search.query('how do workers restore snapshots');
|
|
14
|
+
//
|
|
15
|
+
// Every byte of the artifact is untrusted input (contract section 7): each
|
|
16
|
+
// offset, length, and count is validated — safe integer, within the header's
|
|
17
|
+
// fileBytes and the source's size, under the per-open read budget — before a
|
|
18
|
+
// read is issued or a buffer allocated, and every read must return exactly
|
|
19
|
+
// the bytes asked for. Format 2 files (profile pancake-complete-v2) carry
|
|
20
|
+
// per-record corpus digests, so each hydrated record is verified on its own
|
|
21
|
+
// range read; format 1 files verify eager segments whole and hydrate records
|
|
22
|
+
// under the segment digest only (reported via info().corpusIntegrity).
|
|
23
|
+
|
|
24
|
+
import { loadStudentModel, embedTextWithStudent } from './student-embedder.mjs';
|
|
25
|
+
import { computeMatchQuality, computePreSearchAbstention } from './student-abstention.mjs';
|
|
26
|
+
import { createAbstentionScorer } from './retrieval-abstention.mjs';
|
|
27
|
+
import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
|
|
28
|
+
import { PancakeSketchArtifact } from '../pancake-artifact.js';
|
|
29
|
+
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
33
|
+
createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
|
|
34
|
+
buildInlineTestVectors, verifyInlineTestVectors,
|
|
35
|
+
validateExpectedEmbedding, validateTestVectorTolerance,
|
|
36
|
+
} from './inline-transformer.mjs';
|
|
37
|
+
|
|
38
|
+
export { httpRangeSource } from './sources.mjs';
|
|
39
|
+
// The project renamed to Pikelet (2026-09); the wire format and this
|
|
40
|
+
// reader's original export keep their pancake-era names for
|
|
41
|
+
// compatibility, and openPikeletFile is the documented alias.
|
|
42
|
+
export {
|
|
43
|
+
KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
44
|
+
createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
|
|
45
|
+
buildInlineTestVectors, verifyInlineTestVectors,
|
|
46
|
+
validateExpectedEmbedding, validateTestVectorTolerance,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Supported container formats: header formatVersion -> manifest profile.
|
|
50
|
+
export const SUPPORTED_PROFILES = Object.freeze({ 1: 'pancake-complete-v1', 2: 'pancake-complete-v2' });
|
|
51
|
+
export const CORPUS_LAYOUT_V2 = 'records-v2';
|
|
52
|
+
|
|
53
|
+
// Read budgets. Open-path reads (manifest, segment table, query-interp,
|
|
54
|
+
// corpus tables, evaluation) default to DEFAULT_OPEN_READ_BYTES per read and
|
|
55
|
+
// are configurable per open via options.maxReadBytes; Infinity defers to
|
|
56
|
+
// the absolute backstop. Records hydrate under options.maxRecordBytes.
|
|
57
|
+
const DEFAULT_OPEN_READ_BYTES = 256 * 1024 * 1024;
|
|
58
|
+
const MAX_ARTIFACT_READ_BYTES = 2 * 1024 * 1024 * 1024;
|
|
59
|
+
const DEFAULT_RECORD_BYTES = 16 * 1024 * 1024;
|
|
60
|
+
const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;
|
|
61
|
+
const MAX_SEGMENTS = 64;
|
|
62
|
+
const MAX_RECORDS = 2 ** 31 - 1;
|
|
63
|
+
const MAX_PAGE_RECORDS = 65536;
|
|
64
|
+
const DIGEST_PAGE_CACHE = 64;
|
|
65
|
+
const RECORD_CACHE = 256;
|
|
66
|
+
// The embedded index is a .pancake-sketch artifact (SKETCH_PROFILE.md);
|
|
67
|
+
// its fixed-size header is what format 2 commits to in the manifest.
|
|
68
|
+
const SKETCH_HEADER_BYTES = 256;
|
|
69
|
+
// Hybrid retrieval: BM25 candidates fetched from the lexical segment per
|
|
70
|
+
// query, and the reciprocal-rank-fusion constant (the standard untuned 60).
|
|
71
|
+
const LEXICAL_CANDIDATES = 24;
|
|
72
|
+
const RRF_K = 60;
|
|
73
|
+
// Query-interpretation segments above this size open lazily (kind 3's
|
|
74
|
+
// inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
|
|
75
|
+
const LAZY_QI_BYTES = 4 * 1024 * 1024;
|
|
76
|
+
// Lexical segments above this size open lazily too (a wiki-scale inverted
|
|
77
|
+
// index runs to tens of MiB): only the header and doclen array become
|
|
78
|
+
// resident, term lookups interpolate into the remote table. Lazy reads are
|
|
79
|
+
// committed via the manifest digest but not individually verified — the
|
|
80
|
+
// transitional stance format-1 sketch rows carry.
|
|
81
|
+
const LAZY_LEXICAL_BYTES = 8 * 1024 * 1024;
|
|
82
|
+
// The resident sketch scan is O(count * sketchDims) per query in pure JS —
|
|
83
|
+
// ~500 ms at wiki scale (456k rows x 192 dims). At or above this many
|
|
84
|
+
// cells the reader loads the engine's SIMD scan kernel in the background
|
|
85
|
+
// (Node only; browser bundles inject one via options.sketchScanner) and
|
|
86
|
+
// serves queries from it once staged. Below it, docs-scale corpora scan in
|
|
87
|
+
// single-digit milliseconds and the engine load costs more than it saves.
|
|
88
|
+
const SCANNER_AUTO_CELLS = 16 * 1024 * 1024;
|
|
89
|
+
// The scanner's candidate output buffers are sized once at creation; the
|
|
90
|
+
// sketch reader falls back to the JS scan for any query whose C exceeds
|
|
91
|
+
// this, so an oversized rerank degrades speed, never recall.
|
|
92
|
+
const SCANNER_MAX_RERANK = 4096;
|
|
93
|
+
|
|
94
|
+
function bytesEqual(a, b) {
|
|
95
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
96
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const METRIC_NAMES = { 0: 'l2', 1: 'cosine' };
|
|
100
|
+
|
|
101
|
+
const decoder = new TextDecoder();
|
|
102
|
+
const MAX_SAFE_BIG = BigInt(Number.MAX_SAFE_INTEGER);
|
|
103
|
+
|
|
104
|
+
function toHex(bytes) {
|
|
105
|
+
let out = '';
|
|
106
|
+
for (const b of bytes) out += b.toString(16).padStart(2, '0');
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isSha256Hex = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
111
|
+
|
|
112
|
+
// SHA-256 via WebCrypto (browser, workerd, Node 18+); node:crypto fallback
|
|
113
|
+
// for older Node. Fails closed: verification is not optional in this reader.
|
|
114
|
+
async function sha256hex(bytes) {
|
|
115
|
+
if (globalThis.crypto?.subtle) {
|
|
116
|
+
return toHex(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)));
|
|
117
|
+
}
|
|
118
|
+
const { createHash } = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node:crypto');
|
|
119
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function asUint8Array(bytes) {
|
|
123
|
+
if (bytes instanceof Uint8Array) return bytes;
|
|
124
|
+
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
|
|
125
|
+
if (ArrayBuffer.isView(bytes)) return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
126
|
+
throw new Error('.pancake range source returned a non-binary value');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveBudget(value, label, fallback) {
|
|
130
|
+
if (value === undefined) return fallback;
|
|
131
|
+
if (value === Infinity) return MAX_ARTIFACT_READ_BYTES;
|
|
132
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
133
|
+
throw new Error(`${label} must be a positive number or Infinity`);
|
|
134
|
+
}
|
|
135
|
+
return Math.min(value, MAX_ARTIFACT_READ_BYTES);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// u64 fields become JS numbers only when they are exactly representable.
|
|
139
|
+
function u64(view, offset, label) {
|
|
140
|
+
const big = view.getBigUint64(offset, true);
|
|
141
|
+
if (big > MAX_SAFE_BIG) throw new Error(`.pancake ${label} exceeds the safe integer range`);
|
|
142
|
+
return Number(big);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validate an artifact-derived (offset, length) before touching the source:
|
|
146
|
+
// safe non-negative integers, no overflow, within the read budget, within
|
|
147
|
+
// the header's fileBytes, and within the source's size when it reports one.
|
|
148
|
+
function checkRange(source, offset, length, label, limit, fileBytes) {
|
|
149
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length)
|
|
150
|
+
|| offset < 0 || length < 0 || !Number.isSafeInteger(offset + length)) {
|
|
151
|
+
throw new Error(`.pancake ${label} range is out of bounds`);
|
|
152
|
+
}
|
|
153
|
+
if (length > limit) {
|
|
154
|
+
throw new Error(`.pancake ${label} read of ${length} bytes exceeds the ${limit}-byte read budget`);
|
|
155
|
+
}
|
|
156
|
+
if (fileBytes !== undefined && offset + length > fileBytes) {
|
|
157
|
+
throw new Error(`.pancake ${label} range extends past the file (${offset}+${length} > ${fileBytes})`);
|
|
158
|
+
}
|
|
159
|
+
if (Number.isSafeInteger(source.size) && offset + length > source.size) {
|
|
160
|
+
throw new Error(`.pancake ${label} range extends past the source (${offset}+${length} > ${source.size})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readChecked(source, offset, length, label, limit, fileBytes) {
|
|
165
|
+
checkRange(source, offset, length, label, limit, fileBytes);
|
|
166
|
+
const bytes = asUint8Array(await source.read(offset, length));
|
|
167
|
+
if (bytes.length !== length) {
|
|
168
|
+
throw new Error(`.pancake ${label} read returned ${bytes.length} of ${length} bytes (truncated or misbehaving source)`);
|
|
169
|
+
}
|
|
170
|
+
return bytes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fileSource(filePath) {
|
|
174
|
+
const fs = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node:fs');
|
|
175
|
+
let fd = fs.openSync(filePath, 'r');
|
|
176
|
+
let size;
|
|
177
|
+
try {
|
|
178
|
+
size = fs.fstatSync(fd).size;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
fs.closeSync(fd);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
size,
|
|
185
|
+
preferredParallelism: Infinity,
|
|
186
|
+
preferredGapBytes: 2048,
|
|
187
|
+
async read(offset, length) {
|
|
188
|
+
if (fd === null) throw new Error('.pancake file source is closed');
|
|
189
|
+
const buffer = new Uint8Array(length);
|
|
190
|
+
let bytesRead = 0;
|
|
191
|
+
while (bytesRead < length) {
|
|
192
|
+
const chunk = fs.readSync(fd, buffer, bytesRead, length - bytesRead, offset + bytesRead);
|
|
193
|
+
if (chunk === 0) break;
|
|
194
|
+
bytesRead += chunk;
|
|
195
|
+
}
|
|
196
|
+
return buffer.subarray(0, bytesRead);
|
|
197
|
+
},
|
|
198
|
+
// Idempotent: a second close() is a no-op, not EBADF.
|
|
199
|
+
async close() {
|
|
200
|
+
if (fd !== null) {
|
|
201
|
+
const handle = fd;
|
|
202
|
+
fd = null;
|
|
203
|
+
fs.closeSync(handle);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A source restricted to one segment's window, for handing a container
|
|
210
|
+
// region to a reader that expects offset 0 = its own start (the embedded
|
|
211
|
+
// sketch artifact). Reads outside the window are refused here, before the
|
|
212
|
+
// underlying source sees them.
|
|
213
|
+
function windowSource(source, offset, length) {
|
|
214
|
+
return {
|
|
215
|
+
size: length,
|
|
216
|
+
preferredParallelism: source.preferredParallelism,
|
|
217
|
+
preferredGapBytes: source.preferredGapBytes,
|
|
218
|
+
async read(off, len) {
|
|
219
|
+
if (!Number.isSafeInteger(off) || !Number.isSafeInteger(len) || off < 0 || len < 0 || off + len > length) {
|
|
220
|
+
throw new Error('.pancake index segment read is outside the segment window');
|
|
221
|
+
}
|
|
222
|
+
return source.read(offset + off, len);
|
|
223
|
+
},
|
|
224
|
+
async close() {},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function align16(n) {
|
|
229
|
+
return Math.ceil(n / 16) * 16;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const viewOf = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
233
|
+
|
|
234
|
+
function toFloat32(vector, dim, what) {
|
|
235
|
+
const v = vector instanceof Float32Array ? vector
|
|
236
|
+
: (Array.isArray(vector) || ArrayBuffer.isView(vector)) ? Float32Array.from(vector) : null;
|
|
237
|
+
if (!v || v.length !== dim) {
|
|
238
|
+
throw new Error(`${what} must return a ${dim}-dimensional vector`);
|
|
239
|
+
}
|
|
240
|
+
for (let d = 0; d < dim; d++) {
|
|
241
|
+
if (!Number.isFinite(v[d])) throw new Error(`${what} returned a non-finite component`);
|
|
242
|
+
}
|
|
243
|
+
return v;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function base64Bytes(text) {
|
|
247
|
+
if (typeof text !== 'string') return new Uint8Array(0);
|
|
248
|
+
return typeof Buffer !== 'undefined'
|
|
249
|
+
? Buffer.from(text, 'base64')
|
|
250
|
+
: Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Contract 4.4 mode 2: a host-supplied encoder for a pinned external model
|
|
254
|
+
// is checked against the declaration's verification vectors before the
|
|
255
|
+
// artifact serves a single query. Dimension and every component within the
|
|
256
|
+
// declared tolerance; any disagreement refuses the open.
|
|
257
|
+
export async function verifyHostEncoder(declaration, encodeQuery, dim) {
|
|
258
|
+
const vectors = declaration.testVectors;
|
|
259
|
+
if (!Array.isArray(vectors) || vectors.length === 0) {
|
|
260
|
+
throw new Error('.pancake kind-2 declaration carries no verification vectors');
|
|
261
|
+
}
|
|
262
|
+
if (declaration.dim !== undefined && declaration.dim !== dim) {
|
|
263
|
+
throw new Error(`.pancake kind-2 declaration dim ${declaration.dim} disagrees with manifest dim ${dim}`);
|
|
264
|
+
}
|
|
265
|
+
for (const tv of vectors) {
|
|
266
|
+
if (!tv || typeof tv.text !== 'string') {
|
|
267
|
+
throw new Error('.pancake kind-2 verification vector is malformed');
|
|
268
|
+
}
|
|
269
|
+
// The expected embedding must be dim finite numbers: a malformed
|
|
270
|
+
// entry would turn every comparison into NaN, and NaN > maxDiff is
|
|
271
|
+
// false — the vector would "pass" without comparing anything.
|
|
272
|
+
const label = `.pancake kind-2 verification vector "${tv.text.slice(0, 40)}…"`;
|
|
273
|
+
validateExpectedEmbedding(tv.embedding, dim, label);
|
|
274
|
+
const tolerance = validateTestVectorTolerance(tv.tolerance, label);
|
|
275
|
+
const vector = toFloat32(await encodeQuery(tv.text), dim, 'options.encodeQuery');
|
|
276
|
+
let maxDiff = 0;
|
|
277
|
+
for (let d = 0; d < dim; d++) {
|
|
278
|
+
const diff = Math.abs(vector[d] - tv.embedding[d]);
|
|
279
|
+
if (diff > maxDiff) maxDiff = diff;
|
|
280
|
+
}
|
|
281
|
+
if (!(maxDiff <= tolerance)) {
|
|
282
|
+
throw new Error(`host encoder failed the artifact's verification vector "${tv.text.slice(0, 40)}…": `
|
|
283
|
+
+ `max component diff ${maxDiff.toExponential(2)} exceeds tolerance ${tolerance} `
|
|
284
|
+
+ `(declared model ${declaration.model})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { checked: vectors.length };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Open a .pancake complete artifact from a file path (Node) or a
|
|
292
|
+
* { read(offset, length), size? } range source (any runtime).
|
|
293
|
+
* Returns { query(text, {k}), info(), evaluation(), close() }.
|
|
294
|
+
*/
|
|
295
|
+
export async function openPancakeFile(input, options = {}) {
|
|
296
|
+
const source = typeof input === 'string' ? await fileSource(input) : input;
|
|
297
|
+
const owned = typeof input === 'string';
|
|
298
|
+
if (!source || typeof source.read !== 'function') {
|
|
299
|
+
throw new Error('openPancakeFile() requires a file path or a range source with read(offset, length)');
|
|
300
|
+
}
|
|
301
|
+
const maxReadBytes = resolveBudget(options.maxReadBytes, 'maxReadBytes', DEFAULT_OPEN_READ_BYTES);
|
|
302
|
+
const maxRecordBytes = resolveBudget(options.maxRecordBytes, 'maxRecordBytes', DEFAULT_RECORD_BYTES);
|
|
303
|
+
const verifyRecords = options.verifyRecords !== false;
|
|
304
|
+
// Opt-in full authentication of the lazy rerank rows at open: one
|
|
305
|
+
// streamed pass over the vectors segment against the (identity-anchored)
|
|
306
|
+
// vectorsSha256. Without it, rows that feed reranking are covered by the
|
|
307
|
+
// whole-segment commitment but not verified per read — the documented
|
|
308
|
+
// transitional stance (spec section 6) — and verifyVectors() can run the
|
|
309
|
+
// same pass at any later point.
|
|
310
|
+
const verifyIndexVectors = options.verifyIndexVectors === true;
|
|
311
|
+
const verifyEncoder = options.verifyEncoder !== false;
|
|
312
|
+
// Declared outside the try so a failure after the encoder is created
|
|
313
|
+
// (count mismatch, bad corpus tables, ...) still releases its buffers.
|
|
314
|
+
let disposeEncoder = () => {};
|
|
315
|
+
// Kind-3 lazy encoder: the pending fetch+init promise. close() settles
|
|
316
|
+
// it before disposing so an in-flight prefetch cannot leak its kernel.
|
|
317
|
+
let encoderPending = null;
|
|
318
|
+
const settleEncoder = async () => {
|
|
319
|
+
if (encoderPending) { try { await encoderPending; } catch { /* surfaced to queries */ } }
|
|
320
|
+
};
|
|
321
|
+
// WASM sketch scanner: staged in the background like the encoder, used
|
|
322
|
+
// by queries only once ready (never awaited — the JS scan serves until
|
|
323
|
+
// then, and permanently if staging fails). Reader-created scanners are
|
|
324
|
+
// disposed on close; a caller-supplied scanner object stays caller-owned.
|
|
325
|
+
let scanner = null;
|
|
326
|
+
let scannerPending = null;
|
|
327
|
+
let disposeScanner = () => {};
|
|
328
|
+
const settleScanner = async () => {
|
|
329
|
+
if (scannerPending) { try { await scannerPending; } catch { /* JS scan remains */ } }
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
// Header: the only read whose bounds come from nowhere but the spec.
|
|
333
|
+
const header = await readChecked(source, 0, HEADER_BYTES, 'header', maxReadBytes);
|
|
334
|
+
const headerView = viewOf(header);
|
|
335
|
+
if (headerView.getUint32(0, true) !== MAGIC) {
|
|
336
|
+
throw new Error('not a .pancake file (bad magic)');
|
|
337
|
+
}
|
|
338
|
+
const formatVersion = headerView.getUint32(4, true);
|
|
339
|
+
const profile = SUPPORTED_PROFILES[formatVersion];
|
|
340
|
+
if (!profile) throw new Error(`unsupported .pancake format version ${formatVersion}`);
|
|
341
|
+
const manifestBytes = headerView.getUint32(8, true);
|
|
342
|
+
const segmentCount = headerView.getUint32(12, true);
|
|
343
|
+
const fileBytes = u64(headerView, 16, 'header fileBytes');
|
|
344
|
+
if (manifestBytes < 2 || manifestBytes > MAX_MANIFEST_BYTES || segmentCount < 1 || segmentCount > MAX_SEGMENTS) {
|
|
345
|
+
throw new Error('.pancake header is implausible');
|
|
346
|
+
}
|
|
347
|
+
if (Number.isSafeInteger(source.size) && source.size !== fileBytes) {
|
|
348
|
+
throw new Error(`.pancake is truncated or padded: header says ${fileBytes} bytes, source has ${source.size}`);
|
|
349
|
+
}
|
|
350
|
+
const tableBytes = segmentCount * TABLE_ENTRY_BYTES;
|
|
351
|
+
if (HEADER_BYTES + manifestBytes + tableBytes > fileBytes) {
|
|
352
|
+
throw new Error('.pancake header places the manifest or segment table past the file');
|
|
353
|
+
}
|
|
354
|
+
const identity = toHex(header.subarray(24, 56));
|
|
355
|
+
// A caller pinning an expected identity (a shelf entry, a lockfile,
|
|
356
|
+
// `--pack url#sha256`) is refused at the earliest possible moment —
|
|
357
|
+
// one 64-byte header read in. Wrong knowledge object: don't touch
|
|
358
|
+
// the knowledge object. The manifest hash check below still binds
|
|
359
|
+
// the header's claim to the actual manifest bytes.
|
|
360
|
+
if (options.expectedIdentity !== undefined) {
|
|
361
|
+
if (typeof options.expectedIdentity !== 'string' || !/^[0-9a-f]{64}$/i.test(options.expectedIdentity)) {
|
|
362
|
+
throw new Error('options.expectedIdentity must be a sha256 hex string');
|
|
363
|
+
}
|
|
364
|
+
if (identity !== options.expectedIdentity.toLowerCase()) {
|
|
365
|
+
throw new Error(`.pancake identity mismatch: expected ${options.expectedIdentity.toLowerCase()}, found ${identity}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const manifestBuf = await readChecked(source, HEADER_BYTES, manifestBytes, 'manifest', maxReadBytes, fileBytes);
|
|
370
|
+
if (await sha256hex(manifestBuf) !== identity) {
|
|
371
|
+
throw new Error('.pancake manifest failed identity verification');
|
|
372
|
+
}
|
|
373
|
+
let manifest;
|
|
374
|
+
try { manifest = JSON.parse(decoder.decode(manifestBuf)); } catch (err) {
|
|
375
|
+
throw new Error('.pancake manifest is not valid JSON', { cause: err });
|
|
376
|
+
}
|
|
377
|
+
if (!manifest || typeof manifest !== 'object' || manifest.profile !== profile) {
|
|
378
|
+
throw new Error(`unsupported profile ${manifest?.profile} for format version ${formatVersion}`);
|
|
379
|
+
}
|
|
380
|
+
if (!Array.isArray(manifest.segments) || manifest.segments.length !== segmentCount) {
|
|
381
|
+
throw new Error('.pancake manifest segment list disagrees with the header segment count');
|
|
382
|
+
}
|
|
383
|
+
const dim = manifest.dim;
|
|
384
|
+
if (!Number.isSafeInteger(dim) || dim < 1 || dim > 65536) {
|
|
385
|
+
throw new Error('.pancake manifest dim is implausible');
|
|
386
|
+
}
|
|
387
|
+
if (!manifest.corpus || typeof manifest.corpus !== 'object') {
|
|
388
|
+
throw new Error('.pancake manifest has no corpus block');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const table = await readChecked(source, HEADER_BYTES + manifestBytes, tableBytes, 'segment table', maxReadBytes, fileBytes);
|
|
392
|
+
const tableView = viewOf(table);
|
|
393
|
+
const segments = new Map();
|
|
394
|
+
let expectedOffset = align16(HEADER_BYTES + manifestBytes + tableBytes);
|
|
395
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
396
|
+
const entry = i * TABLE_ENTRY_BYTES;
|
|
397
|
+
const kindNumber = tableView.getUint32(entry, true);
|
|
398
|
+
const kind = KIND_NAMES[kindNumber];
|
|
399
|
+
const offset = u64(tableView, entry + 8, `segment ${i} offset`);
|
|
400
|
+
const length = u64(tableView, entry + 16, `segment ${i} length`);
|
|
401
|
+
const declared = manifest.segments[i];
|
|
402
|
+
if (!declared || typeof declared !== 'object' || declared.bytes !== length || !isSha256Hex(declared.sha256)
|
|
403
|
+
|| offset !== expectedOffset || !Number.isSafeInteger(offset + length) || offset + length > fileBytes) {
|
|
404
|
+
throw new Error(`.pancake segment table disagrees with manifest at entry ${i}`);
|
|
405
|
+
}
|
|
406
|
+
if (kind === undefined) {
|
|
407
|
+
// Unknown kinds are skipped, not failed (spec 3.3); they are
|
|
408
|
+
// still committed through the manifest, so the declared name
|
|
409
|
+
// must at least not claim to be a kind this reader knows.
|
|
410
|
+
if (typeof declared.kind !== 'string' || declared.kind in KINDS) {
|
|
411
|
+
throw new Error(`.pancake segment table entry ${i} has unknown kind ${kindNumber} but the manifest names it ${declared.kind}`);
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
if (declared.kind !== kind) {
|
|
415
|
+
throw new Error(`.pancake segment table disagrees with manifest at entry ${i}`);
|
|
416
|
+
}
|
|
417
|
+
if (segments.has(kind)) throw new Error(`.pancake carries more than one ${kind} segment`);
|
|
418
|
+
segments.set(kind, { offset, length, sha256: declared.sha256 });
|
|
419
|
+
}
|
|
420
|
+
expectedOffset = align16(offset + length);
|
|
421
|
+
}
|
|
422
|
+
for (const required of ['index', 'corpus', 'query-interp']) {
|
|
423
|
+
if (!segments.has(required)) throw new Error(`.pancake is missing the ${required} segment`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Corpus tables: count + offsets (+ page table for layout v2),
|
|
427
|
+
// one contiguous region at the segment start, sized from the
|
|
428
|
+
// identity-verified manifest and cross-checked against the segment's
|
|
429
|
+
// own words after the read.
|
|
430
|
+
const qi = segments.get('query-interp');
|
|
431
|
+
const idx = segments.get('index');
|
|
432
|
+
const corpus = segments.get('corpus');
|
|
433
|
+
const declaredRecords = manifest.corpus.records;
|
|
434
|
+
if (!Number.isSafeInteger(declaredRecords) || declaredRecords < 0 || declaredRecords > MAX_RECORDS) {
|
|
435
|
+
throw new Error('.pancake manifest corpus.records is implausible');
|
|
436
|
+
}
|
|
437
|
+
const layout = manifest.corpus.layout;
|
|
438
|
+
const perRecord = formatVersion >= 2;
|
|
439
|
+
if (perRecord && layout !== CORPUS_LAYOUT_V2) {
|
|
440
|
+
throw new Error(`.pancake format ${formatVersion} requires corpus layout ${CORPUS_LAYOUT_V2}, manifest says ${layout}`);
|
|
441
|
+
}
|
|
442
|
+
if (!perRecord && layout !== undefined) {
|
|
443
|
+
throw new Error(`.pancake format 1 carries corpus layout v1, manifest says ${layout}`);
|
|
444
|
+
}
|
|
445
|
+
let pageRecords = 0;
|
|
446
|
+
let pages = 0;
|
|
447
|
+
let offsetsAt;
|
|
448
|
+
let tablesBytes;
|
|
449
|
+
let recordDigestsAt = 0;
|
|
450
|
+
let recordsAt;
|
|
451
|
+
if (perRecord) {
|
|
452
|
+
pageRecords = manifest.corpus.pageRecords;
|
|
453
|
+
if (!Number.isSafeInteger(pageRecords) || pageRecords < 1 || pageRecords > MAX_PAGE_RECORDS) {
|
|
454
|
+
throw new Error('.pancake manifest corpus.pageRecords is implausible');
|
|
455
|
+
}
|
|
456
|
+
pages = Math.ceil(declaredRecords / pageRecords);
|
|
457
|
+
if (manifest.corpus.pages !== pages || !isSha256Hex(manifest.corpus.pageTableSha256)
|
|
458
|
+
|| manifest.corpus.recordDigest !== 'sha256') {
|
|
459
|
+
throw new Error('.pancake manifest corpus integrity block is inconsistent');
|
|
460
|
+
}
|
|
461
|
+
offsetsAt = 8;
|
|
462
|
+
tablesBytes = offsetsAt + 8 * (declaredRecords + 1) + 32 * pages;
|
|
463
|
+
recordDigestsAt = tablesBytes;
|
|
464
|
+
recordsAt = recordDigestsAt + 32 * declaredRecords;
|
|
465
|
+
} else {
|
|
466
|
+
offsetsAt = 4;
|
|
467
|
+
tablesBytes = offsetsAt + 8 * (declaredRecords + 1);
|
|
468
|
+
recordsAt = tablesBytes;
|
|
469
|
+
}
|
|
470
|
+
if (!Number.isSafeInteger(recordsAt) || recordsAt > corpus.length) {
|
|
471
|
+
throw new Error('.pancake manifest corpus.records is implausible for the corpus segment');
|
|
472
|
+
}
|
|
473
|
+
// Format 2 commits to the embedded sketch's 256-byte header
|
|
474
|
+
// (metric/dim/count and the sketch's residentSha256/vectorsSha256),
|
|
475
|
+
// anchoring the sketch's own integrity chain to the identity; the
|
|
476
|
+
// sketch's self-checks alone verify against fields an attacker who
|
|
477
|
+
// rewrites the segment also controls.
|
|
478
|
+
if (perRecord && (!isSha256Hex(manifest.index?.headerSha256) || idx.length < SKETCH_HEADER_BYTES)) {
|
|
479
|
+
throw new Error('.pancake manifest carries no index header commitment (index.headerSha256)');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// After the segment table, every remaining open-path extent is known,
|
|
483
|
+
// so the query-interp segment, the embedded sketch (two dependent
|
|
484
|
+
// reads of its own), and the corpus tables fetch as one concurrent
|
|
485
|
+
// wave instead of dependent rounds — at 100 ms/request that is most
|
|
486
|
+
// of the open time.
|
|
487
|
+
// A large query-interpretation segment (kind 3's inline encoder is
|
|
488
|
+
// ~25 MiB) is not read at open: only its 16-byte header joins the
|
|
489
|
+
// wave, the calibration region follows as one small dependent read,
|
|
490
|
+
// and the encoder bytes arrive lazily — prefetched in the background
|
|
491
|
+
// by default, awaited by the first query. The whole-segment digest
|
|
492
|
+
// is verified when those bytes arrive, and the header/calibration
|
|
493
|
+
// slices used at open are byte-compared against the verified
|
|
494
|
+
// segment before any query can complete, so nothing unverified ever
|
|
495
|
+
// influences a returned result. Small segments (kinds 1/2) keep the
|
|
496
|
+
// one-read eager path unchanged.
|
|
497
|
+
const qiDeferrable = qi.length > LAZY_QI_BYTES;
|
|
498
|
+
const [qiFirst, sketch, tables, lexicalBytes] = await Promise.all([
|
|
499
|
+
(async () => {
|
|
500
|
+
if (qiDeferrable) {
|
|
501
|
+
return readChecked(source, qi.offset, Math.min(16, qi.length), 'query-interp header', maxReadBytes, fileBytes);
|
|
502
|
+
}
|
|
503
|
+
const bytes = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
504
|
+
if (await sha256hex(bytes) !== qi.sha256) {
|
|
505
|
+
throw new Error('.pancake query-interp segment failed hash verification');
|
|
506
|
+
}
|
|
507
|
+
return bytes;
|
|
508
|
+
})(),
|
|
509
|
+
PancakeSketchArtifact.open(windowSource(source, idx.offset, idx.length), { maxReadBytes }),
|
|
510
|
+
readChecked(source, corpus.offset, tablesBytes, 'corpus tables', maxReadBytes, fileBytes),
|
|
511
|
+
// Lexical index (kind 5, OPTIONAL). Small segments: eager
|
|
512
|
+
// whole-segment read, digest-verified like the query-interp
|
|
513
|
+
// segment. Wiki-scale segments open lazily — header + doclens
|
|
514
|
+
// resident, term table and postings remote (see
|
|
515
|
+
// LAZY_LEXICAL_BYTES for the integrity stance). Readers that
|
|
516
|
+
// predate the kind skip it (spec 3.3).
|
|
517
|
+
segments.has('lexical') ? (async () => {
|
|
518
|
+
const seg = segments.get('lexical');
|
|
519
|
+
if (seg.length > LAZY_LEXICAL_BYTES) {
|
|
520
|
+
const lexRead = (off, len) => readChecked(source, seg.offset + off, len, 'lexical segment', maxReadBytes, fileBytes);
|
|
521
|
+
return { lazyIndex: await openLexicalIndexLazy(lexRead, seg.length) };
|
|
522
|
+
}
|
|
523
|
+
const bytes = await readChecked(source, seg.offset, seg.length, 'lexical segment', maxReadBytes, fileBytes);
|
|
524
|
+
if (await sha256hex(bytes) !== seg.sha256) {
|
|
525
|
+
throw new Error('.pancake lexical segment failed hash verification');
|
|
526
|
+
}
|
|
527
|
+
return { bytes };
|
|
528
|
+
})() : null,
|
|
529
|
+
perRecord ? (async () => {
|
|
530
|
+
const headerBytes = await readChecked(source, idx.offset, SKETCH_HEADER_BYTES, 'index header', maxReadBytes, fileBytes);
|
|
531
|
+
if (await sha256hex(headerBytes) !== manifest.index.headerSha256) {
|
|
532
|
+
throw new Error('.pancake index header failed hash verification against the manifest');
|
|
533
|
+
}
|
|
534
|
+
})() : null,
|
|
535
|
+
]);
|
|
536
|
+
|
|
537
|
+
const lexicalIndex = lexicalBytes
|
|
538
|
+
? (lexicalBytes.lazyIndex || openLexicalIndex(lexicalBytes.bytes))
|
|
539
|
+
: null;
|
|
540
|
+
if (lexicalIndex && lexicalIndex.docCount !== declaredRecords) {
|
|
541
|
+
throw new Error(`.pancake lexical segment covers ${lexicalIndex.docCount} records, corpus declares ${declaredRecords}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Query interpretation: version word, kind, two length-prefixed
|
|
545
|
+
// regions that must tile the segment exactly.
|
|
546
|
+
if (qi.length < 16) throw new Error('.pancake query-interp segment is too short');
|
|
547
|
+
const qiView = viewOf(qiFirst);
|
|
548
|
+
const qiVersion = qiView.getUint32(0, true);
|
|
549
|
+
if (qiVersion !== 1) throw new Error(`unsupported query-interpretation version ${qiVersion}`);
|
|
550
|
+
const qiKind = qiView.getUint32(4, true);
|
|
551
|
+
const encoderLen = qiView.getUint32(8, true);
|
|
552
|
+
const calibrationLen = qiView.getUint32(12, true);
|
|
553
|
+
if (16 + encoderLen + calibrationLen !== qi.length) {
|
|
554
|
+
throw new Error('.pancake query-interp segment layout is inconsistent');
|
|
555
|
+
}
|
|
556
|
+
// Laziness only pays for kind 3 (the deferrable bulk IS the
|
|
557
|
+
// encoder); a large segment of any other kind falls back to the
|
|
558
|
+
// eager whole-segment read and verification, one dependent round.
|
|
559
|
+
let qiBytes = qiFirst;
|
|
560
|
+
let qiDeferred = qiDeferrable;
|
|
561
|
+
if (qiDeferrable && qiKind !== 3) {
|
|
562
|
+
qiBytes = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
563
|
+
if (await sha256hex(qiBytes) !== qi.sha256) {
|
|
564
|
+
throw new Error('.pancake query-interp segment failed hash verification');
|
|
565
|
+
}
|
|
566
|
+
qiDeferred = false;
|
|
567
|
+
}
|
|
568
|
+
// Deferred path: the calibration region arrives now (small), the
|
|
569
|
+
// encoder region stays remote. Both header and calibration are
|
|
570
|
+
// unverified until the full segment fetch cross-checks them.
|
|
571
|
+
const calibrationRegion = qiDeferred
|
|
572
|
+
? await readChecked(source, qi.offset + 16 + encoderLen, calibrationLen, 'calibration region', maxReadBytes, fileBytes)
|
|
573
|
+
: qiBytes.subarray(16 + encoderLen);
|
|
574
|
+
const encoderBytes = qiDeferred ? null : qiBytes.subarray(16, 16 + encoderLen);
|
|
575
|
+
let calibrationJson;
|
|
576
|
+
try { calibrationJson = JSON.parse(decoder.decode(calibrationRegion)); } catch (err) {
|
|
577
|
+
throw new Error('.pancake calibration is not valid JSON', { cause: err });
|
|
578
|
+
}
|
|
579
|
+
if (!calibrationJson || typeof calibrationJson !== 'object') {
|
|
580
|
+
throw new Error('.pancake calibration must be a JSON object');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// kind 1 (student-inline-v1): pure-JS inline encoder + feature-stream
|
|
584
|
+
// abstention. kind 2 (external-transformers-v1): the encoder is a
|
|
585
|
+
// pinned declaration the HOST must satisfy via options.encodeQuery,
|
|
586
|
+
// verified against the declaration's test vectors before serving;
|
|
587
|
+
// calibration scores retrieval signals + query text. kind 3
|
|
588
|
+
// (inline-transformer-v1): the pinned teacher as data, executed by
|
|
589
|
+
// reader-owned kernels, verified against its own test vectors.
|
|
590
|
+
let embed;
|
|
591
|
+
let preScore = () => null;
|
|
592
|
+
let scoreQuality;
|
|
593
|
+
let encoderInfo;
|
|
594
|
+
let encoderVerified = null;
|
|
595
|
+
const retrievalScorer = () => {
|
|
596
|
+
const scorer = createAbstentionScorer(calibrationJson.asset, base64Bytes(calibrationJson.vocabBloomBase64));
|
|
597
|
+
const VERDICTS = { answer: 'strong', weak: 'weak', abstain: 'none' };
|
|
598
|
+
return async (hits, context) => {
|
|
599
|
+
if (!scorer) return { match_quality: 'unscored' };
|
|
600
|
+
// The coverage term grounds the verdict in the top passages'
|
|
601
|
+
// text, so those records hydrate before scoring. When the
|
|
602
|
+
// query is answered the pages are already hot for result
|
|
603
|
+
// hydration; an abstained query costs these extra reads.
|
|
604
|
+
// Hydration failures propagate — on format 2 a record that
|
|
605
|
+
// fails its digest must fail the query, not skew its verdict.
|
|
606
|
+
const topTexts = scorer.usesPassage && hits.length
|
|
607
|
+
? (await Promise.all(hits.slice(0, scorer.passagesNeeded || 1)
|
|
608
|
+
.map((hit) => hydrate(hit.id)))).map((record) => record?.text)
|
|
609
|
+
: [];
|
|
610
|
+
const scored = scorer.score(context.text, hits, topTexts);
|
|
611
|
+
return { match_quality: VERDICTS[scored.verdict] || scored.verdict, confidence: scored.p };
|
|
612
|
+
};
|
|
613
|
+
};
|
|
614
|
+
if (qiKind === 1) {
|
|
615
|
+
const student = loadStudentModel(encoderBytes);
|
|
616
|
+
encoderInfo = { kind: 'student-inline-v1' };
|
|
617
|
+
embed = (text) => {
|
|
618
|
+
const embedded = embedTextWithStudent(text, student);
|
|
619
|
+
return { vector: toFloat32(embedded.vector, dim, 'inline student encoder'), embedded };
|
|
620
|
+
};
|
|
621
|
+
// Pre-search abstention skips the index entirely when it fires,
|
|
622
|
+
// exactly as the Worker does.
|
|
623
|
+
preScore = (context) => computePreSearchAbstention(context.embedded, calibrationJson);
|
|
624
|
+
scoreQuality = (hits, context) => computeMatchQuality(hits, context.embedded, calibrationJson);
|
|
625
|
+
} else if (qiKind === 2) {
|
|
626
|
+
let declaration;
|
|
627
|
+
try { declaration = JSON.parse(decoder.decode(encoderBytes)); } catch (err) {
|
|
628
|
+
throw new Error('.pancake kind-2 encoder declaration is not valid JSON', { cause: err });
|
|
629
|
+
}
|
|
630
|
+
if (!declaration || typeof declaration !== 'object') throw new Error('.pancake kind-2 encoder declaration must be a JSON object');
|
|
631
|
+
encoderInfo = declaration;
|
|
632
|
+
const encodeQuery = options.encodeQuery;
|
|
633
|
+
if (encodeQuery !== undefined && typeof encodeQuery !== 'function') {
|
|
634
|
+
throw new Error('options.encodeQuery must be a function');
|
|
635
|
+
}
|
|
636
|
+
const hasVectors = Array.isArray(declaration.testVectors) && declaration.testVectors.length > 0;
|
|
637
|
+
if (encodeQuery && verifyEncoder) {
|
|
638
|
+
if (hasVectors) {
|
|
639
|
+
await verifyHostEncoder(declaration, encodeQuery, dim);
|
|
640
|
+
encoderVerified = true;
|
|
641
|
+
} else if (options.allowUnverifiedEncoder === true) {
|
|
642
|
+
encoderVerified = false;
|
|
643
|
+
} else {
|
|
644
|
+
throw new Error(`.pancake declares an external encoder (${declaration.model}) without verification vectors, `
|
|
645
|
+
+ 'so the host encoder cannot be checked against it (contract section 4.4); '
|
|
646
|
+
+ 'pass options.allowUnverifiedEncoder: true to serve it anyway, marked unverified');
|
|
647
|
+
}
|
|
648
|
+
} else if (encodeQuery) {
|
|
649
|
+
encoderVerified = false;
|
|
650
|
+
}
|
|
651
|
+
embed = async (text) => {
|
|
652
|
+
if (!encodeQuery) {
|
|
653
|
+
throw new Error(`.pancake declares an external encoder (${declaration.model}); `
|
|
654
|
+
+ 'pass options.encodeQuery to openPancakeFile');
|
|
655
|
+
}
|
|
656
|
+
return { vector: toFloat32(await encodeQuery(text), dim, 'options.encodeQuery'), text };
|
|
657
|
+
};
|
|
658
|
+
scoreQuality = retrievalScorer();
|
|
659
|
+
} else if (qiKind === 3) {
|
|
660
|
+
// Kernels are reader-owned (spec 3.6) and load dynamically so
|
|
661
|
+
// kind-1/2 files never touch them; the node-only glue is hidden
|
|
662
|
+
// from bundlers, the web glue stays analyzable so browser builds
|
|
663
|
+
// carry it. options.createEncoder overrides the loader.
|
|
664
|
+
let createEncoder = options.createEncoder;
|
|
665
|
+
if (!createEncoder) {
|
|
666
|
+
try {
|
|
667
|
+
createEncoder = globalThis.process?.versions?.node
|
|
668
|
+
? (await import(/* webpackIgnore: true */ /* @vite-ignore */ './encoder-kernels/encoder.node.mjs')).default
|
|
669
|
+
: (await import('./encoder-kernels/encoder.mjs')).default;
|
|
670
|
+
} catch (err) {
|
|
671
|
+
throw new Error('kind-3 artifact requires the inline-transformer kernels at '
|
|
672
|
+
+ 'complete/encoder-kernels/ (pikelet-wasm/complete); run '
|
|
673
|
+
+ 'examples/05-one-file-search/encoder-spike/build-encoder.sh to rebuild them',
|
|
674
|
+
{ cause: err });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const openHeader = qiDeferred ? qiFirst.slice(0, 16) : null;
|
|
678
|
+
const loadEmbedder = async () => {
|
|
679
|
+
let encBytes = encoderBytes;
|
|
680
|
+
if (qiDeferred) {
|
|
681
|
+
const full = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
682
|
+
if (await sha256hex(full) !== qi.sha256) {
|
|
683
|
+
throw new Error('.pancake query-interp segment failed hash verification');
|
|
684
|
+
}
|
|
685
|
+
// The header and calibration slices used at open were
|
|
686
|
+
// unverified; the digest-verified segment must contain
|
|
687
|
+
// them byte for byte, or the open state is poisoned and
|
|
688
|
+
// every query must fail.
|
|
689
|
+
if (!bytesEqual(full.subarray(0, 16), openHeader)
|
|
690
|
+
|| !bytesEqual(full.subarray(16 + encoderLen), calibrationRegion)) {
|
|
691
|
+
throw new Error('.pancake query-interp segment bytes changed between open and encoder fetch');
|
|
692
|
+
}
|
|
693
|
+
encBytes = full.subarray(16, 16 + encoderLen);
|
|
694
|
+
}
|
|
695
|
+
const { declaration, vocabText, blob } = parseInlineTransformerEncoder(encBytes);
|
|
696
|
+
if (declaration.dim !== undefined && declaration.dim !== dim) {
|
|
697
|
+
throw new Error(`.pancake kind-3 declaration dim ${declaration.dim} disagrees with manifest dim ${dim}`);
|
|
698
|
+
}
|
|
699
|
+
const embedder = await createInlineTransformerEmbedder({ declaration, vocabText, blob, createEncoder, verify: verifyEncoder });
|
|
700
|
+
encoderInfo = declaration;
|
|
701
|
+
encoderVerified = verifyEncoder && Array.isArray(declaration.testVectors) && declaration.testVectors.length > 0;
|
|
702
|
+
disposeEncoder = () => embedder.dispose();
|
|
703
|
+
return { embedder, declaration };
|
|
704
|
+
};
|
|
705
|
+
const ensureEmbedder = () => (encoderPending ??= loadEmbedder());
|
|
706
|
+
// Until the encoder arrives, info() serves the identity-verified
|
|
707
|
+
// manifest declaration; encoderVerified stays null (unknown, not
|
|
708
|
+
// unverified) and flips once test vectors have run.
|
|
709
|
+
encoderInfo = {
|
|
710
|
+
...(manifest.encoder && typeof manifest.encoder === 'object' ? manifest.encoder : {}),
|
|
711
|
+
kind: 'inline-transformer-v1',
|
|
712
|
+
...(qiDeferred ? { deferred: true } : {}),
|
|
713
|
+
};
|
|
714
|
+
if (!qiDeferred) {
|
|
715
|
+
await ensureEmbedder();
|
|
716
|
+
} else if (options.prefetchEncoder !== false) {
|
|
717
|
+
// Background prefetch: the ~25 MiB encoder downloads while
|
|
718
|
+
// the caller renders/collects input; failures are held and
|
|
719
|
+
// surface on the first query, which awaits this promise.
|
|
720
|
+
ensureEmbedder().catch(() => {});
|
|
721
|
+
}
|
|
722
|
+
embed = async (text) => {
|
|
723
|
+
const { embedder, declaration } = await ensureEmbedder();
|
|
724
|
+
const { vector } = await embedder.embed(`${declaration.prefixPolicy?.query || ''}${text}`);
|
|
725
|
+
return { vector: toFloat32(vector, dim, 'inline transformer encoder'), text };
|
|
726
|
+
};
|
|
727
|
+
scoreQuality = retrievalScorer();
|
|
728
|
+
} else {
|
|
729
|
+
throw new Error(`unsupported query-interpretation kind ${qiKind}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Index: the embedded sketch artifact, opened in the wave above
|
|
733
|
+
// against a segment-windowed source (resident hash verified there).
|
|
734
|
+
if (sketch.count !== declaredRecords) {
|
|
735
|
+
throw new Error(`.pancake index count ${sketch.count} != corpus records ${declaredRecords}`);
|
|
736
|
+
}
|
|
737
|
+
if (sketch.dim !== undefined && sketch.dim !== dim) {
|
|
738
|
+
throw new Error(`.pancake index dim ${sketch.dim} != manifest dim ${dim}`);
|
|
739
|
+
}
|
|
740
|
+
// The metric decides the search semantics; the sketch header's copy
|
|
741
|
+
// must agree with the identity-verified manifest (on format 2 the
|
|
742
|
+
// header itself is hash-anchored above; on format 1 this cross-check
|
|
743
|
+
// is the only binding).
|
|
744
|
+
if (METRIC_NAMES[sketch.metric] !== undefined && typeof manifest.metric === 'string'
|
|
745
|
+
&& METRIC_NAMES[sketch.metric] !== manifest.metric) {
|
|
746
|
+
throw new Error(`.pancake index metric ${METRIC_NAMES[sketch.metric]} != manifest metric ${manifest.metric}`);
|
|
747
|
+
}
|
|
748
|
+
if (verifyIndexVectors) {
|
|
749
|
+
await sketch.verifyVectors();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Resident-scan acceleration. options.sketchScanner:
|
|
753
|
+
// false — pure JS scan always (the pre-0.7 behavior);
|
|
754
|
+
// an object — a ready scanner (createSketchScanner's shape),
|
|
755
|
+
// caller-owned, used as-is;
|
|
756
|
+
// a function — async factory (sketchArtifact) => scanner, invoked
|
|
757
|
+
// in the background once the sketch is fully resident
|
|
758
|
+
// (the injection point for browser bundles, which
|
|
759
|
+
// cannot load the Node engine entrypoint);
|
|
760
|
+
// undefined — auto: in Node, when the corpus is large enough
|
|
761
|
+
// that the JS scan dominates query time, stage the
|
|
762
|
+
// engine's SIMD scan kernel in the background.
|
|
763
|
+
// Staging failures are silent by design: every query the scanner
|
|
764
|
+
// would have served is answered identically (and identically
|
|
765
|
+
// correctly) by the JS scan, just slower.
|
|
766
|
+
const scannerOption = options.sketchScanner;
|
|
767
|
+
if (scannerOption !== undefined && scannerOption !== false
|
|
768
|
+
&& typeof scannerOption !== 'object' && typeof scannerOption !== 'function') {
|
|
769
|
+
throw new Error('options.sketchScanner must be false, a scanner object, or a factory function');
|
|
770
|
+
}
|
|
771
|
+
if (scannerOption && typeof scannerOption === 'object') {
|
|
772
|
+
scanner = scannerOption;
|
|
773
|
+
} else if (typeof scannerOption === 'function') {
|
|
774
|
+
scannerPending = (async () => {
|
|
775
|
+
await sketch.fullyResident;
|
|
776
|
+
const created = await scannerOption(sketch);
|
|
777
|
+
if (!created || typeof created.scan !== 'function') {
|
|
778
|
+
throw new Error('sketchScanner factory must resolve to an object with scan()');
|
|
779
|
+
}
|
|
780
|
+
disposeScanner = () => { if (typeof created.dispose === 'function') created.dispose(); };
|
|
781
|
+
scanner = created;
|
|
782
|
+
})();
|
|
783
|
+
scannerPending.catch(() => {});
|
|
784
|
+
} else if (scannerOption === undefined
|
|
785
|
+
&& sketch.count * (sketch.sketchDims || 0) >= SCANNER_AUTO_CELLS
|
|
786
|
+
&& globalThis.process?.versions?.node) {
|
|
787
|
+
scannerPending = (async () => {
|
|
788
|
+
// Hidden from bundlers like the node encoder kernels: the
|
|
789
|
+
// web entrypoint's ?url wasm imports would break a browser
|
|
790
|
+
// build that statically pulled this in.
|
|
791
|
+
const { default: Pancake } = await import(/* webpackIgnore: true */ /* @vite-ignore */ '../pancake.node.mjs');
|
|
792
|
+
await sketch.fullyResident;
|
|
793
|
+
const created = await Pancake.createSketchScanner(sketch, {
|
|
794
|
+
maxRerank: Math.min(sketch.count, SCANNER_MAX_RERANK),
|
|
795
|
+
});
|
|
796
|
+
disposeScanner = () => created.dispose();
|
|
797
|
+
scanner = created;
|
|
798
|
+
})();
|
|
799
|
+
scannerPending.catch(() => {});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Corpus tables: cross-check the segment's own words, then the
|
|
803
|
+
// offsets — monotonic, starting exactly where the tables end, ending
|
|
804
|
+
// inside the segment — and for layout v2 the page table's digest.
|
|
805
|
+
const tablesView = viewOf(tables);
|
|
806
|
+
const recordCount = tablesView.getUint32(0, true);
|
|
807
|
+
if (recordCount !== declaredRecords) {
|
|
808
|
+
throw new Error('.pancake corpus count disagrees with manifest');
|
|
809
|
+
}
|
|
810
|
+
if (perRecord && tablesView.getUint32(4, true) !== pageRecords) {
|
|
811
|
+
throw new Error('.pancake corpus page size disagrees with manifest');
|
|
812
|
+
}
|
|
813
|
+
const recordOffsets = new Float64Array(recordCount + 1);
|
|
814
|
+
for (let i = 0; i <= recordCount; i++) {
|
|
815
|
+
const value = u64(tablesView, offsetsAt + 8 * i, `corpus offset ${i}`);
|
|
816
|
+
if (value > corpus.length || (i > 0 && value < recordOffsets[i - 1]) || (i === 0 && value !== recordsAt)) {
|
|
817
|
+
throw new Error('.pancake corpus offsets are inconsistent');
|
|
818
|
+
}
|
|
819
|
+
recordOffsets[i] = value;
|
|
820
|
+
}
|
|
821
|
+
let pageTable = null;
|
|
822
|
+
if (perRecord) {
|
|
823
|
+
pageTable = tables.slice(offsetsAt + 8 * (recordCount + 1), tablesBytes);
|
|
824
|
+
if (await sha256hex(pageTable) !== manifest.corpus.pageTableSha256) {
|
|
825
|
+
throw new Error('.pancake corpus page table failed hash verification');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const corpusIntegrity = perRecord ? (verifyRecords ? 'per-record-sha256' : 'per-record-sha256 (unverified by option)') : 'segment-sha256';
|
|
829
|
+
|
|
830
|
+
// Per-page record-digest cache: a page is fetched (one read of
|
|
831
|
+
// 32 * pageRecords bytes), verified against the page table, and kept
|
|
832
|
+
// for the records that share it. Pending fetches are shared so
|
|
833
|
+
// concurrent hydrations of one page cost one read.
|
|
834
|
+
const digestPages = new Map();
|
|
835
|
+
const pageDigests = (page) => {
|
|
836
|
+
if (digestPages.has(page)) {
|
|
837
|
+
const cached = digestPages.get(page);
|
|
838
|
+
digestPages.delete(page);
|
|
839
|
+
digestPages.set(page, cached);
|
|
840
|
+
return cached;
|
|
841
|
+
}
|
|
842
|
+
const pending = (async () => {
|
|
843
|
+
const from = Math.min(recordCount, page * pageRecords);
|
|
844
|
+
const to = Math.min(recordCount, (page + 1) * pageRecords);
|
|
845
|
+
const bytes = await readChecked(source, corpus.offset + recordDigestsAt + 32 * from, 32 * (to - from),
|
|
846
|
+
`corpus digest page ${page}`, maxReadBytes, fileBytes);
|
|
847
|
+
if (await sha256hex(bytes) !== toHex(pageTable.subarray(32 * page, 32 * page + 32))) {
|
|
848
|
+
digestPages.delete(page);
|
|
849
|
+
throw new Error(`.pancake corpus digest page ${page} failed hash verification`);
|
|
850
|
+
}
|
|
851
|
+
return bytes;
|
|
852
|
+
})();
|
|
853
|
+
digestPages.set(page, pending);
|
|
854
|
+
// A failed fetch (transport error, short read, hash mismatch) must
|
|
855
|
+
// not stay cached as a rejected promise that every later record in
|
|
856
|
+
// the page re-throws; evict it so the next hydration retries.
|
|
857
|
+
pending.catch(() => { if (digestPages.get(page) === pending) digestPages.delete(page); });
|
|
858
|
+
if (digestPages.size > DIGEST_PAGE_CACHE) digestPages.delete(digestPages.keys().next().value);
|
|
859
|
+
return pending;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const recordCache = new Map();
|
|
863
|
+
const cacheRecord = (id, record) => {
|
|
864
|
+
if (recordCache.has(id)) recordCache.delete(id);
|
|
865
|
+
recordCache.set(id, record);
|
|
866
|
+
if (recordCache.size > RECORD_CACHE) recordCache.delete(recordCache.keys().next().value);
|
|
867
|
+
return record;
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const hydrate = async (id) => {
|
|
871
|
+
if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
|
|
872
|
+
throw new Error(`.pancake result id ${id} is outside the corpus`);
|
|
873
|
+
}
|
|
874
|
+
if (recordCache.has(id)) {
|
|
875
|
+
const cached = recordCache.get(id);
|
|
876
|
+
recordCache.delete(id);
|
|
877
|
+
recordCache.set(id, cached);
|
|
878
|
+
return cached;
|
|
879
|
+
}
|
|
880
|
+
const start = recordOffsets[id];
|
|
881
|
+
const end = recordOffsets[id + 1];
|
|
882
|
+
const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
|
|
883
|
+
if (perRecord && verifyRecords) {
|
|
884
|
+
const page = Math.floor(id / pageRecords);
|
|
885
|
+
const digests = await pageDigests(page);
|
|
886
|
+
const at = 32 * (id - page * pageRecords);
|
|
887
|
+
if (await sha256hex(bytes) !== toHex(digests.subarray(at, at + 32))) {
|
|
888
|
+
throw new Error(`.pancake corpus record ${id} failed integrity verification`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
let record;
|
|
892
|
+
try { record = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
893
|
+
throw new Error(`.pancake corpus record ${id} is not valid JSON`, { cause: err });
|
|
894
|
+
}
|
|
895
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
896
|
+
throw new Error(`.pancake corpus record ${id} is not a JSON object`);
|
|
897
|
+
}
|
|
898
|
+
return cacheRecord(id, record);
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
// Lifecycle: close() is idempotent (the encoder's WASM buffers and the
|
|
902
|
+
// file descriptor are released once), and the query surface refuses
|
|
903
|
+
// use after close instead of reading through a freed encoder.
|
|
904
|
+
let closed = false;
|
|
905
|
+
const assertOpen = () => {
|
|
906
|
+
if (closed) throw new Error('.pancake reader is closed');
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
info() {
|
|
911
|
+
return {
|
|
912
|
+
identity,
|
|
913
|
+
formatVersion,
|
|
914
|
+
profile,
|
|
915
|
+
// Human-readable pack name from the identity-verified
|
|
916
|
+
// manifest (compile records it under corpus.provenance);
|
|
917
|
+
// null when the builder recorded none.
|
|
918
|
+
name: typeof manifest.corpus?.provenance?.name === 'string'
|
|
919
|
+
? manifest.corpus.provenance.name
|
|
920
|
+
: (typeof manifest.name === 'string' ? manifest.name : null),
|
|
921
|
+
// Content license recorded at compile (SPDX id or free
|
|
922
|
+
// text); null when the builder recorded none.
|
|
923
|
+
license: typeof manifest.corpus?.provenance?.license === 'string'
|
|
924
|
+
? manifest.corpus.provenance.license : null,
|
|
925
|
+
records: recordCount,
|
|
926
|
+
dim,
|
|
927
|
+
metric: manifest.metric,
|
|
928
|
+
encoder: encoderInfo,
|
|
929
|
+
encoderVerified,
|
|
930
|
+
corpusIntegrity,
|
|
931
|
+
fileBytes,
|
|
932
|
+
residentBytes: sketch.residentBytes + 8 * (recordCount + 1) + (pageTable ? pageTable.length : 0),
|
|
933
|
+
residentVerified: sketch.stats().residentVerified,
|
|
934
|
+
// True only after a full vectors pass (verifyIndexVectors
|
|
935
|
+
// at open, or verifyVectors() later).
|
|
936
|
+
vectorsVerified: sketch.vectorsVerified === true,
|
|
937
|
+
// 'per-row-sha256' when the embedded sketch is format 2
|
|
938
|
+
// (every rerank row verified on its own read against the
|
|
939
|
+
// identity-anchored page-hash table); 'segment-sha256'
|
|
940
|
+
// for format-1 sketches, where rows are committed but
|
|
941
|
+
// unverified until a full vectors pass.
|
|
942
|
+
indexRowIntegrity: sketch.formatVersion >= 2 && sketch.verifyRows !== false
|
|
943
|
+
? 'per-row-sha256' : 'segment-sha256',
|
|
944
|
+
// 'engine' once a WASM scan kernel serves the resident
|
|
945
|
+
// scan (auto-staged or injected); 'js' before staging
|
|
946
|
+
// resolves and forever if it fails or was disabled.
|
|
947
|
+
residentScan: scanner ? 'engine' : 'js',
|
|
948
|
+
lexical: lexicalIndex
|
|
949
|
+
? {
|
|
950
|
+
terms: lexicalIndex.termCount,
|
|
951
|
+
docCount: lexicalIndex.docCount,
|
|
952
|
+
...(lexicalIndex.lazy ? { lazy: true } : {}),
|
|
953
|
+
}
|
|
954
|
+
: null,
|
|
955
|
+
sampleQueries: Array.isArray(manifest.sampleQueries) ? manifest.sampleQueries : [],
|
|
956
|
+
};
|
|
957
|
+
},
|
|
958
|
+
|
|
959
|
+
async query(text, queryOptions = {}) {
|
|
960
|
+
assertOpen();
|
|
961
|
+
// k: absent means the default; anything supplied must be a
|
|
962
|
+
// positive integer (an explicit 0, -1, or 2.5 is an error,
|
|
963
|
+
// not a silent fallback) and is capped to the corpus size
|
|
964
|
+
// before it sizes the search.
|
|
965
|
+
let k = 5;
|
|
966
|
+
if (queryOptions.k !== undefined) {
|
|
967
|
+
if (!Number.isSafeInteger(queryOptions.k) || queryOptions.k < 1) {
|
|
968
|
+
throw new Error('query() k must be a positive integer');
|
|
969
|
+
}
|
|
970
|
+
k = queryOptions.k;
|
|
971
|
+
}
|
|
972
|
+
k = Math.min(k, Math.max(recordCount, 1));
|
|
973
|
+
const trimmed = String(text || '').trim();
|
|
974
|
+
if (!trimmed) throw new Error('query text is required');
|
|
975
|
+
const context = await embed(trimmed);
|
|
976
|
+
const pre = preScore(context);
|
|
977
|
+
let hits = [];
|
|
978
|
+
let fused = null;
|
|
979
|
+
if (!pre) {
|
|
980
|
+
// Hybrid retrieval when the artifact carries a lexical
|
|
981
|
+
// segment: the BM25 top matches join the sketch's exact
|
|
982
|
+
// rerank as extra candidates (scored by true vector
|
|
983
|
+
// distance like any candidate — this is what recovers
|
|
984
|
+
// known-item lookups the sketch scan's top-C missed),
|
|
985
|
+
// and the final ordering fuses the distance ranking
|
|
986
|
+
// with the BM25 ranking by reciprocal rank (RRF, k=60,
|
|
987
|
+
// untuned). Abstention still scores the distance-sorted
|
|
988
|
+
// top-k exactly as calibrated; fusion affects result
|
|
989
|
+
// order only. fullRerankOutput returns every reranked
|
|
990
|
+
// candidate (all already fetched and exactly scored) so
|
|
991
|
+
// fusion sees the true distance of every lexical match.
|
|
992
|
+
// Common query terms give every matching document a
|
|
993
|
+
// small, near-tied BM25 score (idf collapses them);
|
|
994
|
+
// that flat mass carries no ranking information and
|
|
995
|
+
// dilutes rank fusion. Keep only hits within a factor
|
|
996
|
+
// of the best lexical score — for a known-item lookup
|
|
997
|
+
// that is typically the one document holding the rare
|
|
998
|
+
// terms.
|
|
999
|
+
// retrieval: 'hybrid' (default when the artifact
|
|
1000
|
+
// carries a lexical segment) | 'vector' | 'lexical' —
|
|
1001
|
+
// the last two exist for measurement and debugging
|
|
1002
|
+
// (retrieval-quality bakeoffs run all three on one
|
|
1003
|
+
// artifact). Abstention scores the distance-sorted
|
|
1004
|
+
// top-k identically in every mode.
|
|
1005
|
+
// 'augmented' unions the lexical candidates into the
|
|
1006
|
+
// exact rerank but keeps pure distance order — the mode
|
|
1007
|
+
// for workloads scored against exact nearest neighbors,
|
|
1008
|
+
// where RRF's reordering reads as loss by definition.
|
|
1009
|
+
const retrieval = queryOptions.retrieval ?? 'hybrid';
|
|
1010
|
+
if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
|
|
1011
|
+
throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
|
|
1012
|
+
}
|
|
1013
|
+
const lexicalRaw = lexicalIndex && retrieval !== 'vector'
|
|
1014
|
+
? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES) : [];
|
|
1015
|
+
const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / 3);
|
|
1016
|
+
const searched = (await sketch.search(context.vector, k, {
|
|
1017
|
+
rerank: queryOptions.rerank,
|
|
1018
|
+
parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
|
|
1019
|
+
// The identity-verified manifest may carry a fetch
|
|
1020
|
+
// hint: cluster-ordered layouts only coalesce at a
|
|
1021
|
+
// gap matched to their cluster geometry (the wiki
|
|
1022
|
+
// pack's k-means layout needs ~16 KiB; the default
|
|
1023
|
+
// 2 KiB squanders it — measured 440 vs 200
|
|
1024
|
+
// requests/query at C=600).
|
|
1025
|
+
gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap
|
|
1026
|
+
?? (Number.isFinite(manifest.recommendedGap) ? manifest.recommendedGap : undefined),
|
|
1027
|
+
maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
|
|
1028
|
+
// Present once background staging finished; the
|
|
1029
|
+
// sketch reader ignores it for any query whose C
|
|
1030
|
+
// exceeds the scanner's buffers.
|
|
1031
|
+
...(scanner ? { scanner } : {}),
|
|
1032
|
+
...(lexicalHits.length ? {
|
|
1033
|
+
extraCandidates: lexicalHits.map((h) => h.id),
|
|
1034
|
+
fullRerankOutput: true,
|
|
1035
|
+
} : {}),
|
|
1036
|
+
})).results;
|
|
1037
|
+
hits = searched.slice(0, k);
|
|
1038
|
+
if (retrieval === 'augmented') {
|
|
1039
|
+
fused = null; // distance order, candidates already augmented
|
|
1040
|
+
} else if (retrieval === 'lexical') {
|
|
1041
|
+
const byId = new Map(searched.map((hit) => [hit.id, hit]));
|
|
1042
|
+
fused = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean).slice(0, k);
|
|
1043
|
+
} else if (lexicalHits.length) {
|
|
1044
|
+
const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
|
|
1045
|
+
fused = searched
|
|
1046
|
+
.map((hit, vRank) => ({
|
|
1047
|
+
hit,
|
|
1048
|
+
score: 1 / (RRF_K + vRank) + (lexRank.has(hit.id) ? 1 / (RRF_K + lexRank.get(hit.id)) : 0),
|
|
1049
|
+
}))
|
|
1050
|
+
.sort((a, b) => (b.score - a.score) || (a.hit.distance - b.hit.distance))
|
|
1051
|
+
.slice(0, k)
|
|
1052
|
+
.map((entry) => entry.hit);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const quality = pre || await scoreQuality(hits, context);
|
|
1056
|
+
const returned = quality.match_quality === 'none' ? [] : (fused || hits);
|
|
1057
|
+
// The search's id and distance are authoritative: they are
|
|
1058
|
+
// written last so a corpus record carrying its own `id` or
|
|
1059
|
+
// `distance` field cannot overwrite them (reserved names).
|
|
1060
|
+
const results = await Promise.all(returned.map(async (hit) => {
|
|
1061
|
+
const record = await hydrate(hit.id);
|
|
1062
|
+
return { ...record, id: hit.id, distance: hit.distance };
|
|
1063
|
+
}));
|
|
1064
|
+
return { matchQuality: quality.match_quality, confidence: quality.confidence, results };
|
|
1065
|
+
},
|
|
1066
|
+
|
|
1067
|
+
/** Hydrate one corpus record by id (verified per record on format 2). */
|
|
1068
|
+
async record(id) {
|
|
1069
|
+
assertOpen();
|
|
1070
|
+
return hydrate(id);
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Full pass over the index's lazy vector rows against the
|
|
1075
|
+
* identity-anchored vectorsSha256 (streamed where possible).
|
|
1076
|
+
* Until this resolves (or verifyIndexVectors ran at open), rows
|
|
1077
|
+
* that feed reranking are committed but not verified per read.
|
|
1078
|
+
*/
|
|
1079
|
+
async verifyVectors(verifyOptions) {
|
|
1080
|
+
assertOpen();
|
|
1081
|
+
return sketch.verifyVectors(verifyOptions);
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
async evaluation() {
|
|
1085
|
+
assertOpen();
|
|
1086
|
+
const ev = segments.get('evaluation');
|
|
1087
|
+
if (!ev) return null;
|
|
1088
|
+
const bytes = await readChecked(source, ev.offset, ev.length, 'evaluation segment', maxReadBytes, fileBytes);
|
|
1089
|
+
if (await sha256hex(bytes) !== ev.sha256) {
|
|
1090
|
+
throw new Error('.pancake evaluation segment failed hash verification');
|
|
1091
|
+
}
|
|
1092
|
+
let parsed;
|
|
1093
|
+
try { parsed = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
1094
|
+
throw new Error('.pancake evaluation segment is not valid JSON', { cause: err });
|
|
1095
|
+
}
|
|
1096
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1097
|
+
throw new Error('.pancake evaluation segment must be a JSON object');
|
|
1098
|
+
}
|
|
1099
|
+
return parsed;
|
|
1100
|
+
},
|
|
1101
|
+
|
|
1102
|
+
async close() {
|
|
1103
|
+
if (closed) return;
|
|
1104
|
+
closed = true;
|
|
1105
|
+
await settleEncoder();
|
|
1106
|
+
disposeEncoder();
|
|
1107
|
+
await settleScanner();
|
|
1108
|
+
disposeScanner();
|
|
1109
|
+
await sketch.close();
|
|
1110
|
+
if (owned) await source.close();
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
await settleEncoder();
|
|
1115
|
+
try { disposeEncoder(); } catch { /* already released */ }
|
|
1116
|
+
await settleScanner();
|
|
1117
|
+
try { disposeScanner(); } catch { /* already released */ }
|
|
1118
|
+
if (owned && source.close) await source.close().catch(() => {});
|
|
1119
|
+
throw err;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Documented alias (see the rename note at the top of this file).
|
|
1124
|
+
export { openPancakeFile as openPikeletFile };
|