pikelet-wasm 0.7.0 → 0.8.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 +19 -3
- package/NOTICE +5 -0
- package/README.md +397 -454
- package/complete/builder.d.ts +4 -4
- package/complete/builder.mjs +8 -8
- package/complete/encoder-kernels/encoder.node.wasm +0 -0
- package/complete/encoder-kernels/encoder.wasm +0 -0
- package/complete/index.d.ts +11 -7
- package/complete/index.mjs +161 -112
- package/complete/inline-transformer.mjs +22 -9
- package/complete/lexical.mjs +11 -11
- package/complete/retrieval-abstention.mjs +18 -6
- package/complete/sources.mjs +22 -1
- package/dist/engine.js +1 -1
- package/dist/engine.scalar.js +1 -1
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +58 -51
- package/{pancake-artifact-common.js → pikelet-artifact-common.js} +42 -42
- package/{pancake-artifact-range.js → pikelet-artifact-range.js} +36 -36
- package/{pancake-artifact-sketch.js → pikelet-artifact-sketch.js} +76 -66
- package/{pancake-artifact.d.ts → pikelet-artifact.d.ts} +11 -11
- package/{pancake-artifact.js → pikelet-artifact.js} +10 -10
- package/{pancake-core.js → pikelet-core.js} +136 -136
- package/{pancake-errors.js → pikelet-errors.js} +9 -8
- package/{pancake.d.ts → pikelet.d.ts} +55 -55
- package/{pancake.js → pikelet.js} +44 -44
- package/{pancake.node.mjs → pikelet.node.mjs} +46 -46
- package/{pancake.web.d.ts → pikelet.web.d.ts} +13 -13
- package/{pancake.web.mjs → pikelet.web.mjs} +20 -20
- package/{pancake.workerd.mjs → pikelet.workerd.mjs} +18 -18
- /package/{pancake-loader.js → pikelet-loader.js} +0 -0
package/complete/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The one-file reader: open a .
|
|
1
|
+
// The one-file reader: open a .pikelet (spec/COMPLETE_PROFILE.md) and query
|
|
2
2
|
// it. Environment-neutral — the same module runs in Node (file path or
|
|
3
3
|
// range source) and the browser (HTTP range source, via a bundler for the
|
|
4
4
|
// CJS sketch-reader dependency). The query path needs no core HNSW engine:
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
// engine's SIMD scan kernel (options.sketchScanner — auto-staged in Node,
|
|
9
9
|
// injected in browsers), which only ever changes speed, never results.
|
|
10
10
|
//
|
|
11
|
-
// const search = await
|
|
12
|
-
// const search = await
|
|
11
|
+
// const search = await openPikeletFile('docs.pikelet'); // Node
|
|
12
|
+
// const search = await openPikeletFile(httpRangeSource(url)); // browser
|
|
13
13
|
// const out = await search.query('how do workers restore snapshots');
|
|
14
14
|
//
|
|
15
15
|
// Every byte of the artifact is untrusted input (contract section 7): each
|
|
16
16
|
// offset, length, and count is validated — safe integer, within the header's
|
|
17
17
|
// fileBytes and the source's size, under the per-open read budget — before a
|
|
18
18
|
// read is issued or a buffer allocated, and every read must return exactly
|
|
19
|
-
// the bytes asked for. Format 2 files (profile
|
|
19
|
+
// the bytes asked for. Format 2 files (profile pikelet-complete-v2) carry
|
|
20
20
|
// per-record corpus digests, so each hydrated record is verified on its own
|
|
21
21
|
// range read; format 1 files verify eager segments whole and hydrate records
|
|
22
22
|
// under the segment digest only (reported via info().corpusIntegrity).
|
|
@@ -25,7 +25,7 @@ import { loadStudentModel, embedTextWithStudent } from './student-embedder.mjs';
|
|
|
25
25
|
import { computeMatchQuality, computePreSearchAbstention } from './student-abstention.mjs';
|
|
26
26
|
import { createAbstentionScorer } from './retrieval-abstention.mjs';
|
|
27
27
|
import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
|
|
28
|
-
import {
|
|
28
|
+
import { PikeletSketchArtifact } from '../pikelet-artifact.js';
|
|
29
29
|
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
30
30
|
|
|
31
31
|
import {
|
|
@@ -36,9 +36,11 @@ import {
|
|
|
36
36
|
} from './inline-transformer.mjs';
|
|
37
37
|
|
|
38
38
|
export { httpRangeSource } from './sources.mjs';
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
39
|
+
// Re-exported for the builder side: calibration fits coverage against the
|
|
40
|
+
// same fused (vector+BM25) ranking the reader serves, which needs an
|
|
41
|
+
// in-memory lexical searcher over the segment bytes already assembled by
|
|
42
|
+
// buildLexicalSegment, before the artifact is written to disk.
|
|
43
|
+
export { openLexicalIndex } from './lexical.mjs';
|
|
42
44
|
export {
|
|
43
45
|
KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
44
46
|
createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
|
|
@@ -47,7 +49,13 @@ export {
|
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
// Supported container formats: header formatVersion -> manifest profile.
|
|
50
|
-
export const SUPPORTED_PROFILES = Object.freeze({ 1: '
|
|
52
|
+
export const SUPPORTED_PROFILES = Object.freeze({ 1: 'pikelet-complete-v1', 2: 'pikelet-complete-v2' });
|
|
53
|
+
// Pre-rename profile strings (2026-09): every .pikelet published before this
|
|
54
|
+
// date — including live GitHub release assets — carries "pancake-complete-
|
|
55
|
+
// vN" in its manifest. Readers accept both spellings indefinitely so an
|
|
56
|
+
// already-published pack does not go dark on a reader upgrade; only
|
|
57
|
+
// SUPPORTED_PROFILES (above) is what a fresh build ever writes.
|
|
58
|
+
const LEGACY_PROFILES = Object.freeze({ 1: 'pancake-complete-v1', 2: 'pancake-complete-v2' });
|
|
51
59
|
export const CORPUS_LAYOUT_V2 = 'records-v2';
|
|
52
60
|
|
|
53
61
|
// Read budgets. Open-path reads (manifest, segment table, query-interp,
|
|
@@ -63,13 +71,20 @@ const MAX_RECORDS = 2 ** 31 - 1;
|
|
|
63
71
|
const MAX_PAGE_RECORDS = 65536;
|
|
64
72
|
const DIGEST_PAGE_CACHE = 64;
|
|
65
73
|
const RECORD_CACHE = 256;
|
|
66
|
-
// The embedded index is a .
|
|
74
|
+
// The embedded index is a .pikelet-sketch artifact (SKETCH_PROFILE.md);
|
|
67
75
|
// its fixed-size header is what format 2 commits to in the manifest.
|
|
68
76
|
const SKETCH_HEADER_BYTES = 256;
|
|
69
77
|
// Hybrid retrieval: BM25 candidates fetched from the lexical segment per
|
|
70
78
|
// query, and the reciprocal-rank-fusion constant (the standard untuned 60).
|
|
71
79
|
const LEXICAL_CANDIDATES = 24;
|
|
72
80
|
const RRF_K = 60;
|
|
81
|
+
// Only BM25 hits within this fraction of the top lexical score join fusion.
|
|
82
|
+
// Common query terms give every matching document a near-tied score (idf
|
|
83
|
+
// collapses them flat); a wide cutoff let that tied mass ride into fusion on
|
|
84
|
+
// a technicality. Kept identical to pikelet/src/calibrate.mjs's
|
|
85
|
+
// LEXICAL_CUTOFF so calibration is fit against the same fusion the reader
|
|
86
|
+
// actually serves.
|
|
87
|
+
const LEXICAL_CUTOFF = 1.5;
|
|
73
88
|
// Query-interpretation segments above this size open lazily (kind 3's
|
|
74
89
|
// inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
|
|
75
90
|
const LAZY_QI_BYTES = 4 * 1024 * 1024;
|
|
@@ -123,7 +138,7 @@ function asUint8Array(bytes) {
|
|
|
123
138
|
if (bytes instanceof Uint8Array) return bytes;
|
|
124
139
|
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
|
|
125
140
|
if (ArrayBuffer.isView(bytes)) return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
126
|
-
throw new Error('.
|
|
141
|
+
throw new Error('.pikelet range source returned a non-binary value');
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
function resolveBudget(value, label, fallback) {
|
|
@@ -138,7 +153,7 @@ function resolveBudget(value, label, fallback) {
|
|
|
138
153
|
// u64 fields become JS numbers only when they are exactly representable.
|
|
139
154
|
function u64(view, offset, label) {
|
|
140
155
|
const big = view.getBigUint64(offset, true);
|
|
141
|
-
if (big > MAX_SAFE_BIG) throw new Error(`.
|
|
156
|
+
if (big > MAX_SAFE_BIG) throw new Error(`.pikelet ${label} exceeds the safe integer range`);
|
|
142
157
|
return Number(big);
|
|
143
158
|
}
|
|
144
159
|
|
|
@@ -148,16 +163,16 @@ function u64(view, offset, label) {
|
|
|
148
163
|
function checkRange(source, offset, length, label, limit, fileBytes) {
|
|
149
164
|
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length)
|
|
150
165
|
|| offset < 0 || length < 0 || !Number.isSafeInteger(offset + length)) {
|
|
151
|
-
throw new Error(`.
|
|
166
|
+
throw new Error(`.pikelet ${label} range is out of bounds`);
|
|
152
167
|
}
|
|
153
168
|
if (length > limit) {
|
|
154
|
-
throw new Error(`.
|
|
169
|
+
throw new Error(`.pikelet ${label} read of ${length} bytes exceeds the ${limit}-byte read budget`);
|
|
155
170
|
}
|
|
156
171
|
if (fileBytes !== undefined && offset + length > fileBytes) {
|
|
157
|
-
throw new Error(`.
|
|
172
|
+
throw new Error(`.pikelet ${label} range extends past the file (${offset}+${length} > ${fileBytes})`);
|
|
158
173
|
}
|
|
159
174
|
if (Number.isSafeInteger(source.size) && offset + length > source.size) {
|
|
160
|
-
throw new Error(`.
|
|
175
|
+
throw new Error(`.pikelet ${label} range extends past the source (${offset}+${length} > ${source.size})`);
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
178
|
|
|
@@ -165,7 +180,7 @@ async function readChecked(source, offset, length, label, limit, fileBytes) {
|
|
|
165
180
|
checkRange(source, offset, length, label, limit, fileBytes);
|
|
166
181
|
const bytes = asUint8Array(await source.read(offset, length));
|
|
167
182
|
if (bytes.length !== length) {
|
|
168
|
-
throw new Error(`.
|
|
183
|
+
throw new Error(`.pikelet ${label} read returned ${bytes.length} of ${length} bytes (truncated or misbehaving source)`);
|
|
169
184
|
}
|
|
170
185
|
return bytes;
|
|
171
186
|
}
|
|
@@ -185,7 +200,7 @@ async function fileSource(filePath) {
|
|
|
185
200
|
preferredParallelism: Infinity,
|
|
186
201
|
preferredGapBytes: 2048,
|
|
187
202
|
async read(offset, length) {
|
|
188
|
-
if (fd === null) throw new Error('.
|
|
203
|
+
if (fd === null) throw new Error('.pikelet file source is closed');
|
|
189
204
|
const buffer = new Uint8Array(length);
|
|
190
205
|
let bytesRead = 0;
|
|
191
206
|
while (bytesRead < length) {
|
|
@@ -217,7 +232,7 @@ function windowSource(source, offset, length) {
|
|
|
217
232
|
preferredGapBytes: source.preferredGapBytes,
|
|
218
233
|
async read(off, len) {
|
|
219
234
|
if (!Number.isSafeInteger(off) || !Number.isSafeInteger(len) || off < 0 || len < 0 || off + len > length) {
|
|
220
|
-
throw new Error('.
|
|
235
|
+
throw new Error('.pikelet index segment read is outside the segment window');
|
|
221
236
|
}
|
|
222
237
|
return source.read(offset + off, len);
|
|
223
238
|
},
|
|
@@ -257,19 +272,19 @@ function base64Bytes(text) {
|
|
|
257
272
|
export async function verifyHostEncoder(declaration, encodeQuery, dim) {
|
|
258
273
|
const vectors = declaration.testVectors;
|
|
259
274
|
if (!Array.isArray(vectors) || vectors.length === 0) {
|
|
260
|
-
throw new Error('.
|
|
275
|
+
throw new Error('.pikelet kind-2 declaration carries no verification vectors');
|
|
261
276
|
}
|
|
262
277
|
if (declaration.dim !== undefined && declaration.dim !== dim) {
|
|
263
|
-
throw new Error(`.
|
|
278
|
+
throw new Error(`.pikelet kind-2 declaration dim ${declaration.dim} disagrees with manifest dim ${dim}`);
|
|
264
279
|
}
|
|
265
280
|
for (const tv of vectors) {
|
|
266
281
|
if (!tv || typeof tv.text !== 'string') {
|
|
267
|
-
throw new Error('.
|
|
282
|
+
throw new Error('.pikelet kind-2 verification vector is malformed');
|
|
268
283
|
}
|
|
269
284
|
// The expected embedding must be dim finite numbers: a malformed
|
|
270
285
|
// entry would turn every comparison into NaN, and NaN > maxDiff is
|
|
271
286
|
// false — the vector would "pass" without comparing anything.
|
|
272
|
-
const label = `.
|
|
287
|
+
const label = `.pikelet kind-2 verification vector "${tv.text.slice(0, 40)}…"`;
|
|
273
288
|
validateExpectedEmbedding(tv.embedding, dim, label);
|
|
274
289
|
const tolerance = validateTestVectorTolerance(tv.tolerance, label);
|
|
275
290
|
const vector = toFloat32(await encodeQuery(tv.text), dim, 'options.encodeQuery');
|
|
@@ -288,15 +303,15 @@ export async function verifyHostEncoder(declaration, encodeQuery, dim) {
|
|
|
288
303
|
}
|
|
289
304
|
|
|
290
305
|
/**
|
|
291
|
-
* Open a .
|
|
306
|
+
* Open a .pikelet complete artifact from a file path (Node) or a
|
|
292
307
|
* { read(offset, length), size? } range source (any runtime).
|
|
293
308
|
* Returns { query(text, {k}), info(), evaluation(), close() }.
|
|
294
309
|
*/
|
|
295
|
-
export async function
|
|
310
|
+
export async function openPikeletFile(input, options = {}) {
|
|
296
311
|
const source = typeof input === 'string' ? await fileSource(input) : input;
|
|
297
312
|
const owned = typeof input === 'string';
|
|
298
313
|
if (!source || typeof source.read !== 'function') {
|
|
299
|
-
throw new Error('
|
|
314
|
+
throw new Error('openPikeletFile() requires a file path or a range source with read(offset, length)');
|
|
300
315
|
}
|
|
301
316
|
const maxReadBytes = resolveBudget(options.maxReadBytes, 'maxReadBytes', DEFAULT_OPEN_READ_BYTES);
|
|
302
317
|
const maxRecordBytes = resolveBudget(options.maxRecordBytes, 'maxRecordBytes', DEFAULT_RECORD_BYTES);
|
|
@@ -333,23 +348,23 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
333
348
|
const header = await readChecked(source, 0, HEADER_BYTES, 'header', maxReadBytes);
|
|
334
349
|
const headerView = viewOf(header);
|
|
335
350
|
if (headerView.getUint32(0, true) !== MAGIC) {
|
|
336
|
-
throw new Error('not a .
|
|
351
|
+
throw new Error('not a .pikelet file (bad magic)');
|
|
337
352
|
}
|
|
338
353
|
const formatVersion = headerView.getUint32(4, true);
|
|
339
354
|
const profile = SUPPORTED_PROFILES[formatVersion];
|
|
340
|
-
if (!profile) throw new Error(`unsupported .
|
|
355
|
+
if (!profile) throw new Error(`unsupported .pikelet format version ${formatVersion}`);
|
|
341
356
|
const manifestBytes = headerView.getUint32(8, true);
|
|
342
357
|
const segmentCount = headerView.getUint32(12, true);
|
|
343
358
|
const fileBytes = u64(headerView, 16, 'header fileBytes');
|
|
344
359
|
if (manifestBytes < 2 || manifestBytes > MAX_MANIFEST_BYTES || segmentCount < 1 || segmentCount > MAX_SEGMENTS) {
|
|
345
|
-
throw new Error('.
|
|
360
|
+
throw new Error('.pikelet header is implausible');
|
|
346
361
|
}
|
|
347
362
|
if (Number.isSafeInteger(source.size) && source.size !== fileBytes) {
|
|
348
|
-
throw new Error(`.
|
|
363
|
+
throw new Error(`.pikelet is truncated or padded: header says ${fileBytes} bytes, source has ${source.size}`);
|
|
349
364
|
}
|
|
350
365
|
const tableBytes = segmentCount * TABLE_ENTRY_BYTES;
|
|
351
366
|
if (HEADER_BYTES + manifestBytes + tableBytes > fileBytes) {
|
|
352
|
-
throw new Error('.
|
|
367
|
+
throw new Error('.pikelet header places the manifest or segment table past the file');
|
|
353
368
|
}
|
|
354
369
|
const identity = toHex(header.subarray(24, 56));
|
|
355
370
|
// A caller pinning an expected identity (a shelf entry, a lockfile,
|
|
@@ -362,30 +377,31 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
362
377
|
throw new Error('options.expectedIdentity must be a sha256 hex string');
|
|
363
378
|
}
|
|
364
379
|
if (identity !== options.expectedIdentity.toLowerCase()) {
|
|
365
|
-
throw new Error(`.
|
|
380
|
+
throw new Error(`.pikelet identity mismatch: expected ${options.expectedIdentity.toLowerCase()}, found ${identity}`);
|
|
366
381
|
}
|
|
367
382
|
}
|
|
368
383
|
|
|
369
384
|
const manifestBuf = await readChecked(source, HEADER_BYTES, manifestBytes, 'manifest', maxReadBytes, fileBytes);
|
|
370
385
|
if (await sha256hex(manifestBuf) !== identity) {
|
|
371
|
-
throw new Error('.
|
|
386
|
+
throw new Error('.pikelet manifest failed identity verification');
|
|
372
387
|
}
|
|
373
388
|
let manifest;
|
|
374
389
|
try { manifest = JSON.parse(decoder.decode(manifestBuf)); } catch (err) {
|
|
375
|
-
throw new Error('.
|
|
390
|
+
throw new Error('.pikelet manifest is not valid JSON', { cause: err });
|
|
376
391
|
}
|
|
377
|
-
if (!manifest || typeof manifest !== 'object'
|
|
392
|
+
if (!manifest || typeof manifest !== 'object'
|
|
393
|
+
|| (manifest.profile !== profile && manifest.profile !== LEGACY_PROFILES[formatVersion])) {
|
|
378
394
|
throw new Error(`unsupported profile ${manifest?.profile} for format version ${formatVersion}`);
|
|
379
395
|
}
|
|
380
396
|
if (!Array.isArray(manifest.segments) || manifest.segments.length !== segmentCount) {
|
|
381
|
-
throw new Error('.
|
|
397
|
+
throw new Error('.pikelet manifest segment list disagrees with the header segment count');
|
|
382
398
|
}
|
|
383
399
|
const dim = manifest.dim;
|
|
384
400
|
if (!Number.isSafeInteger(dim) || dim < 1 || dim > 65536) {
|
|
385
|
-
throw new Error('.
|
|
401
|
+
throw new Error('.pikelet manifest dim is implausible');
|
|
386
402
|
}
|
|
387
403
|
if (!manifest.corpus || typeof manifest.corpus !== 'object') {
|
|
388
|
-
throw new Error('.
|
|
404
|
+
throw new Error('.pikelet manifest has no corpus block');
|
|
389
405
|
}
|
|
390
406
|
|
|
391
407
|
const table = await readChecked(source, HEADER_BYTES + manifestBytes, tableBytes, 'segment table', maxReadBytes, fileBytes);
|
|
@@ -401,26 +417,26 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
401
417
|
const declared = manifest.segments[i];
|
|
402
418
|
if (!declared || typeof declared !== 'object' || declared.bytes !== length || !isSha256Hex(declared.sha256)
|
|
403
419
|
|| offset !== expectedOffset || !Number.isSafeInteger(offset + length) || offset + length > fileBytes) {
|
|
404
|
-
throw new Error(`.
|
|
420
|
+
throw new Error(`.pikelet segment table disagrees with manifest at entry ${i}`);
|
|
405
421
|
}
|
|
406
422
|
if (kind === undefined) {
|
|
407
423
|
// Unknown kinds are skipped, not failed (spec 3.3); they are
|
|
408
424
|
// still committed through the manifest, so the declared name
|
|
409
425
|
// must at least not claim to be a kind this reader knows.
|
|
410
426
|
if (typeof declared.kind !== 'string' || declared.kind in KINDS) {
|
|
411
|
-
throw new Error(`.
|
|
427
|
+
throw new Error(`.pikelet segment table entry ${i} has unknown kind ${kindNumber} but the manifest names it ${declared.kind}`);
|
|
412
428
|
}
|
|
413
429
|
} else {
|
|
414
430
|
if (declared.kind !== kind) {
|
|
415
|
-
throw new Error(`.
|
|
431
|
+
throw new Error(`.pikelet segment table disagrees with manifest at entry ${i}`);
|
|
416
432
|
}
|
|
417
|
-
if (segments.has(kind)) throw new Error(`.
|
|
433
|
+
if (segments.has(kind)) throw new Error(`.pikelet carries more than one ${kind} segment`);
|
|
418
434
|
segments.set(kind, { offset, length, sha256: declared.sha256 });
|
|
419
435
|
}
|
|
420
436
|
expectedOffset = align16(offset + length);
|
|
421
437
|
}
|
|
422
438
|
for (const required of ['index', 'corpus', 'query-interp']) {
|
|
423
|
-
if (!segments.has(required)) throw new Error(`.
|
|
439
|
+
if (!segments.has(required)) throw new Error(`.pikelet is missing the ${required} segment`);
|
|
424
440
|
}
|
|
425
441
|
|
|
426
442
|
// Corpus tables: count + offsets (+ page table for layout v2),
|
|
@@ -432,15 +448,15 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
432
448
|
const corpus = segments.get('corpus');
|
|
433
449
|
const declaredRecords = manifest.corpus.records;
|
|
434
450
|
if (!Number.isSafeInteger(declaredRecords) || declaredRecords < 0 || declaredRecords > MAX_RECORDS) {
|
|
435
|
-
throw new Error('.
|
|
451
|
+
throw new Error('.pikelet manifest corpus.records is implausible');
|
|
436
452
|
}
|
|
437
453
|
const layout = manifest.corpus.layout;
|
|
438
454
|
const perRecord = formatVersion >= 2;
|
|
439
455
|
if (perRecord && layout !== CORPUS_LAYOUT_V2) {
|
|
440
|
-
throw new Error(`.
|
|
456
|
+
throw new Error(`.pikelet format ${formatVersion} requires corpus layout ${CORPUS_LAYOUT_V2}, manifest says ${layout}`);
|
|
441
457
|
}
|
|
442
458
|
if (!perRecord && layout !== undefined) {
|
|
443
|
-
throw new Error(`.
|
|
459
|
+
throw new Error(`.pikelet format 1 carries corpus layout v1, manifest says ${layout}`);
|
|
444
460
|
}
|
|
445
461
|
let pageRecords = 0;
|
|
446
462
|
let pages = 0;
|
|
@@ -451,12 +467,12 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
451
467
|
if (perRecord) {
|
|
452
468
|
pageRecords = manifest.corpus.pageRecords;
|
|
453
469
|
if (!Number.isSafeInteger(pageRecords) || pageRecords < 1 || pageRecords > MAX_PAGE_RECORDS) {
|
|
454
|
-
throw new Error('.
|
|
470
|
+
throw new Error('.pikelet manifest corpus.pageRecords is implausible');
|
|
455
471
|
}
|
|
456
472
|
pages = Math.ceil(declaredRecords / pageRecords);
|
|
457
473
|
if (manifest.corpus.pages !== pages || !isSha256Hex(manifest.corpus.pageTableSha256)
|
|
458
474
|
|| manifest.corpus.recordDigest !== 'sha256') {
|
|
459
|
-
throw new Error('.
|
|
475
|
+
throw new Error('.pikelet manifest corpus integrity block is inconsistent');
|
|
460
476
|
}
|
|
461
477
|
offsetsAt = 8;
|
|
462
478
|
tablesBytes = offsetsAt + 8 * (declaredRecords + 1) + 32 * pages;
|
|
@@ -468,7 +484,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
468
484
|
recordsAt = tablesBytes;
|
|
469
485
|
}
|
|
470
486
|
if (!Number.isSafeInteger(recordsAt) || recordsAt > corpus.length) {
|
|
471
|
-
throw new Error('.
|
|
487
|
+
throw new Error('.pikelet manifest corpus.records is implausible for the corpus segment');
|
|
472
488
|
}
|
|
473
489
|
// Format 2 commits to the embedded sketch's 256-byte header
|
|
474
490
|
// (metric/dim/count and the sketch's residentSha256/vectorsSha256),
|
|
@@ -476,7 +492,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
476
492
|
// sketch's self-checks alone verify against fields an attacker who
|
|
477
493
|
// rewrites the segment also controls.
|
|
478
494
|
if (perRecord && (!isSha256Hex(manifest.index?.headerSha256) || idx.length < SKETCH_HEADER_BYTES)) {
|
|
479
|
-
throw new Error('.
|
|
495
|
+
throw new Error('.pikelet manifest carries no index header commitment (index.headerSha256)');
|
|
480
496
|
}
|
|
481
497
|
|
|
482
498
|
// After the segment table, every remaining open-path extent is known,
|
|
@@ -502,11 +518,11 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
502
518
|
}
|
|
503
519
|
const bytes = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
504
520
|
if (await sha256hex(bytes) !== qi.sha256) {
|
|
505
|
-
throw new Error('.
|
|
521
|
+
throw new Error('.pikelet query-interp segment failed hash verification');
|
|
506
522
|
}
|
|
507
523
|
return bytes;
|
|
508
524
|
})(),
|
|
509
|
-
|
|
525
|
+
PikeletSketchArtifact.open(windowSource(source, idx.offset, idx.length), { maxReadBytes }),
|
|
510
526
|
readChecked(source, corpus.offset, tablesBytes, 'corpus tables', maxReadBytes, fileBytes),
|
|
511
527
|
// Lexical index (kind 5, OPTIONAL). Small segments: eager
|
|
512
528
|
// whole-segment read, digest-verified like the query-interp
|
|
@@ -522,14 +538,14 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
522
538
|
}
|
|
523
539
|
const bytes = await readChecked(source, seg.offset, seg.length, 'lexical segment', maxReadBytes, fileBytes);
|
|
524
540
|
if (await sha256hex(bytes) !== seg.sha256) {
|
|
525
|
-
throw new Error('.
|
|
541
|
+
throw new Error('.pikelet lexical segment failed hash verification');
|
|
526
542
|
}
|
|
527
543
|
return { bytes };
|
|
528
544
|
})() : null,
|
|
529
545
|
perRecord ? (async () => {
|
|
530
546
|
const headerBytes = await readChecked(source, idx.offset, SKETCH_HEADER_BYTES, 'index header', maxReadBytes, fileBytes);
|
|
531
547
|
if (await sha256hex(headerBytes) !== manifest.index.headerSha256) {
|
|
532
|
-
throw new Error('.
|
|
548
|
+
throw new Error('.pikelet index header failed hash verification against the manifest');
|
|
533
549
|
}
|
|
534
550
|
})() : null,
|
|
535
551
|
]);
|
|
@@ -538,12 +554,12 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
538
554
|
? (lexicalBytes.lazyIndex || openLexicalIndex(lexicalBytes.bytes))
|
|
539
555
|
: null;
|
|
540
556
|
if (lexicalIndex && lexicalIndex.docCount !== declaredRecords) {
|
|
541
|
-
throw new Error(`.
|
|
557
|
+
throw new Error(`.pikelet lexical segment covers ${lexicalIndex.docCount} records, corpus declares ${declaredRecords}`);
|
|
542
558
|
}
|
|
543
559
|
|
|
544
560
|
// Query interpretation: version word, kind, two length-prefixed
|
|
545
561
|
// regions that must tile the segment exactly.
|
|
546
|
-
if (qi.length < 16) throw new Error('.
|
|
562
|
+
if (qi.length < 16) throw new Error('.pikelet query-interp segment is too short');
|
|
547
563
|
const qiView = viewOf(qiFirst);
|
|
548
564
|
const qiVersion = qiView.getUint32(0, true);
|
|
549
565
|
if (qiVersion !== 1) throw new Error(`unsupported query-interpretation version ${qiVersion}`);
|
|
@@ -551,7 +567,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
551
567
|
const encoderLen = qiView.getUint32(8, true);
|
|
552
568
|
const calibrationLen = qiView.getUint32(12, true);
|
|
553
569
|
if (16 + encoderLen + calibrationLen !== qi.length) {
|
|
554
|
-
throw new Error('.
|
|
570
|
+
throw new Error('.pikelet query-interp segment layout is inconsistent');
|
|
555
571
|
}
|
|
556
572
|
// Laziness only pays for kind 3 (the deferrable bulk IS the
|
|
557
573
|
// encoder); a large segment of any other kind falls back to the
|
|
@@ -561,7 +577,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
561
577
|
if (qiDeferrable && qiKind !== 3) {
|
|
562
578
|
qiBytes = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
563
579
|
if (await sha256hex(qiBytes) !== qi.sha256) {
|
|
564
|
-
throw new Error('.
|
|
580
|
+
throw new Error('.pikelet query-interp segment failed hash verification');
|
|
565
581
|
}
|
|
566
582
|
qiDeferred = false;
|
|
567
583
|
}
|
|
@@ -574,10 +590,10 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
574
590
|
const encoderBytes = qiDeferred ? null : qiBytes.subarray(16, 16 + encoderLen);
|
|
575
591
|
let calibrationJson;
|
|
576
592
|
try { calibrationJson = JSON.parse(decoder.decode(calibrationRegion)); } catch (err) {
|
|
577
|
-
throw new Error('.
|
|
593
|
+
throw new Error('.pikelet calibration is not valid JSON', { cause: err });
|
|
578
594
|
}
|
|
579
595
|
if (!calibrationJson || typeof calibrationJson !== 'object') {
|
|
580
|
-
throw new Error('.
|
|
596
|
+
throw new Error('.pikelet calibration must be a JSON object');
|
|
581
597
|
}
|
|
582
598
|
|
|
583
599
|
// kind 1 (student-inline-v1): pure-JS inline encoder + feature-stream
|
|
@@ -595,16 +611,25 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
595
611
|
const retrievalScorer = () => {
|
|
596
612
|
const scorer = createAbstentionScorer(calibrationJson.asset, base64Bytes(calibrationJson.vocabBloomBase64));
|
|
597
613
|
const VERDICTS = { answer: 'strong', weak: 'weak', abstain: 'none' };
|
|
598
|
-
return async (hits, context) => {
|
|
614
|
+
return async (hits, context, fusedHits) => {
|
|
599
615
|
if (!scorer) return { match_quality: 'unscored' };
|
|
600
616
|
// The coverage term grounds the verdict in the top passages'
|
|
601
|
-
// text, so those records hydrate before scoring.
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
617
|
+
// text, so those records hydrate before scoring. Hydrated
|
|
618
|
+
// from the fused (RRF) order when hybrid retrieval produced
|
|
619
|
+
// one — calibrate.mjs fits coverage against the same fused
|
|
620
|
+
// ranking, and a chunk BM25 ranks first but the embedding
|
|
621
|
+
// ranks far down is exactly the case coverage exists to
|
|
622
|
+
// catch; scoring it against vector-only order would miss it
|
|
623
|
+
// again. Falls back to vector order (hits) when there is no
|
|
624
|
+
// lexical segment or fusion did not change anything. When
|
|
625
|
+
// the query is answered the pages are already hot for
|
|
626
|
+
// result hydration; an abstained query costs these extra
|
|
627
|
+
// reads. Hydration failures propagate — on format 2 a
|
|
628
|
+
// record that fails its digest must fail the query, not
|
|
629
|
+
// skew its verdict.
|
|
630
|
+
const passageSource = fusedHits?.length ? fusedHits : hits;
|
|
631
|
+
const topTexts = scorer.usesPassage && passageSource.length
|
|
632
|
+
? (await Promise.all(passageSource.slice(0, scorer.passagesNeeded || 1)
|
|
608
633
|
.map((hit) => hydrate(hit.id)))).map((record) => record?.text)
|
|
609
634
|
: [];
|
|
610
635
|
const scored = scorer.score(context.text, hits, topTexts);
|
|
@@ -625,9 +650,9 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
625
650
|
} else if (qiKind === 2) {
|
|
626
651
|
let declaration;
|
|
627
652
|
try { declaration = JSON.parse(decoder.decode(encoderBytes)); } catch (err) {
|
|
628
|
-
throw new Error('.
|
|
653
|
+
throw new Error('.pikelet kind-2 encoder declaration is not valid JSON', { cause: err });
|
|
629
654
|
}
|
|
630
|
-
if (!declaration || typeof declaration !== 'object') throw new Error('.
|
|
655
|
+
if (!declaration || typeof declaration !== 'object') throw new Error('.pikelet kind-2 encoder declaration must be a JSON object');
|
|
631
656
|
encoderInfo = declaration;
|
|
632
657
|
const encodeQuery = options.encodeQuery;
|
|
633
658
|
if (encodeQuery !== undefined && typeof encodeQuery !== 'function') {
|
|
@@ -641,7 +666,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
641
666
|
} else if (options.allowUnverifiedEncoder === true) {
|
|
642
667
|
encoderVerified = false;
|
|
643
668
|
} else {
|
|
644
|
-
throw new Error(`.
|
|
669
|
+
throw new Error(`.pikelet declares an external encoder (${declaration.model}) without verification vectors, `
|
|
645
670
|
+ 'so the host encoder cannot be checked against it (contract section 4.4); '
|
|
646
671
|
+ 'pass options.allowUnverifiedEncoder: true to serve it anyway, marked unverified');
|
|
647
672
|
}
|
|
@@ -650,8 +675,8 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
650
675
|
}
|
|
651
676
|
embed = async (text) => {
|
|
652
677
|
if (!encodeQuery) {
|
|
653
|
-
throw new Error(`.
|
|
654
|
-
+ 'pass options.encodeQuery to
|
|
678
|
+
throw new Error(`.pikelet declares an external encoder (${declaration.model}); `
|
|
679
|
+
+ 'pass options.encodeQuery to openPikeletFile');
|
|
655
680
|
}
|
|
656
681
|
return { vector: toFloat32(await encodeQuery(text), dim, 'options.encodeQuery'), text };
|
|
657
682
|
};
|
|
@@ -680,7 +705,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
680
705
|
if (qiDeferred) {
|
|
681
706
|
const full = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
682
707
|
if (await sha256hex(full) !== qi.sha256) {
|
|
683
|
-
throw new Error('.
|
|
708
|
+
throw new Error('.pikelet query-interp segment failed hash verification');
|
|
684
709
|
}
|
|
685
710
|
// The header and calibration slices used at open were
|
|
686
711
|
// unverified; the digest-verified segment must contain
|
|
@@ -688,13 +713,13 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
688
713
|
// every query must fail.
|
|
689
714
|
if (!bytesEqual(full.subarray(0, 16), openHeader)
|
|
690
715
|
|| !bytesEqual(full.subarray(16 + encoderLen), calibrationRegion)) {
|
|
691
|
-
throw new Error('.
|
|
716
|
+
throw new Error('.pikelet query-interp segment bytes changed between open and encoder fetch');
|
|
692
717
|
}
|
|
693
718
|
encBytes = full.subarray(16, 16 + encoderLen);
|
|
694
719
|
}
|
|
695
720
|
const { declaration, vocabText, blob } = parseInlineTransformerEncoder(encBytes);
|
|
696
721
|
if (declaration.dim !== undefined && declaration.dim !== dim) {
|
|
697
|
-
throw new Error(`.
|
|
722
|
+
throw new Error(`.pikelet kind-3 declaration dim ${declaration.dim} disagrees with manifest dim ${dim}`);
|
|
698
723
|
}
|
|
699
724
|
const embedder = await createInlineTransformerEmbedder({ declaration, vocabText, blob, createEncoder, verify: verifyEncoder });
|
|
700
725
|
encoderInfo = declaration;
|
|
@@ -732,10 +757,10 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
732
757
|
// Index: the embedded sketch artifact, opened in the wave above
|
|
733
758
|
// against a segment-windowed source (resident hash verified there).
|
|
734
759
|
if (sketch.count !== declaredRecords) {
|
|
735
|
-
throw new Error(`.
|
|
760
|
+
throw new Error(`.pikelet index count ${sketch.count} != corpus records ${declaredRecords}`);
|
|
736
761
|
}
|
|
737
762
|
if (sketch.dim !== undefined && sketch.dim !== dim) {
|
|
738
|
-
throw new Error(`.
|
|
763
|
+
throw new Error(`.pikelet index dim ${sketch.dim} != manifest dim ${dim}`);
|
|
739
764
|
}
|
|
740
765
|
// The metric decides the search semantics; the sketch header's copy
|
|
741
766
|
// must agree with the identity-verified manifest (on format 2 the
|
|
@@ -743,7 +768,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
743
768
|
// is the only binding).
|
|
744
769
|
if (METRIC_NAMES[sketch.metric] !== undefined && typeof manifest.metric === 'string'
|
|
745
770
|
&& METRIC_NAMES[sketch.metric] !== manifest.metric) {
|
|
746
|
-
throw new Error(`.
|
|
771
|
+
throw new Error(`.pikelet index metric ${METRIC_NAMES[sketch.metric]} != manifest metric ${manifest.metric}`);
|
|
747
772
|
}
|
|
748
773
|
if (verifyIndexVectors) {
|
|
749
774
|
await sketch.verifyVectors();
|
|
@@ -788,9 +813,9 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
788
813
|
// Hidden from bundlers like the node encoder kernels: the
|
|
789
814
|
// web entrypoint's ?url wasm imports would break a browser
|
|
790
815
|
// build that statically pulled this in.
|
|
791
|
-
const { default:
|
|
816
|
+
const { default: Pikelet } = await import(/* webpackIgnore: true */ /* @vite-ignore */ '../pikelet.node.mjs');
|
|
792
817
|
await sketch.fullyResident;
|
|
793
|
-
const created = await
|
|
818
|
+
const created = await Pikelet.createSketchScanner(sketch, {
|
|
794
819
|
maxRerank: Math.min(sketch.count, SCANNER_MAX_RERANK),
|
|
795
820
|
});
|
|
796
821
|
disposeScanner = () => created.dispose();
|
|
@@ -805,16 +830,16 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
805
830
|
const tablesView = viewOf(tables);
|
|
806
831
|
const recordCount = tablesView.getUint32(0, true);
|
|
807
832
|
if (recordCount !== declaredRecords) {
|
|
808
|
-
throw new Error('.
|
|
833
|
+
throw new Error('.pikelet corpus count disagrees with manifest');
|
|
809
834
|
}
|
|
810
835
|
if (perRecord && tablesView.getUint32(4, true) !== pageRecords) {
|
|
811
|
-
throw new Error('.
|
|
836
|
+
throw new Error('.pikelet corpus page size disagrees with manifest');
|
|
812
837
|
}
|
|
813
838
|
const recordOffsets = new Float64Array(recordCount + 1);
|
|
814
839
|
for (let i = 0; i <= recordCount; i++) {
|
|
815
840
|
const value = u64(tablesView, offsetsAt + 8 * i, `corpus offset ${i}`);
|
|
816
841
|
if (value > corpus.length || (i > 0 && value < recordOffsets[i - 1]) || (i === 0 && value !== recordsAt)) {
|
|
817
|
-
throw new Error('.
|
|
842
|
+
throw new Error('.pikelet corpus offsets are inconsistent');
|
|
818
843
|
}
|
|
819
844
|
recordOffsets[i] = value;
|
|
820
845
|
}
|
|
@@ -822,7 +847,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
822
847
|
if (perRecord) {
|
|
823
848
|
pageTable = tables.slice(offsetsAt + 8 * (recordCount + 1), tablesBytes);
|
|
824
849
|
if (await sha256hex(pageTable) !== manifest.corpus.pageTableSha256) {
|
|
825
|
-
throw new Error('.
|
|
850
|
+
throw new Error('.pikelet corpus page table failed hash verification');
|
|
826
851
|
}
|
|
827
852
|
}
|
|
828
853
|
const corpusIntegrity = perRecord ? (verifyRecords ? 'per-record-sha256' : 'per-record-sha256 (unverified by option)') : 'segment-sha256';
|
|
@@ -846,7 +871,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
846
871
|
`corpus digest page ${page}`, maxReadBytes, fileBytes);
|
|
847
872
|
if (await sha256hex(bytes) !== toHex(pageTable.subarray(32 * page, 32 * page + 32))) {
|
|
848
873
|
digestPages.delete(page);
|
|
849
|
-
throw new Error(`.
|
|
874
|
+
throw new Error(`.pikelet corpus digest page ${page} failed hash verification`);
|
|
850
875
|
}
|
|
851
876
|
return bytes;
|
|
852
877
|
})();
|
|
@@ -869,7 +894,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
869
894
|
|
|
870
895
|
const hydrate = async (id) => {
|
|
871
896
|
if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
|
|
872
|
-
throw new Error(`.
|
|
897
|
+
throw new Error(`.pikelet result id ${id} is outside the corpus`);
|
|
873
898
|
}
|
|
874
899
|
if (recordCache.has(id)) {
|
|
875
900
|
const cached = recordCache.get(id);
|
|
@@ -885,15 +910,15 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
885
910
|
const digests = await pageDigests(page);
|
|
886
911
|
const at = 32 * (id - page * pageRecords);
|
|
887
912
|
if (await sha256hex(bytes) !== toHex(digests.subarray(at, at + 32))) {
|
|
888
|
-
throw new Error(`.
|
|
913
|
+
throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
|
|
889
914
|
}
|
|
890
915
|
}
|
|
891
916
|
let record;
|
|
892
917
|
try { record = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
893
|
-
throw new Error(`.
|
|
918
|
+
throw new Error(`.pikelet corpus record ${id} is not valid JSON`, { cause: err });
|
|
894
919
|
}
|
|
895
920
|
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
896
|
-
throw new Error(`.
|
|
921
|
+
throw new Error(`.pikelet corpus record ${id} is not a JSON object`);
|
|
897
922
|
}
|
|
898
923
|
return cacheRecord(id, record);
|
|
899
924
|
};
|
|
@@ -903,7 +928,7 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
903
928
|
// use after close instead of reading through a freed encoder.
|
|
904
929
|
let closed = false;
|
|
905
930
|
const assertOpen = () => {
|
|
906
|
-
if (closed) throw new Error('.
|
|
931
|
+
if (closed) throw new Error('.pikelet reader is closed');
|
|
907
932
|
};
|
|
908
933
|
|
|
909
934
|
return {
|
|
@@ -976,6 +1001,8 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
976
1001
|
const pre = preScore(context);
|
|
977
1002
|
let hits = [];
|
|
978
1003
|
let fused = null;
|
|
1004
|
+
let searched = null;
|
|
1005
|
+
let fusedFull = null;
|
|
979
1006
|
if (!pre) {
|
|
980
1007
|
// Hybrid retrieval when the artifact carries a lexical
|
|
981
1008
|
// segment: the BM25 top matches join the sketch's exact
|
|
@@ -1012,8 +1039,8 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
1012
1039
|
}
|
|
1013
1040
|
const lexicalRaw = lexicalIndex && retrieval !== 'vector'
|
|
1014
1041
|
? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES) : [];
|
|
1015
|
-
const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score /
|
|
1016
|
-
|
|
1042
|
+
const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / LEXICAL_CUTOFF);
|
|
1043
|
+
searched = (await sketch.search(context.vector, k, {
|
|
1017
1044
|
rerank: queryOptions.rerank,
|
|
1018
1045
|
parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
|
|
1019
1046
|
// The identity-verified manifest may carry a fetch
|
|
@@ -1029,31 +1056,56 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
1029
1056
|
// sketch reader ignores it for any query whose C
|
|
1030
1057
|
// exceeds the scanner's buffers.
|
|
1031
1058
|
...(scanner ? { scanner } : {}),
|
|
1032
|
-
...(lexicalHits.length ? {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1059
|
+
...(lexicalHits.length ? { extraCandidates: lexicalHits.map((h) => h.id) } : {}),
|
|
1060
|
+
// Always return the full reranked candidate list
|
|
1061
|
+
// (already fetched and exactly scored) rather than
|
|
1062
|
+
// truncating to k: abstention is fit at a fixed
|
|
1063
|
+
// top-10 window (calibrate.mjs) independent of the
|
|
1064
|
+
// caller's k, and needs that full window to score
|
|
1065
|
+
// consistently — see retrieval-abstention.mjs.
|
|
1066
|
+
fullRerankOutput: true,
|
|
1036
1067
|
})).results;
|
|
1037
1068
|
hits = searched.slice(0, k);
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1069
|
+
// Full-window fused order (not k-truncated) for scoring:
|
|
1070
|
+
// coverage grounds its verdict in the passage fusion
|
|
1071
|
+
// actually ranks first, and calibrate.mjs fits it the
|
|
1072
|
+
// same way — a k-truncated fused list would silently
|
|
1073
|
+
// drop the fused top hit for callers requesting a small k.
|
|
1074
|
+
if (retrieval === 'lexical') {
|
|
1041
1075
|
const byId = new Map(searched.map((hit) => [hit.id, hit]));
|
|
1042
|
-
|
|
1043
|
-
} else if (lexicalHits.length) {
|
|
1076
|
+
fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
|
|
1077
|
+
} else if (retrieval !== 'augmented' && lexicalHits.length) {
|
|
1044
1078
|
const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
|
|
1045
|
-
|
|
1079
|
+
fusedFull = searched
|
|
1046
1080
|
.map((hit, vRank) => ({
|
|
1047
1081
|
hit,
|
|
1048
1082
|
score: 1 / (RRF_K + vRank) + (lexRank.has(hit.id) ? 1 / (RRF_K + lexRank.get(hit.id)) : 0),
|
|
1049
1083
|
}))
|
|
1050
1084
|
.sort((a, b) => (b.score - a.score) || (a.hit.distance - b.hit.distance))
|
|
1051
|
-
.slice(0, k)
|
|
1052
1085
|
.map((entry) => entry.hit);
|
|
1053
1086
|
}
|
|
1087
|
+
fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
|
|
1054
1088
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1089
|
+
// Abstention scores the full reranked candidate window
|
|
1090
|
+
// (searched), not the k-truncated hits: calibrate.mjs fits
|
|
1091
|
+
// its signals at a fixed top-10 (top-5 for coverage)
|
|
1092
|
+
// regardless of how many results a caller ultimately
|
|
1093
|
+
// requests, so scoring must use the same window to match.
|
|
1094
|
+
// preScore (kind 1) runs before search and has no window to
|
|
1095
|
+
// fix, so it is unaffected. fusedFull (RRF over the same
|
|
1096
|
+
// full window, not k-truncated) is threaded through
|
|
1097
|
+
// separately so coverage grades the passage fusion actually
|
|
1098
|
+
// ranks first, while d0/margin/mean10 still read vector
|
|
1099
|
+
// order (searched) exactly as calibrated.
|
|
1100
|
+
const quality = pre || await scoreQuality(searched ?? hits, context, fusedFull);
|
|
1101
|
+
// A 'none' verdict withholds results by default — the
|
|
1102
|
+
// calibrated abstention signal is doing its job, and most
|
|
1103
|
+
// callers want that. showAbstained is an explicit opt-out
|
|
1104
|
+
// for a caller that wants the raw retrieval anyway (e.g. to
|
|
1105
|
+
// inspect why the classifier abstained); it never changes
|
|
1106
|
+
// matchQuality or confidence, only whether results ships.
|
|
1107
|
+
const returned = quality.match_quality === 'none' && !queryOptions.showAbstained
|
|
1108
|
+
? [] : (fused || hits);
|
|
1057
1109
|
// The search's id and distance are authoritative: they are
|
|
1058
1110
|
// written last so a corpus record carrying its own `id` or
|
|
1059
1111
|
// `distance` field cannot overwrite them (reserved names).
|
|
@@ -1087,14 +1139,14 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
1087
1139
|
if (!ev) return null;
|
|
1088
1140
|
const bytes = await readChecked(source, ev.offset, ev.length, 'evaluation segment', maxReadBytes, fileBytes);
|
|
1089
1141
|
if (await sha256hex(bytes) !== ev.sha256) {
|
|
1090
|
-
throw new Error('.
|
|
1142
|
+
throw new Error('.pikelet evaluation segment failed hash verification');
|
|
1091
1143
|
}
|
|
1092
1144
|
let parsed;
|
|
1093
1145
|
try { parsed = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
1094
|
-
throw new Error('.
|
|
1146
|
+
throw new Error('.pikelet evaluation segment is not valid JSON', { cause: err });
|
|
1095
1147
|
}
|
|
1096
1148
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1097
|
-
throw new Error('.
|
|
1149
|
+
throw new Error('.pikelet evaluation segment must be a JSON object');
|
|
1098
1150
|
}
|
|
1099
1151
|
return parsed;
|
|
1100
1152
|
},
|
|
@@ -1119,6 +1171,3 @@ export async function openPancakeFile(input, options = {}) {
|
|
|
1119
1171
|
throw err;
|
|
1120
1172
|
}
|
|
1121
1173
|
}
|
|
1122
|
-
|
|
1123
|
-
// Documented alias (see the rename note at the top of this file).
|
|
1124
|
-
export { openPancakeFile as openPikeletFile };
|