pikelet-wasm 0.8.0 → 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 +399 -2
- package/README.md +81 -257
- package/complete/builder.mjs +35 -5
- package/complete/format.mjs +6 -1
- package/complete/fusion.mjs +72 -0
- package/complete/index.d.ts +23 -0
- package/complete/index.mjs +580 -76
- package/complete/inline-transformer.mjs +97 -1
- 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 +132 -28
- package/complete/sources.mjs +1 -1
- package/complete/tombstones.mjs +281 -0
- package/complete/wordpiece.mjs +22 -0
- 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 +65 -77
- package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
- package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
- package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +97 -8
- package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
- package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
- package/{pikelet.js → src/pikelet.js} +7 -7
- package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
- package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
- package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
- /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
- /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
- /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
- /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
- /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
package/complete/index.mjs
CHANGED
|
@@ -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 { PikeletSketchArtifact } from '../pikelet-artifact.js';
|
|
28
|
+
import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
|
|
29
29
|
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
30
30
|
|
|
31
31
|
import {
|
|
@@ -41,6 +41,8 @@ export { httpRangeSource } from './sources.mjs';
|
|
|
41
41
|
// in-memory lexical searcher over the segment bytes already assembled by
|
|
42
42
|
// buildLexicalSegment, before the artifact is written to disk.
|
|
43
43
|
export { openLexicalIndex } from './lexical.mjs';
|
|
44
|
+
export { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
|
|
45
|
+
import { fuseCandidates } from './fusion.mjs';
|
|
44
46
|
export {
|
|
45
47
|
KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
46
48
|
createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
|
|
@@ -75,16 +77,28 @@ const RECORD_CACHE = 256;
|
|
|
75
77
|
// its fixed-size header is what format 2 commits to in the manifest.
|
|
76
78
|
const SKETCH_HEADER_BYTES = 256;
|
|
77
79
|
// Hybrid retrieval: BM25 candidates fetched from the lexical segment per
|
|
78
|
-
// query
|
|
79
|
-
|
|
80
|
-
const
|
|
80
|
+
// query; the fusion rule and its constants live in fusion.mjs, shared with
|
|
81
|
+
// the calibrator so fit-time rankings match what is served.
|
|
82
|
+
export const LEXICAL_CANDIDATES = 24;
|
|
83
|
+
// scoreQuality's coverage term hydrates the fused top passagesNeeded
|
|
84
|
+
// records (asset.coverage.topK, 5 by default — see calibrate.mjs's
|
|
85
|
+
// COVERAGE_TOP_PASSAGES) once retrieval returns. The scorer that knows
|
|
86
|
+
// the real number is created inside retrievalScorer()'s own closure, not
|
|
87
|
+
// reachable from here, so this is a fixed approximation used to decide
|
|
88
|
+
// how many of fusedFull's records to include in the batched hydration
|
|
89
|
+
// below, alongside phrase-pinning's own candidates — hydrating a couple
|
|
90
|
+
// more than coverage strictly needs just costs a little extra bandwidth
|
|
91
|
+
// on a cache entry that goes unread; hydrating fewer only narrows how
|
|
92
|
+
// much of coverage's later hydration hits warm cache. Either way is
|
|
93
|
+
// harmless: it's a batch-sizing choice, not a correctness one.
|
|
94
|
+
const COVERAGE_BATCH_COUNT = 5;
|
|
81
95
|
// Only BM25 hits within this fraction of the top lexical score join fusion.
|
|
82
96
|
// Common query terms give every matching document a near-tied score (idf
|
|
83
97
|
// collapses them flat); a wide cutoff let that tied mass ride into fusion on
|
|
84
98
|
// a technicality. Kept identical to pikelet/src/calibrate.mjs's
|
|
85
99
|
// LEXICAL_CUTOFF so calibration is fit against the same fusion the reader
|
|
86
100
|
// actually serves.
|
|
87
|
-
const LEXICAL_CUTOFF = 1.5;
|
|
101
|
+
export const LEXICAL_CUTOFF = 1.5;
|
|
88
102
|
// Query-interpretation segments above this size open lazily (kind 3's
|
|
89
103
|
// inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
|
|
90
104
|
const LAZY_QI_BYTES = 4 * 1024 * 1024;
|
|
@@ -125,13 +139,23 @@ function toHex(bytes) {
|
|
|
125
139
|
const isSha256Hex = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
126
140
|
|
|
127
141
|
// SHA-256 via WebCrypto (browser, workerd, Node 18+); node:crypto fallback
|
|
128
|
-
// for older Node
|
|
142
|
+
// for older Node, gated on an actual Node runtime check rather than just
|
|
143
|
+
// "WebCrypto is missing" — a browser can lack crypto.subtle too (privacy
|
|
144
|
+
// settings, a hardened build), and a bare dynamic import('node:crypto')
|
|
145
|
+
// there isn't a Node module resolution failure, it's a cross-origin module
|
|
146
|
+
// fetch a browser refuses outright, surfacing as an opaque module-loading
|
|
147
|
+
// error instead of the clear message below. Fails closed either way:
|
|
148
|
+
// verification is not optional in this reader.
|
|
129
149
|
async function sha256hex(bytes) {
|
|
130
150
|
if (globalThis.crypto?.subtle) {
|
|
131
151
|
return toHex(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)));
|
|
132
152
|
}
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
154
|
+
const { createHash } = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node:crypto');
|
|
155
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
156
|
+
}
|
|
157
|
+
throw new Error('No crypto backend available (crypto.subtle is missing) — '
|
|
158
|
+
+ 'cannot verify artifact integrity in this environment');
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
function asUint8Array(bytes) {
|
|
@@ -258,7 +282,7 @@ function toFloat32(vector, dim, what) {
|
|
|
258
282
|
return v;
|
|
259
283
|
}
|
|
260
284
|
|
|
261
|
-
function base64Bytes(text) {
|
|
285
|
+
export function base64Bytes(text) {
|
|
262
286
|
if (typeof text !== 'string') return new Uint8Array(0);
|
|
263
287
|
return typeof Buffer !== 'undefined'
|
|
264
288
|
? Buffer.from(text, 'base64')
|
|
@@ -307,6 +331,169 @@ export async function verifyHostEncoder(declaration, encodeQuery, dim) {
|
|
|
307
331
|
* { read(offset, length), size? } range source (any runtime).
|
|
308
332
|
* Returns { query(text, {k}), info(), evaluation(), close() }.
|
|
309
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
|
+
|
|
310
497
|
export async function openPikeletFile(input, options = {}) {
|
|
311
498
|
const source = typeof input === 'string' ? await fileSource(input) : input;
|
|
312
499
|
const owned = typeof input === 'string';
|
|
@@ -330,6 +517,10 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
330
517
|
// Kind-3 lazy encoder: the pending fetch+init promise. close() settles
|
|
331
518
|
// it before disposing so an in-flight prefetch cannot leak its kernel.
|
|
332
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;
|
|
333
524
|
const settleEncoder = async () => {
|
|
334
525
|
if (encoderPending) { try { await encoderPending; } catch { /* surfaced to queries */ } }
|
|
335
526
|
};
|
|
@@ -389,8 +580,15 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
389
580
|
try { manifest = JSON.parse(decoder.decode(manifestBuf)); } catch (err) {
|
|
390
581
|
throw new Error('.pikelet manifest is not valid JSON', { cause: err });
|
|
391
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;
|
|
392
590
|
if (!manifest || typeof manifest !== 'object'
|
|
393
|
-
|| (manifest.profile !== profile && manifest.profile !== LEGACY_PROFILES[formatVersion])) {
|
|
591
|
+
|| (manifest.profile !== profile && manifest.profile !== LEGACY_PROFILES[formatVersion] && !acceptLayer)) {
|
|
394
592
|
throw new Error(`unsupported profile ${manifest?.profile} for format version ${formatVersion}`);
|
|
395
593
|
}
|
|
396
594
|
if (!Array.isArray(manifest.segments) || manifest.segments.length !== segmentCount) {
|
|
@@ -405,37 +603,97 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
405
603
|
}
|
|
406
604
|
|
|
407
605
|
const table = await readChecked(source, HEADER_BYTES + manifestBytes, tableBytes, 'segment table', maxReadBytes, fileBytes);
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
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');
|
|
421
618
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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;
|
|
432
669
|
}
|
|
433
|
-
if (segments.has(kind)) throw new Error(`.pikelet carries more than one ${kind} segment`);
|
|
434
|
-
segments.set(kind, { offset, length, sha256: declared.sha256 });
|
|
435
670
|
}
|
|
436
|
-
|
|
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');
|
|
437
692
|
}
|
|
438
|
-
|
|
693
|
+
const requiredSegments = acceptLayer
|
|
694
|
+
? ['index', 'corpus', 'query-interp', 'tombstones']
|
|
695
|
+
: ['index', 'corpus', 'query-interp'];
|
|
696
|
+
for (const required of requiredSegments) {
|
|
439
697
|
if (!segments.has(required)) throw new Error(`.pikelet is missing the ${required} segment`);
|
|
440
698
|
}
|
|
441
699
|
|
|
@@ -611,7 +869,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
611
869
|
const retrievalScorer = () => {
|
|
612
870
|
const scorer = createAbstentionScorer(calibrationJson.asset, base64Bytes(calibrationJson.vocabBloomBase64));
|
|
613
871
|
const VERDICTS = { answer: 'strong', weak: 'weak', abstain: 'none' };
|
|
614
|
-
return async (hits, context, fusedHits) => {
|
|
872
|
+
return async (hits, context, fusedHits, scoreOptions) => {
|
|
615
873
|
if (!scorer) return { match_quality: 'unscored' };
|
|
616
874
|
// The coverage term grounds the verdict in the top passages'
|
|
617
875
|
// text, so those records hydrate before scoring. Hydrated
|
|
@@ -628,12 +886,36 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
628
886
|
// record that fails its digest must fail the query, not
|
|
629
887
|
// skew its verdict.
|
|
630
888
|
const passageSource = fusedHits?.length ? fusedHits : hits;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
889
|
+
// A record's text starts with its own heading echoed as the
|
|
890
|
+
// first line (ingest.mjs's section-to-chunk join). A
|
|
891
|
+
// heading match still grounds the query but at reduced
|
|
892
|
+
// credit — see retrieval-abstention.mjs's coverageFrac and
|
|
893
|
+
// calibrate.mjs's HEADING_COVERAGE_WEIGHT, which this split
|
|
894
|
+
// must match exactly (build-time and serve-time coverage
|
|
895
|
+
// must agree, or the fitted thresholds are scoring a
|
|
896
|
+
// different signal than the one they were calibrated
|
|
897
|
+
// against).
|
|
898
|
+
const splitHeadingBody = (text) => {
|
|
899
|
+
const nl = String(text || '').indexOf('\n');
|
|
900
|
+
return nl === -1 ? { heading: text, body: '' } : { heading: text.slice(0, nl), body: text.slice(nl + 1) };
|
|
901
|
+
};
|
|
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
|
+
});
|
|
918
|
+
return { match_quality: VERDICTS[scored.verdict] || scored.verdict, confidence: scored.p, grounding: scored.grounding || null };
|
|
637
919
|
};
|
|
638
920
|
};
|
|
639
921
|
if (qiKind === 1) {
|
|
@@ -695,7 +977,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
695
977
|
} catch (err) {
|
|
696
978
|
throw new Error('kind-3 artifact requires the inline-transformer kernels at '
|
|
697
979
|
+ 'complete/encoder-kernels/ (pikelet-wasm/complete); run '
|
|
698
|
-
+ 'examples/
|
|
980
|
+
+ 'examples/one-file-search/encoder-spike/build-encoder.sh to rebuild them',
|
|
699
981
|
{ cause: err });
|
|
700
982
|
}
|
|
701
983
|
}
|
|
@@ -728,6 +1010,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
728
1010
|
return { embedder, declaration };
|
|
729
1011
|
};
|
|
730
1012
|
const ensureEmbedder = () => (encoderPending ??= loadEmbedder());
|
|
1013
|
+
resolveEmbedder = ensureEmbedder;
|
|
731
1014
|
// Until the encoder arrives, info() serves the identity-verified
|
|
732
1015
|
// manifest declaration; encoderVerified stays null (unknown, not
|
|
733
1016
|
// unverified) and flips once test vectors have run.
|
|
@@ -750,6 +1033,17 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
750
1033
|
return { vector: toFloat32(vector, dim, 'inline transformer encoder'), text };
|
|
751
1034
|
};
|
|
752
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;
|
|
753
1047
|
} else {
|
|
754
1048
|
throw new Error(`unsupported query-interpretation kind ${qiKind}`);
|
|
755
1049
|
}
|
|
@@ -813,7 +1107,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
813
1107
|
// Hidden from bundlers like the node encoder kernels: the
|
|
814
1108
|
// web entrypoint's ?url wasm imports would break a browser
|
|
815
1109
|
// build that statically pulled this in.
|
|
816
|
-
const { default: Pikelet } = await import(/* webpackIgnore: true */ /* @vite-ignore */ '../pikelet.node.mjs');
|
|
1110
|
+
const { default: Pikelet } = await import(/* webpackIgnore: true */ /* @vite-ignore */ '../src/pikelet.node.mjs');
|
|
817
1111
|
await sketch.fullyResident;
|
|
818
1112
|
const created = await Pikelet.createSketchScanner(sketch, {
|
|
819
1113
|
maxRerank: Math.min(sketch.count, SCANNER_MAX_RERANK),
|
|
@@ -892,16 +1186,13 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
892
1186
|
return record;
|
|
893
1187
|
};
|
|
894
1188
|
|
|
895
|
-
|
|
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) => {
|
|
896
1193
|
if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
|
|
897
1194
|
throw new Error(`.pikelet result id ${id} is outside the corpus`);
|
|
898
1195
|
}
|
|
899
|
-
if (recordCache.has(id)) {
|
|
900
|
-
const cached = recordCache.get(id);
|
|
901
|
-
recordCache.delete(id);
|
|
902
|
-
recordCache.set(id, cached);
|
|
903
|
-
return cached;
|
|
904
|
-
}
|
|
905
1196
|
const start = recordOffsets[id];
|
|
906
1197
|
const end = recordOffsets[id + 1];
|
|
907
1198
|
const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
|
|
@@ -913,6 +1204,20 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
913
1204
|
throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
|
|
914
1205
|
}
|
|
915
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);
|
|
916
1221
|
let record;
|
|
917
1222
|
try { record = JSON.parse(decoder.decode(bytes)); } catch (err) {
|
|
918
1223
|
throw new Error(`.pikelet corpus record ${id} is not valid JSON`, { cause: err });
|
|
@@ -937,6 +1242,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
937
1242
|
identity,
|
|
938
1243
|
formatVersion,
|
|
939
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,
|
|
940
1255
|
// Human-readable pack name from the identity-verified
|
|
941
1256
|
// manifest (compile records it under corpus.provenance);
|
|
942
1257
|
// null when the builder recorded none.
|
|
@@ -1076,13 +1391,51 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1076
1391
|
fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
|
|
1077
1392
|
} else if (retrieval !== 'augmented' && lexicalHits.length) {
|
|
1078
1393
|
const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
|
|
1079
|
-
fusedFull = searched
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1394
|
+
fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
|
|
1395
|
+
// One batched hydration for everything this block and
|
|
1396
|
+
// scoreQuality's coverage term (below) will need, in
|
|
1397
|
+
// place of two separate staged fetches (phrase-pin's
|
|
1398
|
+
// own, then a later cold start for coverage): over a
|
|
1399
|
+
// remote range-read source each hydrate() a caller
|
|
1400
|
+
// hasn't already paid for is its own network round
|
|
1401
|
+
// trip, and sequential/staged awaits pay that cost
|
|
1402
|
+
// once per stage instead of once per query. The
|
|
1403
|
+
// union of phrase-pin's candidates (top lexical
|
|
1404
|
+
// hits) and coverage's (fusedFull's top few — phrase
|
|
1405
|
+
// pinning only ever reorders that window, never
|
|
1406
|
+
// changes its membership) is known right here, so
|
|
1407
|
+
// both stages can share one round trip. A record
|
|
1408
|
+
// that fails to hydrate here does not fail the
|
|
1409
|
+
// query: the id is just missing from the map, and
|
|
1410
|
+
// each site below falls through exactly as it would
|
|
1411
|
+
// on a cold cache miss (recs[i] undefined; coverage
|
|
1412
|
+
// sees fewer usable passages) — hydrate() raising
|
|
1413
|
+
// for a bad id is still a real failure, but only
|
|
1414
|
+
// once a site actually needs that specific record,
|
|
1415
|
+
// not speculatively for every candidate touched here.
|
|
1416
|
+
const phraseCandidates = lexicalHits.slice(0, 3);
|
|
1417
|
+
const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
|
|
1418
|
+
const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
|
|
1419
|
+
const batch = new Map();
|
|
1420
|
+
await Promise.all(batchIds.map(async (id) => {
|
|
1421
|
+
try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
|
|
1422
|
+
}));
|
|
1423
|
+
// Phrase pinning: a verbatim occurrence of the query (>= 3
|
|
1424
|
+
// content words) in one of the top lexical hits is the
|
|
1425
|
+
// strongest evidence of support there is; rank it first.
|
|
1426
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1427
|
+
const phrase = norm(trimmed);
|
|
1428
|
+
if (phrase.split(' ').length >= 3) {
|
|
1429
|
+
for (const h of phraseCandidates) {
|
|
1430
|
+
if (norm(batch.get(h.id)?.text).includes(phrase)) {
|
|
1431
|
+
fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
// Rank transparency: a consumer can see a verbatim hit sitting
|
|
1437
|
+
// at fused rank 3 behind two semantic near-misses.
|
|
1438
|
+
for (const [i, hit] of fusedFull.entries()) { hit.fusedRank = i + 1; hit.lexicalRank = lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null; hit.vectorRank = searched.findIndex((x) => x.id === hit.id) + 1; }
|
|
1086
1439
|
}
|
|
1087
1440
|
fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
|
|
1088
1441
|
}
|
|
@@ -1111,9 +1464,14 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1111
1464
|
// `distance` field cannot overwrite them (reserved names).
|
|
1112
1465
|
const results = await Promise.all(returned.map(async (hit) => {
|
|
1113
1466
|
const record = await hydrate(hit.id);
|
|
1114
|
-
return { ...record, id: hit.id, distance: hit.distance };
|
|
1467
|
+
return { ...record, id: hit.id, distance: hit.distance, ...(hit.fusedRank ? { fusedRank: hit.fusedRank, lexicalRank: hit.lexicalRank, vectorRank: hit.vectorRank } : {}) };
|
|
1115
1468
|
}));
|
|
1116
|
-
|
|
1469
|
+
// Grounding transparency: which record's text grounded the verdict
|
|
1470
|
+
// and which query words it covered, so a consumer can tell a
|
|
1471
|
+
// topical match ("much money darcy make year") from an answer.
|
|
1472
|
+
const passageOrder = fusedFull?.length ? fusedFull : (searched ?? hits);
|
|
1473
|
+
const grounding = quality.grounding ? { ...quality.grounding, recordId: passageOrder[quality.grounding.passageIndex]?.id ?? null } : null;
|
|
1474
|
+
return { matchQuality: quality.match_quality, confidence: quality.confidence, results, ...(grounding ? { grounding } : {}) };
|
|
1117
1475
|
},
|
|
1118
1476
|
|
|
1119
1477
|
/** Hydrate one corpus record by id (verified per record on format 2). */
|
|
@@ -1135,20 +1493,166 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1135
1493
|
|
|
1136
1494
|
async evaluation() {
|
|
1137
1495
|
assertOpen();
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
+
};
|
|
1152
1656
|
},
|
|
1153
1657
|
|
|
1154
1658
|
async close() {
|