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.
@@ -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 tableView = viewOf(table);
433
- const segments = new Map();
434
- let expectedOffset = align16(HEADER_BYTES + manifestBytes + tableBytes);
435
- for (let i = 0; i < segmentCount; i++) {
436
- const entry = i * TABLE_ENTRY_BYTES;
437
- const kindNumber = tableView.getUint32(entry, true);
438
- const kind = KIND_NAMES[kindNumber];
439
- const offset = u64(tableView, entry + 8, `segment ${i} offset`);
440
- const length = u64(tableView, entry + 16, `segment ${i} length`);
441
- const declared = manifest.segments[i];
442
- if (!declared || typeof declared !== 'object' || declared.bytes !== length || !isSha256Hex(declared.sha256)
443
- || offset !== expectedOffset || !Number.isSafeInteger(offset + length) || offset + length > fileBytes) {
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
- if (kind === undefined) {
447
- // Unknown kinds are skipped, not failed (spec 3.3); they are
448
- // still committed through the manifest, so the declared name
449
- // must at least not claim to be a kind this reader knows.
450
- if (typeof declared.kind !== 'string' || declared.kind in KINDS) {
451
- throw new Error(`.pikelet segment table entry ${i} has unknown kind ${kindNumber} but the manifest names it ${declared.kind}`);
452
- }
453
- } else {
454
- if (declared.kind !== kind) {
455
- throw new Error(`.pikelet segment table disagrees with manifest at entry ${i}`);
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
- expectedOffset = align16(offset + length);
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
- for (const required of ['index', 'corpus', 'query-interp']) {
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
- const topTexts = scorer.usesPassage && passageSource.length
669
- ? (await Promise.all(passageSource.slice(0, scorer.passagesNeeded || 1)
670
- .map((hit) => hydrate(hit.id)))).map((record) => splitHeadingBody(record?.text))
671
- : [];
672
- const scored = await scorer.score(context.text, hits, topTexts);
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
- const hydrate = async (id) => {
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
- const ev = segments.get('evaluation');
1219
- if (!ev) return null;
1220
- const bytes = await readChecked(source, ev.offset, ev.length, 'evaluation segment', maxReadBytes, fileBytes);
1221
- if (await sha256hex(bytes) !== ev.sha256) {
1222
- throw new Error('.pikelet evaluation segment failed hash verification');
1223
- }
1224
- let parsed;
1225
- try { parsed = JSON.parse(decoder.decode(bytes)); } catch (err) {
1226
- throw new Error('.pikelet evaluation segment is not valid JSON', { cause: err });
1227
- }
1228
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1229
- throw new Error('.pikelet evaluation segment must be a JSON object');
1230
- }
1231
- return parsed;
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() {