pikelet-wasm 0.8.1 → 0.9.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 +110 -33
- package/README.md +17 -1
- package/complete/builder.mjs +17 -4
- package/complete/format.mjs +6 -1
- package/complete/index.mjs +482 -58
- package/complete/layer-append.mjs +246 -0
- package/complete/layer-chain.mjs +284 -0
- package/complete/layer-compact.mjs +319 -0
- package/complete/layer-encoder.mjs +223 -0
- package/complete/layer-locator.mjs +228 -0
- package/complete/layer-manifest.mjs +271 -0
- package/complete/layer-reader.mjs +759 -0
- package/complete/layer-rebase.mjs +218 -0
- package/complete/lexical.mjs +78 -8
- package/complete/retrieval-abstention.mjs +15 -4
- package/complete/tombstones.mjs +281 -0
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +31 -1
- package/src/artifact/pikelet-artifact-sketch.js +67 -5
package/complete/index.mjs
CHANGED
|
@@ -79,7 +79,7 @@ const SKETCH_HEADER_BYTES = 256;
|
|
|
79
79
|
// Hybrid retrieval: BM25 candidates fetched from the lexical segment per
|
|
80
80
|
// query; the fusion rule and its constants live in fusion.mjs, shared with
|
|
81
81
|
// the calibrator so fit-time rankings match what is served.
|
|
82
|
-
const LEXICAL_CANDIDATES = 24;
|
|
82
|
+
export const LEXICAL_CANDIDATES = 24;
|
|
83
83
|
// scoreQuality's coverage term hydrates the fused top passagesNeeded
|
|
84
84
|
// records (asset.coverage.topK, 5 by default — see calibrate.mjs's
|
|
85
85
|
// COVERAGE_TOP_PASSAGES) once retrieval returns. The scorer that knows
|
|
@@ -98,7 +98,7 @@ const COVERAGE_BATCH_COUNT = 5;
|
|
|
98
98
|
// a technicality. Kept identical to pikelet/src/calibrate.mjs's
|
|
99
99
|
// LEXICAL_CUTOFF so calibration is fit against the same fusion the reader
|
|
100
100
|
// actually serves.
|
|
101
|
-
const LEXICAL_CUTOFF = 1.5;
|
|
101
|
+
export const LEXICAL_CUTOFF = 1.5;
|
|
102
102
|
// Query-interpretation segments above this size open lazily (kind 3's
|
|
103
103
|
// inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
|
|
104
104
|
const LAZY_QI_BYTES = 4 * 1024 * 1024;
|
|
@@ -282,7 +282,7 @@ function toFloat32(vector, dim, what) {
|
|
|
282
282
|
return v;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
function base64Bytes(text) {
|
|
285
|
+
export function base64Bytes(text) {
|
|
286
286
|
if (typeof text !== 'string') return new Uint8Array(0);
|
|
287
287
|
return typeof Buffer !== 'undefined'
|
|
288
288
|
? Buffer.from(text, 'base64')
|
|
@@ -331,6 +331,169 @@ export async function verifyHostEncoder(declaration, encodeQuery, dim) {
|
|
|
331
331
|
* { read(offset, length), size? } range source (any runtime).
|
|
332
332
|
* Returns { query(text, {k}), info(), evaluation(), close() }.
|
|
333
333
|
*/
|
|
334
|
+
/**
|
|
335
|
+
* Read a chain member's manifest and segment table without opening the corpus,
|
|
336
|
+
* sketch or lexical machinery — LAYERED_PROFILE.md 4.3.
|
|
337
|
+
*
|
|
338
|
+
* A tombstone-only layer carries only a query-interp (kind 4) and a tombstones
|
|
339
|
+
* segment. It is a chain member but not a search tier (3.1), so the full open
|
|
340
|
+
* has nothing to do for it and every corpus/index step would need a branch.
|
|
341
|
+
* The chain reader uses this instead; the header, manifest identity and each
|
|
342
|
+
* segment digest are verified exactly as the full open verifies them.
|
|
343
|
+
*
|
|
344
|
+
* Returns { tombstoneOnly, tier, close }. When `tombstoneOnly` is false the
|
|
345
|
+
* caller should close this and use openPikeletFile() instead: the member is a
|
|
346
|
+
* search tier and needs the real open.
|
|
347
|
+
*/
|
|
348
|
+
/**
|
|
349
|
+
* Read and identity-verify just a member's manifest, without opening its
|
|
350
|
+
* segments. 5.1.1's locator walk needs this: to find a chain's ancestors from
|
|
351
|
+
* the head alone it must read each member's `layer.parent` and stop at the
|
|
352
|
+
* base, and paying for a full open per hop only to discard it is wasteful when
|
|
353
|
+
* the walk may then reject the location.
|
|
354
|
+
*
|
|
355
|
+
* Returns { identity, manifest, isLayer, parent }. The identity check is the
|
|
356
|
+
* same one openPikeletFile makes, so a tampered manifest fails here too.
|
|
357
|
+
*/
|
|
358
|
+
export async function readMemberManifest(input, options = {}) {
|
|
359
|
+
const source = typeof input === 'string' ? await fileSource(input) : input;
|
|
360
|
+
const owned = typeof input === 'string';
|
|
361
|
+
const maxReadBytes = resolveBudget(options.maxReadBytes, 'maxReadBytes', DEFAULT_OPEN_READ_BYTES);
|
|
362
|
+
try {
|
|
363
|
+
const header = await readChecked(source, 0, HEADER_BYTES, 'header', maxReadBytes);
|
|
364
|
+
const hv = viewOf(header);
|
|
365
|
+
if (hv.getUint32(0, true) !== MAGIC) throw new Error('not a .pikelet file (bad magic)');
|
|
366
|
+
const manifestBytes = hv.getUint32(8, true);
|
|
367
|
+
const identity = [...new Uint8Array(header.buffer, header.byteOffset + 24, 32)]
|
|
368
|
+
.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
369
|
+
const fileBytes = u64(hv, 16, 'header fileBytes');
|
|
370
|
+
const manifestBuf = await readChecked(source, HEADER_BYTES, manifestBytes, 'manifest', maxReadBytes, fileBytes);
|
|
371
|
+
if (await sha256hex(manifestBuf) !== identity) {
|
|
372
|
+
throw new Error('.pikelet manifest failed identity verification');
|
|
373
|
+
}
|
|
374
|
+
const manifest = JSON.parse(decoder.decode(manifestBuf));
|
|
375
|
+
const isLayer = manifest.profile === 'pikelet-layer-v1';
|
|
376
|
+
return {
|
|
377
|
+
identity,
|
|
378
|
+
manifest,
|
|
379
|
+
isLayer,
|
|
380
|
+
parent: isLayer && manifest.layer?.parent ? { ...manifest.layer.parent } : null,
|
|
381
|
+
};
|
|
382
|
+
} finally {
|
|
383
|
+
if (owned && source.close) await source.close();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Validate a segment table against the identity-committed manifest and return
|
|
389
|
+
* kind -> {offset, length, sha256}. The table sits OUTSIDE the identity, so
|
|
390
|
+
* every entry must agree with the manifest entry at the same index: length,
|
|
391
|
+
* digest shape, contiguous 16-aligned placement inside the file, and kind. A
|
|
392
|
+
* known kind appears at most once. Unknown kinds are skipped (spec 3.3), but
|
|
393
|
+
* the manifest must not name them as a kind this reader knows. Shared by the
|
|
394
|
+
* full open and the chain-member shell so neither can drift from the other.
|
|
395
|
+
*/
|
|
396
|
+
function parseSegmentTable(table, manifest, segmentCount, manifestBytes, fileBytes) {
|
|
397
|
+
const tableView = viewOf(table);
|
|
398
|
+
const segments = new Map();
|
|
399
|
+
let expectedOffset = align16(HEADER_BYTES + manifestBytes + segmentCount * TABLE_ENTRY_BYTES);
|
|
400
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
401
|
+
const entry = i * TABLE_ENTRY_BYTES;
|
|
402
|
+
const kindNumber = tableView.getUint32(entry, true);
|
|
403
|
+
const kind = KIND_NAMES[kindNumber];
|
|
404
|
+
const offset = u64(tableView, entry + 8, `segment ${i} offset`);
|
|
405
|
+
const length = u64(tableView, entry + 16, `segment ${i} length`);
|
|
406
|
+
const declared = manifest.segments[i];
|
|
407
|
+
if (!declared || typeof declared !== 'object' || declared.bytes !== length || !isSha256Hex(declared.sha256)
|
|
408
|
+
|| offset !== expectedOffset || !Number.isSafeInteger(offset + length) || offset + length > fileBytes) {
|
|
409
|
+
throw new Error(`.pikelet segment table disagrees with manifest at entry ${i}`);
|
|
410
|
+
}
|
|
411
|
+
if (kind === undefined) {
|
|
412
|
+
if (typeof declared.kind !== 'string' || declared.kind in KINDS) {
|
|
413
|
+
throw new Error(`.pikelet segment table entry ${i} has unknown kind ${kindNumber} but the manifest names it ${declared.kind}`);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
if (declared.kind !== kind) {
|
|
417
|
+
throw new Error(`.pikelet segment table disagrees with manifest at entry ${i}`);
|
|
418
|
+
}
|
|
419
|
+
if (segments.has(kind)) throw new Error(`.pikelet carries more than one ${kind} segment`);
|
|
420
|
+
segments.set(kind, { offset, length, sha256: declared.sha256 });
|
|
421
|
+
}
|
|
422
|
+
expectedOffset = align16(offset + length);
|
|
423
|
+
}
|
|
424
|
+
return segments;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function readChainMemberShell(input, options = {}) {
|
|
428
|
+
const source = typeof input === 'string' ? await fileSource(input) : input;
|
|
429
|
+
const owned = typeof input === 'string';
|
|
430
|
+
const maxReadBytes = resolveBudget(options.maxReadBytes, 'maxReadBytes', DEFAULT_OPEN_READ_BYTES);
|
|
431
|
+
const close = async () => { if (owned && source.close) await source.close(); };
|
|
432
|
+
try {
|
|
433
|
+
const header = await readChecked(source, 0, HEADER_BYTES, 'header', maxReadBytes);
|
|
434
|
+
const hv = viewOf(header);
|
|
435
|
+
if (hv.getUint32(0, true) !== MAGIC) throw new Error('not a .pikelet file (bad magic)');
|
|
436
|
+
const formatVersion = hv.getUint32(4, true);
|
|
437
|
+
const manifestBytes = hv.getUint32(8, true);
|
|
438
|
+
const segmentCount = hv.getUint32(12, true);
|
|
439
|
+
const identity = [...new Uint8Array(header.buffer, header.byteOffset + 24, 32)]
|
|
440
|
+
.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
441
|
+
const fileBytes = u64(hv, 16, 'header fileBytes');
|
|
442
|
+
if (manifestBytes < 2 || manifestBytes > MAX_MANIFEST_BYTES || segmentCount < 1 || segmentCount > MAX_SEGMENTS
|
|
443
|
+
|| HEADER_BYTES + manifestBytes + segmentCount * TABLE_ENTRY_BYTES > fileBytes) {
|
|
444
|
+
throw new Error('.pikelet header is implausible');
|
|
445
|
+
}
|
|
446
|
+
const manifestBuf = await readChecked(source, HEADER_BYTES, manifestBytes, 'manifest', maxReadBytes, fileBytes);
|
|
447
|
+
if (await sha256hex(manifestBuf) !== identity) {
|
|
448
|
+
throw new Error('.pikelet manifest failed identity verification');
|
|
449
|
+
}
|
|
450
|
+
const manifest = JSON.parse(decoder.decode(manifestBuf));
|
|
451
|
+
if (manifest.profile !== 'pikelet-layer-v1' || formatVersion !== 2) {
|
|
452
|
+
return { tombstoneOnly: false, tier: null, close };
|
|
453
|
+
}
|
|
454
|
+
if (manifest.layer?.records !== 0) {
|
|
455
|
+
return { tombstoneOnly: false, tier: null, close };
|
|
456
|
+
}
|
|
457
|
+
if (!Array.isArray(manifest.segments) || manifest.segments.length !== segmentCount) {
|
|
458
|
+
throw new Error('.pikelet manifest segment list disagrees with the header segment count');
|
|
459
|
+
}
|
|
460
|
+
// The same table checks as the full open. This path used to keep only
|
|
461
|
+
// length and digest shape: it never compared the table's kind with the
|
|
462
|
+
// manifest's and let a later duplicate overwrite an earlier one, so
|
|
463
|
+
// whoever controls the table bytes (a mirror) could relabel another
|
|
464
|
+
// committed segment as the tombstone mask without changing identity.
|
|
465
|
+
const table = await readChecked(source, HEADER_BYTES + manifestBytes, segmentCount * TABLE_ENTRY_BYTES, 'segment table', maxReadBytes, fileBytes);
|
|
466
|
+
const segments = parseSegmentTable(table, manifest, segmentCount, manifestBytes, fileBytes);
|
|
467
|
+
for (const required of ['query-interp', 'tombstones']) {
|
|
468
|
+
if (!segments.has(required)) throw new Error(`a tombstone-only layer is missing the ${required} segment`);
|
|
469
|
+
}
|
|
470
|
+
// 4.3: a tombstone-only layer carries no records, so it MUST NOT carry
|
|
471
|
+
// the segments that would hold them.
|
|
472
|
+
for (const forbidden of ['index', 'corpus', 'lexical']) {
|
|
473
|
+
if (segments.has(forbidden)) throw new Error(`a tombstone-only layer must not carry a ${forbidden} segment (4.3)`);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
tombstoneOnly: true,
|
|
477
|
+
tier: {
|
|
478
|
+
manifest, identity, recordCount: 0, dim: manifest.dim,
|
|
479
|
+
sketch: null, lexicalIndex: null, hydrate: null,
|
|
480
|
+
encoderInfo: null, segments,
|
|
481
|
+
async readSegmentBytes(seg) {
|
|
482
|
+
const bytes = await readChecked(source, seg.offset, seg.length, 'chain segment', maxReadBytes, fileBytes);
|
|
483
|
+
if (await sha256hex(bytes) !== seg.sha256) {
|
|
484
|
+
throw new Error('.pikelet chain segment failed hash verification');
|
|
485
|
+
}
|
|
486
|
+
return bytes;
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
close,
|
|
490
|
+
};
|
|
491
|
+
} catch (err) {
|
|
492
|
+
await close().catch(() => {});
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
334
497
|
export async function openPikeletFile(input, options = {}) {
|
|
335
498
|
const source = typeof input === 'string' ? await fileSource(input) : input;
|
|
336
499
|
const owned = typeof input === 'string';
|
|
@@ -354,6 +517,10 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
354
517
|
// Kind-3 lazy encoder: the pending fetch+init promise. close() settles
|
|
355
518
|
// it before disposing so an in-flight prefetch cannot leak its kernel.
|
|
356
519
|
let encoderPending = null;
|
|
520
|
+
// Set by the kind-3 branch below. Hoisted because `append` needs the base's
|
|
521
|
+
// own encoder through the __chainTier seam (6.1), and the branch's own
|
|
522
|
+
// ensureEmbedder is scoped to that branch.
|
|
523
|
+
let resolveEmbedder = null;
|
|
357
524
|
const settleEncoder = async () => {
|
|
358
525
|
if (encoderPending) { try { await encoderPending; } catch { /* surfaced to queries */ } }
|
|
359
526
|
};
|
|
@@ -413,8 +580,15 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
413
580
|
try { manifest = JSON.parse(decoder.decode(manifestBuf)); } catch (err) {
|
|
414
581
|
throw new Error('.pikelet manifest is not valid JSON', { cause: err });
|
|
415
582
|
}
|
|
583
|
+
// 4.1 of the layered profile: a layer carries `pikelet-layer-v1` under
|
|
584
|
+
// formatVersion 2, and a reader MUST refuse it by default — a layer
|
|
585
|
+
// opened alone would silently serve a fraction of a corpus. Only the
|
|
586
|
+
// chain reader, which opens every ancestor and verifies the hash chain,
|
|
587
|
+
// passes asChainMember to accept one.
|
|
588
|
+
const isLayerProfile = manifest?.profile === 'pikelet-layer-v1' && formatVersion === 2;
|
|
589
|
+
const acceptLayer = isLayerProfile && options.asChainMember === true;
|
|
416
590
|
if (!manifest || typeof manifest !== 'object'
|
|
417
|
-
|| (manifest.profile !== profile && manifest.profile !== LEGACY_PROFILES[formatVersion])) {
|
|
591
|
+
|| (manifest.profile !== profile && manifest.profile !== LEGACY_PROFILES[formatVersion] && !acceptLayer)) {
|
|
418
592
|
throw new Error(`unsupported profile ${manifest?.profile} for format version ${formatVersion}`);
|
|
419
593
|
}
|
|
420
594
|
if (!Array.isArray(manifest.segments) || manifest.segments.length !== segmentCount) {
|
|
@@ -429,37 +603,97 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
429
603
|
}
|
|
430
604
|
|
|
431
605
|
const table = await readChecked(source, HEADER_BYTES + manifestBytes, tableBytes, 'segment table', maxReadBytes, fileBytes);
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
throw new Error(`.pikelet segment table disagrees with manifest at entry ${i}`);
|
|
606
|
+
const segments = parseSegmentTable(table, manifest, segmentCount, manifestBytes, fileBytes);
|
|
607
|
+
// The evaluation segment, read and hash-verified. Named so both the
|
|
608
|
+
// reader's own evaluation() and the __chainTier seam can reach it: a
|
|
609
|
+
// chain has no evaluation segment of its own, and `verify_pack` called
|
|
610
|
+
// `search.evaluation()` on a chain reader that had no such method,
|
|
611
|
+
// which threw a TypeError instead of reporting the base's goldens.
|
|
612
|
+
const readEvaluationSegment = async () => {
|
|
613
|
+
const ev = segments.get('evaluation');
|
|
614
|
+
if (!ev) return null;
|
|
615
|
+
const bytes = await readChecked(source, ev.offset, ev.length, 'evaluation segment', maxReadBytes, fileBytes);
|
|
616
|
+
if (await sha256hex(bytes) !== ev.sha256) {
|
|
617
|
+
throw new Error('.pikelet evaluation segment failed hash verification');
|
|
445
618
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
619
|
+
let parsed;
|
|
620
|
+
try { parsed = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
621
|
+
throw new Error('.pikelet evaluation segment is not valid JSON', { cause: err });
|
|
622
|
+
}
|
|
623
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
624
|
+
throw new Error('.pikelet evaluation segment must be a JSON object');
|
|
625
|
+
}
|
|
626
|
+
return parsed;
|
|
627
|
+
};
|
|
628
|
+
// The lineage segment (kind 7, LAYERED_PROFILE.md 6.3), parsed and
|
|
629
|
+
// validated on first use. Present only on a base compacted from a
|
|
630
|
+
// chain. buildLineageSegment wrote it and parseLineageSegment validated
|
|
631
|
+
// it, but nothing on the read side ever reached either: a real
|
|
632
|
+
// compacted pack could not translate a citation, which is the whole
|
|
633
|
+
// point of the segment ("what lets a consumer treat a translated
|
|
634
|
+
// citation as evidence rather than as a hint").
|
|
635
|
+
//
|
|
636
|
+
// Cached because the validation is not free and the segment is
|
|
637
|
+
// immutable; the cache holds the parse, never a translation.
|
|
638
|
+
let lineageCache;
|
|
639
|
+
const readLineageSegment = async () => {
|
|
640
|
+
if (lineageCache !== undefined) return lineageCache;
|
|
641
|
+
const seg = segments.get('lineage');
|
|
642
|
+
if (!seg) { lineageCache = null; return null; }
|
|
643
|
+
const bytes = await readChecked(source, seg.offset, seg.length, 'lineage segment', maxReadBytes, fileBytes);
|
|
644
|
+
if (await sha256hex(bytes) !== seg.sha256) {
|
|
645
|
+
throw new Error('.pikelet lineage segment failed hash verification');
|
|
646
|
+
}
|
|
647
|
+
const { parseLineageSegment } = await import('./layer-compact.mjs');
|
|
648
|
+
// Cross-checked against the manifest: the parser refuses a segment
|
|
649
|
+
// whose last head is not compactedFrom.identity, or whose counts
|
|
650
|
+
// disagree, so a tampered pair fails rather than translating.
|
|
651
|
+
lineageCache = parseLineageSegment(bytes, manifest.compactedFrom || null);
|
|
652
|
+
return lineageCache;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// oldId -> new dense id, by 6.3's rule: a surviving id loses one
|
|
656
|
+
// position per tombstone below it. The per-byte prefix table is built
|
|
657
|
+
// once and reused, because the naive form rescans the bitset from zero
|
|
658
|
+
// on every call -- O(id) per citation, quadratic over a run of them on
|
|
659
|
+
// a large pack.
|
|
660
|
+
let compactPrefix;
|
|
661
|
+
const compactMapFor = (lineage) => {
|
|
662
|
+
if (compactPrefix === undefined) {
|
|
663
|
+
const bytes = lineage.bitset.length;
|
|
664
|
+
compactPrefix = new Uint32Array(bytes + 1);
|
|
665
|
+
for (let b = 0; b < bytes; b++) {
|
|
666
|
+
let n = 0;
|
|
667
|
+
for (let k = 0; k < 8; k++) if ((lineage.bitset[b] >> k) & 1) n++;
|
|
668
|
+
compactPrefix[b + 1] = compactPrefix[b] + n;
|
|
456
669
|
}
|
|
457
|
-
if (segments.has(kind)) throw new Error(`.pikelet carries more than one ${kind} segment`);
|
|
458
|
-
segments.set(kind, { offset, length, sha256: declared.sha256 });
|
|
459
670
|
}
|
|
460
|
-
|
|
671
|
+
return (oldId) => {
|
|
672
|
+
if (oldId < 0 || oldId >= lineage.oldRowTotal) return null;
|
|
673
|
+
if ((lineage.bitset[oldId >> 3] >> (oldId & 7)) & 1) return null;
|
|
674
|
+
const byte = oldId >> 3;
|
|
675
|
+
let below = compactPrefix[byte];
|
|
676
|
+
for (let k = 0; k < (oldId & 7); k++) {
|
|
677
|
+
if ((lineage.bitset[byte] >> k) & 1) below++;
|
|
678
|
+
}
|
|
679
|
+
return oldId - below;
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// Required segments are per-profile (layered profile 4.3). A layer with
|
|
684
|
+
// records carries a tombstones segment on top of the complete set; a
|
|
685
|
+
// TOMBSTONE-ONLY layer has neither an index nor a corpus segment and is
|
|
686
|
+
// therefore not opened through this function at all — the chain reader
|
|
687
|
+
// reads its two segments directly (see openPikeletChain), because
|
|
688
|
+
// threading "no index, no corpus" through the whole of this open would
|
|
689
|
+
// put a branch on every step of the single-file path.
|
|
690
|
+
if (acceptLayer && manifest.layer?.records === 0) {
|
|
691
|
+
throw new Error('a tombstone-only layer is read by the chain reader, not opened as a member');
|
|
461
692
|
}
|
|
462
|
-
|
|
693
|
+
const requiredSegments = acceptLayer
|
|
694
|
+
? ['index', 'corpus', 'query-interp', 'tombstones']
|
|
695
|
+
: ['index', 'corpus', 'query-interp'];
|
|
696
|
+
for (const required of requiredSegments) {
|
|
463
697
|
if (!segments.has(required)) throw new Error(`.pikelet is missing the ${required} segment`);
|
|
464
698
|
}
|
|
465
699
|
|
|
@@ -635,7 +869,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
635
869
|
const retrievalScorer = () => {
|
|
636
870
|
const scorer = createAbstentionScorer(calibrationJson.asset, base64Bytes(calibrationJson.vocabBloomBase64));
|
|
637
871
|
const VERDICTS = { answer: 'strong', weak: 'weak', abstain: 'none' };
|
|
638
|
-
return async (hits, context, fusedHits) => {
|
|
872
|
+
return async (hits, context, fusedHits, scoreOptions) => {
|
|
639
873
|
if (!scorer) return { match_quality: 'unscored' };
|
|
640
874
|
// The coverage term grounds the verdict in the top passages'
|
|
641
875
|
// text, so those records hydrate before scoring. Hydrated
|
|
@@ -665,11 +899,22 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
665
899
|
const nl = String(text || '').indexOf('\n');
|
|
666
900
|
return nl === -1 ? { heading: text, body: '' } : { heading: text.slice(0, nl), body: text.slice(nl + 1) };
|
|
667
901
|
};
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
902
|
+
// A chain supplies the passages itself (context.passages): its
|
|
903
|
+
// ids are GLOBAL, and this hydrate only knows this member's
|
|
904
|
+
// local ids, so letting it fetch them would throw on any id a
|
|
905
|
+
// layer owns. Single-file callers pass none and it hydrates.
|
|
906
|
+
const topTexts = Array.isArray(context.passages)
|
|
907
|
+
? context.passages.slice(0, scorer.passagesNeeded || 1).map(splitHeadingBody)
|
|
908
|
+
: scorer.usesPassage && passageSource.length
|
|
909
|
+
? (await Promise.all(passageSource.slice(0, scorer.passagesNeeded || 1)
|
|
910
|
+
.map((hit) => hydrate(hit.id)))).map((record) => splitHeadingBody(record?.text))
|
|
911
|
+
: [];
|
|
912
|
+
// scoreOptions.vocabBloom is the chain's union bloom (4.5).
|
|
913
|
+
// Absent for a single-file pack, where the fit's own bloom is
|
|
914
|
+
// the whole vocabulary.
|
|
915
|
+
const scored = await scorer.score(context.text, hits, topTexts, {
|
|
916
|
+
vocabBloom: scoreOptions?.vocabBloom,
|
|
917
|
+
});
|
|
673
918
|
return { match_quality: VERDICTS[scored.verdict] || scored.verdict, confidence: scored.p, grounding: scored.grounding || null };
|
|
674
919
|
};
|
|
675
920
|
};
|
|
@@ -765,6 +1010,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
765
1010
|
return { embedder, declaration };
|
|
766
1011
|
};
|
|
767
1012
|
const ensureEmbedder = () => (encoderPending ??= loadEmbedder());
|
|
1013
|
+
resolveEmbedder = ensureEmbedder;
|
|
768
1014
|
// Until the encoder arrives, info() serves the identity-verified
|
|
769
1015
|
// manifest declaration; encoderVerified stays null (unknown, not
|
|
770
1016
|
// unverified) and flips once test vectors have run.
|
|
@@ -787,6 +1033,17 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
787
1033
|
return { vector: toFloat32(vector, dim, 'inline transformer encoder'), text };
|
|
788
1034
|
};
|
|
789
1035
|
scoreQuality = retrievalScorer();
|
|
1036
|
+
} else if (qiKind === 4 && acceptLayer) {
|
|
1037
|
+
// Kind 4, `inherited-v1` (layered profile 4.5): a layer carries a
|
|
1038
|
+
// COMMITMENT to the base's encoder, never encoder bytes. This open
|
|
1039
|
+
// therefore installs no embedder — the chain reader resolves the
|
|
1040
|
+
// encoder from the base it opened and validates the commitment
|
|
1041
|
+
// itself (layer-reader.mjs), and a query on this member alone is
|
|
1042
|
+
// refused rather than answered with a missing encoder.
|
|
1043
|
+
embed = async () => {
|
|
1044
|
+
throw new Error('a layer inherits its encoder from the base: query the chain, not the layer (4.5)');
|
|
1045
|
+
};
|
|
1046
|
+
scoreQuality = null;
|
|
790
1047
|
} else {
|
|
791
1048
|
throw new Error(`unsupported query-interpretation kind ${qiKind}`);
|
|
792
1049
|
}
|
|
@@ -929,16 +1186,13 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
929
1186
|
return record;
|
|
930
1187
|
};
|
|
931
1188
|
|
|
932
|
-
|
|
1189
|
+
// One record's stored bytes, read and (on format 2) verified against
|
|
1190
|
+
// its per-record digest. hydrate() parses these; `compact` (6.3)
|
|
1191
|
+
// copies them verbatim through the chain's __recordBytes seam.
|
|
1192
|
+
const readRecordBytes = async (id) => {
|
|
933
1193
|
if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
|
|
934
1194
|
throw new Error(`.pikelet result id ${id} is outside the corpus`);
|
|
935
1195
|
}
|
|
936
|
-
if (recordCache.has(id)) {
|
|
937
|
-
const cached = recordCache.get(id);
|
|
938
|
-
recordCache.delete(id);
|
|
939
|
-
recordCache.set(id, cached);
|
|
940
|
-
return cached;
|
|
941
|
-
}
|
|
942
1196
|
const start = recordOffsets[id];
|
|
943
1197
|
const end = recordOffsets[id + 1];
|
|
944
1198
|
const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
|
|
@@ -950,6 +1204,20 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
950
1204
|
throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
|
|
951
1205
|
}
|
|
952
1206
|
}
|
|
1207
|
+
return bytes;
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const hydrate = async (id) => {
|
|
1211
|
+
if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
|
|
1212
|
+
throw new Error(`.pikelet result id ${id} is outside the corpus`);
|
|
1213
|
+
}
|
|
1214
|
+
if (recordCache.has(id)) {
|
|
1215
|
+
const cached = recordCache.get(id);
|
|
1216
|
+
recordCache.delete(id);
|
|
1217
|
+
recordCache.set(id, cached);
|
|
1218
|
+
return cached;
|
|
1219
|
+
}
|
|
1220
|
+
const bytes = await readRecordBytes(id);
|
|
953
1221
|
let record;
|
|
954
1222
|
try { record = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
955
1223
|
throw new Error(`.pikelet corpus record ${id} is not valid JSON`, { cause: err });
|
|
@@ -974,6 +1242,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
974
1242
|
identity,
|
|
975
1243
|
formatVersion,
|
|
976
1244
|
profile,
|
|
1245
|
+
// 6.3: present when this base was compacted from a chain,
|
|
1246
|
+
// and the key to whether citation() can say anything. It
|
|
1247
|
+
// was in the manifest all along and reported nowhere, so a
|
|
1248
|
+
// compacted pack looked indistinguishable from a freshly
|
|
1249
|
+
// compiled one.
|
|
1250
|
+
compactedFrom: manifest.compactedFrom
|
|
1251
|
+
&& typeof manifest.compactedFrom === 'object'
|
|
1252
|
+
&& !Array.isArray(manifest.compactedFrom)
|
|
1253
|
+
? { ...manifest.compactedFrom }
|
|
1254
|
+
: null,
|
|
977
1255
|
// Human-readable pack name from the identity-verified
|
|
978
1256
|
// manifest (compile records it under corpus.provenance);
|
|
979
1257
|
// null when the builder recorded none.
|
|
@@ -1215,20 +1493,166 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1215
1493
|
|
|
1216
1494
|
async evaluation() {
|
|
1217
1495
|
assertOpen();
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1496
|
+
return readEvaluationSegment();
|
|
1497
|
+
},
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* 6.3: translate a citation made against any head of the chain this
|
|
1501
|
+
* base was compacted from. Returns null when this pack carries no
|
|
1502
|
+
* lineage segment, i.e. it was not compacted from a chain.
|
|
1503
|
+
*
|
|
1504
|
+
* Forward translation only, and the shape says so: `{ok: true,
|
|
1505
|
+
* newId, via}` for a record that survives, `{ok: true,
|
|
1506
|
+
* deleted: true, ...}` for one that does not, and `{ok: false,
|
|
1507
|
+
* reason}` for an id or identity this history cannot speak to.
|
|
1508
|
+
* It answers what an old id BECAME, never what an old head served
|
|
1509
|
+
* — the old record's bytes are not in this file (see the spec's
|
|
1510
|
+
* "Translation is not dereference").
|
|
1511
|
+
*/
|
|
1512
|
+
async citation(identity, id) {
|
|
1513
|
+
assertOpen();
|
|
1514
|
+
const lineage = await readLineageSegment();
|
|
1515
|
+
if (!lineage) return null;
|
|
1516
|
+
const { translateCitation } = await import('./layer-compact.mjs');
|
|
1517
|
+
// oldId -> new dense id, by the spec's own rule: a live id
|
|
1518
|
+
// loses one position per tombstone below it. Derived from the
|
|
1519
|
+
// segment's bitset rather than passed in, so a caller cannot
|
|
1520
|
+
// supply a mapping that disagrees with the bytes.
|
|
1521
|
+
return translateCitation(lineage, compactMapFor(lineage), identity, id);
|
|
1522
|
+
},
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* The parsed lineage segment, or null. Exposed so a consumer can
|
|
1526
|
+
* enumerate the heads a citation may name without guessing.
|
|
1527
|
+
*/
|
|
1528
|
+
async lineage() {
|
|
1529
|
+
assertOpen();
|
|
1530
|
+
const parsed = await readLineageSegment();
|
|
1531
|
+
if (!parsed) return null;
|
|
1532
|
+
return {
|
|
1533
|
+
heads: parsed.heads.map((h) => ({ ...h })),
|
|
1534
|
+
oldRowTotal: parsed.oldRowTotal,
|
|
1535
|
+
tombstoneCount: parsed.tombstoneCount,
|
|
1536
|
+
supersessions: parsed.supersessions.map((e) => [...e]),
|
|
1537
|
+
};
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Internal seam for the layered profile (LAYERED_PROFILE.md 5.1
|
|
1542
|
+
* step 3): a chain reader mounts each member through this same
|
|
1543
|
+
* open, then needs each search tier's sketch, lexical index and
|
|
1544
|
+
* hydration to merge across tiers (5.3). Exposing them keeps one
|
|
1545
|
+
* container parser rather than a second one — decision 1.
|
|
1546
|
+
*
|
|
1547
|
+
* Deliberately not part of the documented reader API: the names
|
|
1548
|
+
* carry no compatibility promise and a caller outside this package
|
|
1549
|
+
* should use query()/record() instead.
|
|
1550
|
+
*/
|
|
1551
|
+
get __chainTier() {
|
|
1552
|
+
assertOpen();
|
|
1553
|
+
return {
|
|
1554
|
+
manifest,
|
|
1555
|
+
identity,
|
|
1556
|
+
recordCount,
|
|
1557
|
+
dim,
|
|
1558
|
+
sketch,
|
|
1559
|
+
lexicalIndex,
|
|
1560
|
+
hydrate,
|
|
1561
|
+
readRecordBytes,
|
|
1562
|
+
encoderInfo,
|
|
1563
|
+
segments,
|
|
1564
|
+
// The query-interpretation kind, verbatim from the
|
|
1565
|
+
// segment header. `append` needs it to decide
|
|
1566
|
+
// appendability (6.1), and reverse-mapping it from
|
|
1567
|
+
// encoderInfo.kind loses kind 2, whose encoderInfo is
|
|
1568
|
+
// shaped by the host declaration rather than a fixed name.
|
|
1569
|
+
qiKind,
|
|
1570
|
+
// This member's own evaluation segment, hash-verified.
|
|
1571
|
+
// A chain surfaces the base's through it (6.2).
|
|
1572
|
+
evaluation: readEvaluationSegment,
|
|
1573
|
+
// The tier's staged WASM scan kernel, or null before
|
|
1574
|
+
// background staging resolves. A chain queries
|
|
1575
|
+
// sketch.search directly, so without passing this through
|
|
1576
|
+
// every tier fell back to the JS scan: on the 456k-record
|
|
1577
|
+
// wiki base that is ~450 ms a query against ~50 ms with
|
|
1578
|
+
// the kernel. A getter, not a captured value, because
|
|
1579
|
+
// staging completes after open() returns.
|
|
1580
|
+
get scanner() { return scanner; },
|
|
1581
|
+
// The base's OWN encoder, for `append` (6.1): "a kind-3
|
|
1582
|
+
// base carries the teacher, and append loads it from the
|
|
1583
|
+
// base's own query-interp segment — no model download, no
|
|
1584
|
+
// external dependency: a pack is sufficient to compile its
|
|
1585
|
+
// own successors." Resolving it here rather than letting a
|
|
1586
|
+
// producer construct one is what makes substituting a
|
|
1587
|
+
// different encoder impossible.
|
|
1588
|
+
//
|
|
1589
|
+
// Returns null for kinds that cannot embed passages: kind 1
|
|
1590
|
+
// carries a query encoder only (6.1 refuses it), and kind 4
|
|
1591
|
+
// is a layer, which inherits.
|
|
1592
|
+
async passageEmbedder() {
|
|
1593
|
+
assertOpen();
|
|
1594
|
+
if (qiKind !== 3 || !resolveEmbedder) return null;
|
|
1595
|
+
const { embedder, declaration } = await resolveEmbedder();
|
|
1596
|
+
return {
|
|
1597
|
+
declaration,
|
|
1598
|
+
// The passage prefix is part of the contract the
|
|
1599
|
+
// base was built with; applying the wrong one (or
|
|
1600
|
+
// none) silently produces vectors in a different
|
|
1601
|
+
// space from the base's.
|
|
1602
|
+
embed: async (text) => {
|
|
1603
|
+
const prefix = declaration.prefixPolicy?.passage || '';
|
|
1604
|
+
const { vector } = await embedder.embed(`${prefix}${text}`);
|
|
1605
|
+
return vector;
|
|
1606
|
+
},
|
|
1607
|
+
// A refit (6.3 step 4) embeds generated QUERIES,
|
|
1608
|
+
// which take the query prefix, not the passage one:
|
|
1609
|
+
// the asymmetry is the encoder's contract and
|
|
1610
|
+
// applying the wrong side puts the fit's probes in
|
|
1611
|
+
// a different space from the corpus it scores.
|
|
1612
|
+
embedQuery: async (text) => {
|
|
1613
|
+
const prefix = declaration.prefixPolicy?.query || '';
|
|
1614
|
+
const { vector } = await embedder.embed(`${prefix}${text}`);
|
|
1615
|
+
return vector;
|
|
1616
|
+
},
|
|
1617
|
+
// Word-level vectors for the comparison feature.
|
|
1618
|
+
// Unprefixed by design -- a single word is not a
|
|
1619
|
+
// query needing an instruction prefix. Null when
|
|
1620
|
+
// the kernel cannot do it, which the caller reads
|
|
1621
|
+
// as "fit without that feature".
|
|
1622
|
+
embedWords: typeof embedder.embedWords === 'function'
|
|
1623
|
+
? (text) => embedder.embedWords(text)
|
|
1624
|
+
: null,
|
|
1625
|
+
};
|
|
1626
|
+
},
|
|
1627
|
+
// The base's own abstention scorer, for a chain serving
|
|
1628
|
+
// under `inherited` (5.5): "the base's
|
|
1629
|
+
// retrieval-signals-v1 scorer runs unchanged over the
|
|
1630
|
+
// merged searched window and fused passages, with the union
|
|
1631
|
+
// bloom of 4.5". Null when the base carries no fit, which
|
|
1632
|
+
// is what makes a chain report `none` rather than silently
|
|
1633
|
+
// omitting the verdict.
|
|
1634
|
+
scoreQuality: typeof scoreQuality === 'function' ? scoreQuality : null,
|
|
1635
|
+
// The base's fit, for a chain's calibration status (5.5).
|
|
1636
|
+
// It lives in the query-interp segment's calibration
|
|
1637
|
+
// region, not in the manifest — `asset` present means the
|
|
1638
|
+
// base carries a retrieval-signals fit, and
|
|
1639
|
+
// `asset.driftLimit` is the producer's declared validity
|
|
1640
|
+
// envelope when it declared one.
|
|
1641
|
+
calibrationJson,
|
|
1642
|
+
// Raw segment bytes for the chain's structural parsing
|
|
1643
|
+
// (tombstones, kind-4). Bounded and digest-checked exactly
|
|
1644
|
+
// as any other segment read: the digest comes from the
|
|
1645
|
+
// identity-verified manifest, so bytes that fail it never
|
|
1646
|
+
// reach a parser.
|
|
1647
|
+
async readSegmentBytes(seg) {
|
|
1648
|
+
assertOpen();
|
|
1649
|
+
const bytes = await readChecked(source, seg.offset, seg.length, 'chain segment', maxReadBytes, fileBytes);
|
|
1650
|
+
if (await sha256hex(bytes) !== seg.sha256) {
|
|
1651
|
+
throw new Error('.pikelet chain segment failed hash verification');
|
|
1652
|
+
}
|
|
1653
|
+
return bytes;
|
|
1654
|
+
},
|
|
1655
|
+
};
|
|
1232
1656
|
},
|
|
1233
1657
|
|
|
1234
1658
|
async close() {
|