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.
Files changed (40) hide show
  1. package/CHANGELOG.md +399 -2
  2. package/README.md +81 -257
  3. package/complete/builder.mjs +35 -5
  4. package/complete/format.mjs +6 -1
  5. package/complete/fusion.mjs +72 -0
  6. package/complete/index.d.ts +23 -0
  7. package/complete/index.mjs +580 -76
  8. package/complete/inline-transformer.mjs +97 -1
  9. package/complete/layer-append.mjs +246 -0
  10. package/complete/layer-chain.mjs +284 -0
  11. package/complete/layer-compact.mjs +319 -0
  12. package/complete/layer-encoder.mjs +223 -0
  13. package/complete/layer-locator.mjs +228 -0
  14. package/complete/layer-manifest.mjs +271 -0
  15. package/complete/layer-reader.mjs +759 -0
  16. package/complete/layer-rebase.mjs +218 -0
  17. package/complete/lexical.mjs +78 -8
  18. package/complete/retrieval-abstention.mjs +132 -28
  19. package/complete/sources.mjs +1 -1
  20. package/complete/tombstones.mjs +281 -0
  21. package/complete/wordpiece.mjs +22 -0
  22. package/dist/engine.js +1 -1
  23. package/dist/engine.scalar.js +1 -1
  24. package/dist/engine.scalar.wasm +0 -0
  25. package/dist/engine.wasm +0 -0
  26. package/package.json +65 -77
  27. package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
  28. package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
  29. package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +97 -8
  30. package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
  31. package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
  32. package/{pikelet.js → src/pikelet.js} +7 -7
  33. package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
  34. package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
  35. package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
  36. /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
  37. /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
  38. /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
  39. /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
  40. /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
@@ -0,0 +1,759 @@
1
+ // The chain reader — LAYERED_PROFILE.md 5.1, 5.2, 5.3, 5.6.
2
+ //
3
+ // openPikeletChain() mounts a base plus zero or more layers and serves them as
4
+ // one corpus. It does NOT reimplement the container: every member is opened
5
+ // through openPikeletFile(), which is decision 1 — "a reader that implements the
6
+ // complete profile implements this one by adding the chain logic of section 5,
7
+ // not a second container parser."
8
+ //
9
+ // What this file adds on top of those opens:
10
+ // - chain resolution and ancestry verification (5.1 steps 1-2, 3.2, 3.3)
11
+ // - the interval table, per-tier masks and chain state (5.1 step 4)
12
+ // - a query() that generates candidates per search tier with that tier's
13
+ // mask supplied to the scan, merges by distance, and fuses once (5.3)
14
+ // - record()/citation resolution over global ids (5.6)
15
+ //
16
+ // The caller may supply every member base-first, or just the head and let the
17
+ // reader walk up to the base through 5.1.1's parent resolution: a caller-
18
+ // supplied lineage map (section 7), then each layer's confined
19
+ // `parent.locator`, then a host resolver. The lineage map is the cheaper path
20
+ // when available, since every location is known up front and members open in
21
+ // one wave; the locator walk is sequential by nature, because each hop's
22
+ // location is only known once its child's manifest has been read.
23
+ //
24
+ // This comment used to claim locator following "is wired". It was not: this
25
+ // module did not import layer-locator.mjs at all, so every ancestor had to be
26
+ // named by the caller and a published chain could not be mounted from its head.
27
+
28
+ import { openPikeletFile, readChainMemberShell, readMemberManifest, base64Bytes, LEXICAL_CANDIDATES, LEXICAL_CUTOFF } from './index.mjs';
29
+ import { resolveParentLocation } from './layer-locator.mjs';
30
+ import { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
31
+ import { parseTombstoneSegment, firstSupersetViolation } from './tombstones.mjs';
32
+ import { validateAgainstParent, chainIngestDeclaration, validateIngestAgainstChain, LAYER_PROFILE, MAX_DEPTH } from './layer-manifest.mjs';
33
+ import { parseInheritedQuerySegment, unionBloom } from './layer-encoder.mjs';
34
+ import {
35
+ buildIntervalTable, ownerOf, projectMasks, liveCount, buildSupersessionMap,
36
+ resolveSupersession, calibrationDrift, calibrationStatus, globalLexicalStats,
37
+ buildAncestry, resolveCitation,
38
+ } from './layer-chain.mjs';
39
+
40
+ // 5.9's budgets, provisional: "chosen for round-trip count and plausibility,
41
+ // not measured" (13.5). Named here so a host can lower them and so a refusal
42
+ // can name the budget it hit.
43
+ export const CHAIN_DEFAULTS = Object.freeze({
44
+ maxDepth: MAX_DEPTH,
45
+ // 5.5: the reader's limit MUST be finite and MUST be declared. 13.1
46
+ // measured the inherited fit out to 40% drift with no degradation, so 0.20
47
+ // is conservative rather than a guess — but it stays a reader policy, not a
48
+ // format constant, so it can be revisited without a format change.
49
+ readerDriftLimit: 0.20,
50
+ });
51
+
52
+ /**
53
+ * @param {Array<string|object>} members head-last or base-first list of
54
+ * locations/sources. Base first is the natural order and what this accepts:
55
+ * [base, layer1, layer2, ...]. Every member is opened through
56
+ * openPikeletFile(), so a string is a path and an object is a range source.
57
+ * @param {object} [options] forwarded to each member's open, plus:
58
+ * readerDriftLimit, maxDepth.
59
+ */
60
+ /**
61
+ * 5.1.1: walk from a head to its base, resolving each parent by lineage
62
+ * listing, then confined locator, then host resolver. Returns the members
63
+ * base-first, which is what openPikeletChain consumes.
64
+ *
65
+ * Sequential by necessity: a hop's location comes from its child's manifest,
66
+ * so nothing below the head is known until the head is read. Bounded by
67
+ * maxDepth so a manifest cycle or a hostile chain of locators cannot walk
68
+ * forever, and every hop's identity is checked against what its child named
69
+ * before that member is accepted -- a locator that resolves to the wrong
70
+ * artifact fails here rather than at the pairwise check later.
71
+ */
72
+ async function walkToBase(head, options) {
73
+ const maxDepth = options.maxDepth ?? CHAIN_DEFAULTS.maxDepth;
74
+ const lineage = options.lineage instanceof Map ? options.lineage : null;
75
+ const chain = [head];
76
+ let cursor = head;
77
+ let expectIdentity = null;
78
+ for (let hop = 0; hop <= maxDepth; hop++) {
79
+ const peek = await readMemberManifest(cursor, options);
80
+ if (expectIdentity !== null && peek.identity !== expectIdentity) {
81
+ throw new Error(`chain member at ${describeLocation(cursor)} has identity ${peek.identity.slice(0, 12)}… `
82
+ + `but its child named ${expectIdentity.slice(0, 12)}…: the located artifact is not the parent the chain commits to (3.2)`);
83
+ }
84
+ if (!peek.isLayer) return chain; // reached the base
85
+ if (!peek.parent) throw new Error('a layer manifest carries no layer.parent (3.2)');
86
+ const located = await resolveParentLocation(peek.parent, {
87
+ lineage,
88
+ childLocation: typeof cursor === 'string' ? cursor : (cursor.url ?? cursor.location ?? null),
89
+ kind: typeof cursor === 'string' ? 'file' : 'url',
90
+ resolveParents: options.resolveParents !== false,
91
+ hostResolver: options.hostResolver || null,
92
+ // A file-path locator needs realpath/dirname/join to confine
93
+ // against the child's REAL directory (symlink escapes are the
94
+ // point of 5.1.1's "real path" wording). The locator module stays
95
+ // runtime-agnostic, so the Node implementation is supplied here
96
+ // and only when a file path is actually being resolved -- a
97
+ // browser or Worker mount is URL-only and never loads node:fs.
98
+ fsops: options.fsops || (typeof cursor === 'string' ? await nodeFsops() : null),
99
+ });
100
+ expectIdentity = peek.parent.identity;
101
+ cursor = located.location;
102
+ chain.unshift(cursor);
103
+ }
104
+ throw new Error(`walking to the base exceeded the depth limit ${maxDepth} (2.7): a layer.parent cycle or an over-deep chain`);
105
+ }
106
+
107
+ const describeLocation = (loc) => (typeof loc === 'string' ? loc : (loc?.url ?? loc?.location ?? '<source>'));
108
+
109
+ // Lazily imported so a URL-only mount in a runtime without node:fs never
110
+ // touches it. Cached: the walk resolves one hop at a time and would otherwise
111
+ // re-import per hop.
112
+ let fsopsPromise = null;
113
+ const nodeFsops = () => {
114
+ fsopsPromise ??= (async () => {
115
+ const [fsp, path] = await Promise.all([import('node:fs/promises'), import('node:path')]);
116
+ return {
117
+ realpath: (p) => fsp.realpath(p),
118
+ dirname: (p) => path.dirname(p),
119
+ join: (a, b) => path.join(a, b),
120
+ sep: path.sep,
121
+ };
122
+ })();
123
+ return fsopsPromise;
124
+ };
125
+
126
+ export async function openPikeletChain(members, options = {}) {
127
+ if (!Array.isArray(members) || members.length === 0) {
128
+ throw new Error('openPikeletChain() needs at least a base');
129
+ }
130
+ // A single member that turns out to be a layer is a head to walk up from,
131
+ // not an error: 5.1.1 exists so a published chain can be mounted by naming
132
+ // its head. A single base is just a base, and the walk returns it as-is.
133
+ if (members.length === 1 && options.followParents !== false) {
134
+ const peek = await readMemberManifest(members[0], options);
135
+ if (peek.isLayer) members = await walkToBase(members[0], options);
136
+ }
137
+ const maxDepth = options.maxDepth ?? CHAIN_DEFAULTS.maxDepth;
138
+ if (members.length - 1 > maxDepth) {
139
+ throw new Error(`chain depth ${members.length - 1} exceeds the limit ${maxDepth} (2.7)`);
140
+ }
141
+ const readerDriftLimit = options.readerDriftLimit ?? CHAIN_DEFAULTS.readerDriftLimit;
142
+
143
+ const opened = [];
144
+ const closeAll = async () => {
145
+ for (const r of opened) { try { await r.close(); } catch { /* best effort */ } }
146
+ };
147
+
148
+ try {
149
+ // --- 5.1 steps 1-2: open every member, verify the chain links ---
150
+ // asChainMember lets openPikeletFile accept a `pikelet-layer-v1`
151
+ // manifest. Only this function passes it, and only because it goes on
152
+ // to open every ancestor and verify the hash chain — a layer opened
153
+ // alone still fails, which is what 4.1 requires.
154
+ //
155
+ // A tombstone-only layer has no index, corpus or lexical segment, so it
156
+ // is read as a "shell": manifest plus its two segments, verified the
157
+ // same way, with none of the corpus/sketch machinery a search tier
158
+ // needs. It is a chain member but not a search tier (3.1).
159
+ const tiersRaw = [];
160
+ for (let i = 0; i < members.length; i++) {
161
+ const shell = i > 0 ? await readChainMemberShell(members[i], options) : null;
162
+ if (shell && shell.tombstoneOnly) {
163
+ opened.push(shell);
164
+ tiersRaw.push(shell.tier);
165
+ continue;
166
+ }
167
+ if (shell) await shell.close();
168
+ const reader = await openPikeletFile(members[i], { ...options, asChainMember: i > 0 });
169
+ opened.push(reader);
170
+ tiersRaw.push(reader.__chainTier);
171
+ }
172
+ const base = tiersRaw[0];
173
+ if (base.manifest.profile === LAYER_PROFILE) {
174
+ throw new Error('the first chain member is a layer, not a base: a layer opened alone would serve a fraction of a corpus (4.1)');
175
+ }
176
+
177
+ const chainMembers = [{
178
+ depth: 0, identity: base.identity, rowBase: 0, records: base.recordCount,
179
+ }];
180
+ // Ingestion declaration: defined by the base, or asserted by depth 1.
181
+ const depth1Manifest = tiersRaw.length > 1 ? tiersRaw[1].manifest : null;
182
+ const ingestDecl = tiersRaw.length > 1 ? chainIngestDeclaration(base.manifest, depth1Manifest) : null;
183
+
184
+ const perLayerTombstones = [];
185
+ let headBitset = new Uint8Array(0);
186
+ const layerBlooms = [];
187
+ let everyLayerShipsBloom = true;
188
+ // 4.5: every layer's bloom MUST use the base asset's geometry, so the
189
+ // union is an exact OR. The geometry is right here in the base's fit;
190
+ // it used to come only from options.baseBloom, which no caller set, so
191
+ // the check never ran and a layer with foreign hashes was OR'd in.
192
+ const baseVocabBloom = base.calibrationJson?.asset?.vocabBloom;
193
+ const baseBloomGeometry = options.baseBloom
194
+ || (baseVocabBloom ? { bits: baseVocabBloom.bits, hashes: baseVocabBloom.hashes } : null);
195
+ // 4.3: lexical is REQUIRED on a layer with records if the base carries
196
+ // one, else MUST be absent. A layer that drops it leaves its records
197
+ // invisible to BM25 and skews the global statistics; one that adds it
198
+ // introduces lexical retrieval the base's fit never saw.
199
+ const baseHasLexical = base.segments.has('lexical');
200
+
201
+ for (let i = 1; i < tiersRaw.length; i++) {
202
+ const tier = tiersRaw[i];
203
+ const parent = tiersRaw[i - 1];
204
+ // 3.2/3.3/4.1: verified against the parent ACTUALLY OPENED.
205
+ const rel = validateAgainstParent(tier.manifest, {
206
+ manifest: parent.manifest, identity: parent.identity, isBase: i === 1,
207
+ });
208
+ validateIngestAgainstChain(tier.manifest, ingestDecl);
209
+
210
+ // 4.5: the encoder is resolved from the base, and the layer's
211
+ // commitment must match the base's query-interp digest.
212
+ const baseQiSha = base.manifest.segments.find((sg) => sg.kind === 'query-interp')?.sha256;
213
+ if (!baseQiSha) throw new Error('the base carries no query-interp segment digest to inherit');
214
+ const qiSeg = tier.segments.get('query-interp');
215
+ if (!qiSeg) throw new Error(`layer at depth ${rel.depth} carries no query-interp segment`);
216
+ const inherited = parseInheritedQuerySegment(
217
+ await readSegment(opened[i], qiSeg),
218
+ {
219
+ layerBaseIdentity: rel.identityFields.baseIdentity,
220
+ baseQueryInterpSha256: baseQiSha,
221
+ baseBloom: baseBloomGeometry,
222
+ },
223
+ );
224
+ if (inherited.bloomBytes && !baseBloomGeometry) {
225
+ throw new Error(`layer at depth ${rel.depth} ships a vocabulary bloom but the base carries none whose geometry it could match (4.5)`);
226
+ }
227
+ if (inherited.bloomBytes) layerBlooms.push(inherited.bloomBytes);
228
+ else everyLayerShipsBloom = false;
229
+
230
+ if (!rel.tombstoneOnly && tier.segments.has('lexical') !== baseHasLexical) {
231
+ throw new Error(baseHasLexical
232
+ ? `layer at depth ${rel.depth} carries no lexical segment, but the base does: a layer must not drop lexical retrieval (4.3)`
233
+ : `layer at depth ${rel.depth} carries a lexical segment, but the base does not: a layer must not introduce lexical retrieval (4.3)`);
234
+ }
235
+
236
+ // 4.4: the tombstone segment is eager and digest-verified by the
237
+ // member's own open; here it is parsed and structurally validated.
238
+ const tombSeg = tier.segments.get('tombstones');
239
+ if (!tombSeg) throw new Error(`layer at depth ${rel.depth} carries no tombstones segment (4.3)`);
240
+ const tomb = parseTombstoneSegment(await readSegment(opened[i], tombSeg), {
241
+ rowBase: rel.rowBase, tombstones: rel.tombstones,
242
+ supersessions: rel.supersessions, records: rel.records,
243
+ });
244
+ // 3.4: the cumulative superset rule, against the parent's mask
245
+ // zero-extended over the ids the parent did not yet address.
246
+ const violation = firstSupersetViolation(tomb.bitset, headBitset, rel.rowBase);
247
+ if (violation !== -1) {
248
+ throw new Error(`layer at depth ${rel.depth} clears inherited tombstone for id ${violation}: a child's mask must be a superset of its parent's (3.4)`);
249
+ }
250
+ // 4.3: a tombstone-only layer exists only to delete, so its
251
+ // cumulative count MUST exceed its parent's. The producer checked
252
+ // this; a reader accepted a no-op layer from anyone else.
253
+ if (rel.tombstoneOnly && countBits(tomb.bitset) <= countBits(headBitset)) {
254
+ throw new Error(`tombstone-only layer at depth ${rel.depth} deletes nothing its parent had not already deleted (4.3)`);
255
+ }
256
+ headBitset = tomb.bitset;
257
+ perLayerTombstones.push({ depth: rel.depth, supersessions: tomb.supersessions });
258
+ chainMembers.push({
259
+ depth: rel.depth, identity: tier.identity,
260
+ rowBase: rel.rowBase, records: rel.records,
261
+ // Retained per member, not just the head's: `rebase` needs a
262
+ // layer's OWN bitset and its parent's to compute the delta it
263
+ // replays, and 6.2 refuses rather than guessing without them.
264
+ bitset: tomb.bitset,
265
+ supersessions: tomb.supersessions,
266
+ });
267
+ }
268
+
269
+ // --- 5.1 step 4: chain state ---
270
+ const table = buildIntervalTable(chainMembers);
271
+ const byIdentity = new Map(tiersRaw.map((tr) => [tr.identity, tr]));
272
+ const searchTiers = table.tiers.map((t) => ({ ...t, reader: byIdentity.get(t.identity) }));
273
+ for (const t of searchTiers) {
274
+ if (!t.reader || !t.reader.sketch) {
275
+ throw new Error(`search tier at depth ${t.depth} has no index: a tier that owns ids must carry one (4.3)`);
276
+ }
277
+ }
278
+ const masks = projectMasks(table, headBitset);
279
+ // Per-tier popcount, so a query can tell an empty mask (skip it, keep
280
+ // the WASM scan) from one that actually excludes something.
281
+ const maskCounts = masks.map((m) => countBits(m));
282
+ const supersessions = buildSupersessionMap(perLayerTombstones);
283
+ const ancestry = buildAncestry(chainMembers);
284
+ const headTombstoneCount = countBits(headBitset);
285
+ const drift = chainMembers.length > 1
286
+ ? calibrationDrift(chainMembers, headTombstoneCount) : 0;
287
+ // 5.5: "the base carries no fit" is status `none`. The fit lives in the
288
+ // base's query-interp calibration region (`asset`), never in the
289
+ // manifest; an earlier draft of this file read manifest.calibration,
290
+ // which does not exist.
291
+ const baseCal = base.calibrationJson || null;
292
+ const baseHasFit = !!(baseCal && baseCal.asset);
293
+ const calibration = calibrationStatus({
294
+ drift,
295
+ // producerEnvelope is the base producer's declared validity
296
+ // envelope. No claim is no constraint (+Infinity), which
297
+ // calibrationStatus() handles; the reader's finite limit still
298
+ // gates it.
299
+ producerEnvelope: baseCal?.asset?.driftLimit,
300
+ readerLimit: readerDriftLimit,
301
+ baseHasFit,
302
+ everyLayerShipsBloom: chainMembers.length === 1 ? true : everyLayerShipsBloom,
303
+ });
304
+ const lexStats = globalLexicalStats(searchTiers.map((t) => ({
305
+ docCount: t.reader.lexicalIndex ? t.reader.lexicalIndex.docCount : 0,
306
+ totalTokens: t.reader.lexicalIndex ? t.reader.lexicalIndex.totalTokens : 0,
307
+ })));
308
+ // The union bloom (4.5) starts from the base's own vocabulary bloom,
309
+ // which ships in its calibration region beside the fit.
310
+ const baseBloomBytes = baseCal?.vocabBloomBase64
311
+ ? base64Bytes(baseCal.vocabBloomBase64)
312
+ : null;
313
+ const chainBloom = unionBloom([baseBloomBytes, ...layerBlooms]);
314
+
315
+ let closed = false;
316
+ const assertOpen = () => { if (closed) throw new Error('chain is closed'); };
317
+
318
+ return {
319
+ info() {
320
+ assertOpen();
321
+ return {
322
+ profile: LAYER_PROFILE,
323
+ identity: chainMembers[chainMembers.length - 1].identity,
324
+ baseIdentity: base.identity,
325
+ layers: chainMembers.length,
326
+ searchTiers: searchTiers.length,
327
+ records: table.rowTotal,
328
+ liveRecords: liveCount(table, headTombstoneCount),
329
+ tombstones: headTombstoneCount,
330
+ supersessions: supersessions.size,
331
+ dim: base.dim,
332
+ metric: base.manifest.metric,
333
+ // 4.5: a chain's encoder IS the base's — a layer carries no
334
+ // encoder bytes and inherits by commitment. Omitting it here
335
+ // made every consumer that reads info().encoder report null
336
+ // on a chain: `list_packs` advertised "encoder": null for a
337
+ // pack whose base is MiniLM, so a model asking what embedded
338
+ // the corpus got nothing.
339
+ encoder: base.encoderInfo ?? null,
340
+ calibrationDrift: calibration.drift,
341
+ calibrationStatus: calibration.status,
342
+ driftLimit: calibration.effectiveLimit,
343
+ producerEnvelope: calibration.producerEnvelope,
344
+ readerLimit: calibration.readerLimit,
345
+ lexical: { ...lexStats },
346
+ // 4.1: sampleQueries may appear on any member and applies
347
+ // to that member. A chain surfaces the base's, which are
348
+ // the ones fit against the corpus the encoder saw.
349
+ sampleQueries: Array.isArray(base.manifest.sampleQueries) ? base.manifest.sampleQueries : [],
350
+ members: chainMembers.map((m) => ({ depth: m.depth, identity: m.identity, records: m.records })),
351
+ };
352
+ },
353
+
354
+ /**
355
+ * 5.3. One encode, per-tier masked candidate generation, merge by
356
+ * distance, fuse once. Equal distances break by ascending global id
357
+ * — the reference reader's declared tie rule for chains.
358
+ */
359
+ async query(vector, k = 5, queryOptions = {}) {
360
+ assertOpen();
361
+ if (!Number.isSafeInteger(k) || k < 1) throw new Error('chain query() k must be a positive integer');
362
+ const retrieval = queryOptions.retrieval ?? 'hybrid';
363
+ if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
364
+ throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
365
+ }
366
+
367
+ // --- 2. Lexical candidates (5.3 step 2, 5.4) -------------------
368
+ // df is summed across tiers per query term, so a term common in
369
+ // the base and rare in a layer scores with one idf everywhere;
370
+ // each tier scoring with its own df made layer records outrank
371
+ // base records on the same term. With comparable scores every
372
+ // tier's own top LEXICAL_CANDIDATES contains its share of the
373
+ // merged top LEXICAL_CANDIDATES, so the cutoff and cap below are
374
+ // exact over the merged, live-only list.
375
+ let lexicalHits = [];
376
+ const lexTiers = searchTiers.map((t, j) => ({ lex: t.reader.lexicalIndex, j }))
377
+ .filter((x) => x.lex);
378
+ if (retrieval !== 'vector' && queryOptions.text && lexTiers.length) {
379
+ const perTierDf = await Promise.all(lexTiers.map(({ lex }) => lex.documentFrequencies(queryOptions.text)));
380
+ const df = new Map();
381
+ for (const m of perTierDf) for (const [term, n] of m) df.set(term, (df.get(term) || 0) + n);
382
+ const merged = [];
383
+ for (const { lex, j } of lexTiers) {
384
+ const tierMask = masks[j];
385
+ // The mask goes into search() so a tombstoned row never
386
+ // takes a slot before the cap (5.3); lazy openers
387
+ // return a promise, which await covers.
388
+ const hits = await lex.search(queryOptions.text, LEXICAL_CANDIDATES, {
389
+ exclude: maskCounts[j] > 0 ? (id) => bitAt(tierMask, id) : undefined,
390
+ // globalLexicalStats names the corpus size `N`;
391
+ // search() takes `docCount`.
392
+ stats: { docCount: lexStats.N, avgdl: lexStats.avgdl },
393
+ df,
394
+ });
395
+ for (const h of hits) {
396
+ merged.push({ id: searchTiers[j].rowBase + h.id, localId: h.id, tier: j, score: h.score });
397
+ }
398
+ }
399
+ merged.sort((a, b) => (b.score - a.score) || (a.id - b.id));
400
+ lexicalHits = merged.length
401
+ ? merged.filter((h) => h.score >= merged[0].score / LEXICAL_CUTOFF).slice(0, LEXICAL_CANDIDATES)
402
+ : [];
403
+ }
404
+
405
+ // --- 3. Vector candidates (5.3 step 3) -------------------------
406
+ const perTier = await Promise.all(searchTiers.map(async (t, j) => {
407
+ // The lexical hits this tier owns join its exact rerank, so
408
+ // a known-item match the sketch scan's top-C missed is still
409
+ // scored by true distance (the single-file reader's rule).
410
+ // They were masked while postings scored; the check here is
411
+ // belt and braces, since extraCandidates bypass the mask.
412
+ const extra = lexicalHits.filter((h) => h.tier === j && !(maskCounts[j] > 0 && bitAt(masks[j], h.localId)))
413
+ .map((h) => h.localId);
414
+ // An all-zero mask is equivalent to no mask, and passing one
415
+ // makes the sketch reader over-fetch; it is omitted when the
416
+ // tier has no live deletion (maskCounts, computed at mount).
417
+ const out = await t.reader.sketch.search(vector, Math.min(k, t.records), {
418
+ rerank: queryOptions.rerank,
419
+ ...(maskCounts[j] > 0 ? { exclude: masks[j] } : {}),
420
+ // The tier's own staged scan kernel. Read per query, not
421
+ // captured at mount: staging finishes in the background
422
+ // after the member's open returns.
423
+ ...(t.reader.scanner ? { scanner: t.reader.scanner } : {}),
424
+ ...(extra.length ? { extraCandidates: extra } : {}),
425
+ // Every reranked candidate, not the top k: abstention is
426
+ // fit at a fixed top-10 window whatever the caller's k,
427
+ // and a k-sized window biased mean10 and could flip the
428
+ // verdict (the single-file reader's k-mismatch fix).
429
+ fullRerankOutput: true,
430
+ });
431
+ return out.results.map((r) => ({
432
+ id: t.rowBase + r.id, localId: r.id, tier: j,
433
+ depth: t.depth, distance: r.distance,
434
+ }));
435
+ }));
436
+ const searched = perTier.flat()
437
+ .sort((a, b) => (a.distance - b.distance) || (a.id - b.id));
438
+
439
+ // --- 4. Fuse (5.3 step 4) --------------------------------------
440
+ // fusedFull is the full-window order (not k-truncated): coverage
441
+ // grounds its verdict in what fusion ranks first, exactly as the
442
+ // calibrator fit it. Null where the mode keeps distance order.
443
+ let fusedFull = null;
444
+ if (retrieval === 'lexical') {
445
+ const byId = new Map(searched.map((hit) => [hit.id, hit]));
446
+ fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
447
+ } else if (retrieval === 'hybrid' && lexicalHits.length) {
448
+ const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
449
+ fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id), queryOptions.fusion);
450
+ const vecRank = new Map(searched.map((h, i) => [h.id, i]));
451
+ fusedFull = fusedFull.map((hit, i) => ({
452
+ ...hit,
453
+ fusedRank: i + 1,
454
+ lexicalRank: lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null,
455
+ vectorRank: vecRank.get(hit.id) + 1,
456
+ }));
457
+ }
458
+ const top = (fusedFull ?? searched).slice(0, k);
459
+ const results = await Promise.all(top.map(async (hit) => {
460
+ // ownerOf searches the interval table, whose entries carry
461
+ // no reader; searchTiers[] is the parallel array that does.
462
+ const owner = ownerOf(table, hit.id);
463
+ if (!owner) throw new Error(`fused result id ${hit.id} owns no search tier (5.2)`);
464
+ const tier = searchTiers[owner.tier.tier];
465
+ const record = await tier.reader.hydrate(owner.localId);
466
+ return {
467
+ ...record, id: hit.id, distance: hit.distance, layer: tier.depth,
468
+ ...(hit.fusedRank ? { fusedRank: hit.fusedRank, lexicalRank: hit.lexicalRank, vectorRank: hit.vectorRank } : {}),
469
+ };
470
+ }));
471
+ // 5.5: under `inherited` the base's scorer runs unchanged over
472
+ // the merged window. Under `drift-exceeded` matchQuality is
473
+ // `unscored`, confidence is omitted, results still ship, and the
474
+ // response says which input bound it — so a consumer can tell
475
+ // "no fit" from "fit outgrown". A reader MUST NOT apply an
476
+ // outgrown fit silently, and MUST NOT omit the verdict either:
477
+ // omitting it makes "the fit said no" indistinguishable from
478
+ // "nothing scored", which is exactly the confusion 5.5 exists
479
+ // to prevent.
480
+ let matchQuality = 'unscored';
481
+ let confidence;
482
+ if (calibration.status === 'inherited' && base.scoreQuality) {
483
+ // Coverage reads the fit's passagesNeeded from the FULL
484
+ // fused window; top.slice(0, 3) gave it one passage at k=1.
485
+ const passagesNeeded = baseCal?.asset?.coverage?.topK || 5;
486
+ const passageOrder = fusedFull?.length ? fusedFull : searched;
487
+ const passages = await Promise.all(passageOrder.slice(0, passagesNeeded).map(async (hit) => {
488
+ const owner = ownerOf(table, hit.id);
489
+ const rec = await searchTiers[owner.tier.tier].reader.hydrate(owner.localId);
490
+ return rec?.text ?? '';
491
+ }));
492
+ // 4.5 is normative: "A reader MUST compute the union
493
+ // before scoring." The union was computed at mount and
494
+ // then never reached the scorer, so known_frac was
495
+ // measured against the base's vocabulary alone and
496
+ // under-read every query about a layer's content —
497
+ // exactly the records a layer is published to add.
498
+ const scored = await base.scoreQuality(
499
+ searched,
500
+ { text: queryOptions.text ?? '', vector, passages },
501
+ fusedFull,
502
+ { vocabBloom: chainBloom },
503
+ );
504
+ if (scored && scored.match_quality) {
505
+ matchQuality = scored.match_quality;
506
+ if (Number.isFinite(scored.confidence)) confidence = scored.confidence;
507
+ }
508
+ } else if (calibration.status === 'none') {
509
+ matchQuality = 'unscored';
510
+ }
511
+ // A 'none' verdict withholds results, exactly as the
512
+ // single-file reader does: the calibrated abstention signal is
513
+ // doing its job, and a chain must not be a way to get results
514
+ // the base would have refused. showAbstained is the same
515
+ // explicit opt-out, and never changes matchQuality.
516
+ const withheld = matchQuality === 'none' && !queryOptions.showAbstained;
517
+ return {
518
+ matchQuality,
519
+ ...(confidence === undefined ? {} : { confidence }),
520
+ results: withheld ? [] : results,
521
+ identity: chainMembers[chainMembers.length - 1].identity,
522
+ calibration: {
523
+ status: calibration.status,
524
+ drift: calibration.drift,
525
+ effectiveLimit: calibration.effectiveLimit,
526
+ producerEnvelope: calibration.producerEnvelope,
527
+ readerLimit: calibration.readerLimit,
528
+ },
529
+ };
530
+ },
531
+
532
+ /** 5.6: a bare id is an id in the mounted head. */
533
+ async record(id) {
534
+ assertOpen();
535
+ const owner = ownerOf(table, id);
536
+ if (!owner) throw new Error(`id ${id} is outside every search tier's interval (5.2)`);
537
+ const tier = searchTiers[owner.tier.tier];
538
+ const record = await tier.reader.hydrate(owner.localId);
539
+ const tombstoned = bitAt(headBitset, id);
540
+ const sup = resolveSupersession(supersessions, id);
541
+ return {
542
+ ...record, id, layer: tier.depth, tombstoned,
543
+ supersededBy: sup.immediate, successors: sup.all, currentSuccessor: sup.current,
544
+ };
545
+ },
546
+
547
+ /** 5.6: a citation is (identity, id), resolved along the mounted ancestry. */
548
+ citation(identity, id) {
549
+ assertOpen();
550
+ return resolveCitation(ancestry, identity, id);
551
+ },
552
+
553
+ /**
554
+ * 6.2: a chain has no evaluation segment of its own, so this
555
+ * surfaces the BASE's, tagged with where it came from. Callers use
556
+ * it to run goldens against the chain; a golden written for the
557
+ * base may legitimately fail here, because a layer can tombstone
558
+ * or supersede the record it expects, so `evaluationScope` says
559
+ * the material is the base's and not the chain's.
560
+ *
561
+ * Before this existed `verify_pack` called `search.evaluation()`
562
+ * on a chain and got a TypeError.
563
+ */
564
+ async evaluation() {
565
+ assertOpen();
566
+ if (typeof base.evaluation !== 'function') return null;
567
+ const ev = await base.evaluation();
568
+ if (!ev) return null;
569
+ return { ...ev, evaluationScope: 'base', evaluationIdentity: base.identity };
570
+ },
571
+
572
+ chainBloom() { assertOpen(); return chainBloom; },
573
+
574
+ /**
575
+ * The stored bytes of each id's record, from its owning tier's
576
+ * corpus segment and verified per record, in the order ids were
577
+ * given. `compact` (6.3) copies these verbatim: record() returns
578
+ * the parsed record with chain fields laid over it, and
579
+ * re-serializing that is not the original bytes.
580
+ */
581
+ async __recordBytes(ids) {
582
+ assertOpen();
583
+ return Promise.all(ids.map(async (id) => {
584
+ const owner = ownerOf(table, id);
585
+ if (!owner) throw new Error(`id ${id} is outside every search tier's interval (5.2)`);
586
+ return searchTiers[owner.tier.tier].reader.readRecordBytes(owner.localId);
587
+ }));
588
+ },
589
+
590
+ /**
591
+ * 6.3's verbatim row copy: the quantized bytes and per-row
592
+ * scale/offset for a set of chain ids, read back from whichever
593
+ * tier owns each one. Producer seam, like __head.
594
+ *
595
+ * `compact` re-embedded every live record with the base's own
596
+ * encoder instead, on the stated grounds that the engine exposed
597
+ * no row readback. It does: the sketch reader's fetchRows()
598
+ * (format 2 verifies each row's digest as it reads) plus the
599
+ * resident scales/offsets arrays. Re-embedding costs a forward
600
+ * pass per record and only reproduces the same bytes when the
601
+ * encoder is bit-identical, which is a stronger assumption than
602
+ * the spec's copy makes.
603
+ *
604
+ * Returns rows in the order ids were given. Throws if an id is
605
+ * outside every tier, which callers should read as a planning bug
606
+ * rather than a missing row.
607
+ */
608
+ async __fetchRows(ids) {
609
+ assertOpen();
610
+ // Group by owning tier so each tier's fetchRows sees one
611
+ // batched call: the reader coalesces adjacent rows into single
612
+ // range reads, which per-id calls would defeat on a URL mount.
613
+ const byTier = new Map();
614
+ const placed = ids.map((id, at) => {
615
+ const owner = ownerOf(table, id);
616
+ if (!owner) throw new Error(`id ${id} is outside every search tier's interval (5.2)`);
617
+ const t = owner.tier.tier;
618
+ if (!byTier.has(t)) byTier.set(t, []);
619
+ byTier.get(t).push({ at, localId: owner.localId });
620
+ return { tier: t, localId: owner.localId };
621
+ });
622
+ const out = new Array(ids.length);
623
+ for (const [tierIndex, wanted] of byTier) {
624
+ const { reader } = searchTiers[tierIndex];
625
+ const fetched = await reader.sketch.fetchRows(wanted.map((w) => w.localId));
626
+ for (const w of wanted) {
627
+ const row = fetched.get(w.localId);
628
+ if (!row) throw new Error(`tier ${tierIndex} returned no row for local id ${w.localId}`);
629
+ out[w.at] = {
630
+ row,
631
+ scale: reader.sketch.scales[w.localId],
632
+ offset: reader.sketch.offsets[w.localId],
633
+ };
634
+ }
635
+ }
636
+ for (let i = 0; i < out.length; i++) {
637
+ if (!out[i]) throw new Error(`no row read back for id ${ids[i]} (tier ${placed[i].tier})`);
638
+ }
639
+ return out;
640
+ },
641
+
642
+ /**
643
+ * The chain's members with each layer's own bitset and edges, which
644
+ * is what `rebase` replays (6.2) and what `compact` reads to
645
+ * enumerate live ids and rebuild the lineage segment (6.3).
646
+ */
647
+ get __members() {
648
+ assertOpen();
649
+ return chainMembers.map((m) => ({ ...m }));
650
+ },
651
+
652
+ /**
653
+ * Everything `append` needs about the head to plan a new layer
654
+ * (6.1). Internal, like __chainTier: a producer is part of this
655
+ * package, and the names carry no compatibility promise.
656
+ */
657
+ get __head() {
658
+ assertOpen();
659
+ const last = chainMembers[chainMembers.length - 1];
660
+ const baseMember = chainMembers[0];
661
+ return {
662
+ identity: last.identity,
663
+ depth: last.depth,
664
+ // The head's own rowBase and record count, which together
665
+ // give the next layer's rowBase (3.3).
666
+ rowBase: last.depth === 0 ? 0 : last.rowBase,
667
+ records: last.records,
668
+ bitset: headBitset,
669
+ baseIdentity: base.identity,
670
+ baseRecords: baseMember.records,
671
+ // The base sketch's numeric metric code, which
672
+ // exportSketchArtifact writes verbatim. manifest.metric is
673
+ // the string form and is not interchangeable with it.
674
+ metricCode: base.sketch.metric,
675
+ // Sum of every layer's records, for the drift numerator.
676
+ appendedBefore: chainMembers.slice(1).reduce((n, m) => n + m.records, 0),
677
+ // 6.1 needs the base's kind to decide appendability, its
678
+ // ingestion declaration to enforce granularity, and its
679
+ // chunking so new records are cut the same way. The kind
680
+ // comes from the segment header verbatim, not from
681
+ // encoderInfo: kind 2's encoderInfo is shaped by the host
682
+ // declaration and carries no fixed name to match on.
683
+ qiKind: base.qiKind ?? null,
684
+ // The base's own encoder, resolved by the base's open.
685
+ passageEmbedder: () => base.passageEmbedder(),
686
+ // The base's query-interp segment bytes. `compact` copies
687
+ // them verbatim: compaction MUST NOT change the encoder,
688
+ // dim, metric, ingestion or tokenization (6.3), and the
689
+ // surest way not to is to carry the same bytes.
690
+ // The base's bloom geometry, which every layer's bloom
691
+ // MUST match so the chain's union is an exact bitwise OR
692
+ // (4.5). Null when the base carries no fit.
693
+ baseBloomGeometry: baseCal?.asset?.vocabBloom
694
+ ? {
695
+ bits: baseCal.asset.vocabBloom.bits,
696
+ hashes: baseCal.asset.vocabBloom.hashes,
697
+ minCount: baseCal.asset.vocabBloom.minCount ?? null,
698
+ }
699
+ : null,
700
+ readBaseQueryInterp: () => {
701
+ const seg = base.segments.get('query-interp');
702
+ if (!seg) return null;
703
+ return base.readSegmentBytes(seg);
704
+ },
705
+ corpusIngest: base.manifest.corpus?.ingest ?? null,
706
+ // The chain's ingestion declaration as validated at mount
707
+ // ({canonical, asserted, source}); null for a lone base,
708
+ // whose declaration is corpusIngest alone. rebase (6.2)
709
+ // compares two histories' declarations through this.
710
+ chainIngest: ingestDecl,
711
+ chunking: base.manifest.corpus?.ingest?.chunking
712
+ ?? base.manifest.corpus?.ingest
713
+ ?? null,
714
+ baseQueryInterpSha256: base.manifest.segments.find((sg) => sg.kind === 'query-interp')?.sha256 ?? null,
715
+ dim: base.dim,
716
+ metric: base.manifest.metric,
717
+ encoder: base.manifest.encoder,
718
+ };
719
+ },
720
+
721
+ async close() {
722
+ if (closed) return;
723
+ closed = true;
724
+ await closeAll();
725
+ },
726
+ };
727
+ } catch (err) {
728
+ await closeAll();
729
+ throw err;
730
+ }
731
+ }
732
+
733
+ function countBits(bytes) {
734
+ let n = 0;
735
+ for (let i = 0; i < bytes.length; i++) {
736
+ let x = bytes[i];
737
+ while (x) { n += x & 1; x >>= 1; }
738
+ }
739
+ return n;
740
+ }
741
+
742
+ function bitAt(bitset, id) {
743
+ const byte = id >> 3;
744
+ if (!bitset || byte >= bitset.length) return false;
745
+ return ((bitset[byte] >> (id & 7)) & 1) === 1;
746
+ }
747
+
748
+ /**
749
+ * Read one segment's bytes through the member's own reader. The member's open
750
+ * already verified the segment digest where the profile requires it eagerly;
751
+ * this is the bytes for structural parsing.
752
+ */
753
+ async function readSegment(member, seg) {
754
+ const tier = member.__chainTier || member.tier;
755
+ if (tier && typeof tier.readSegmentBytes === 'function') return tier.readSegmentBytes(seg);
756
+ throw new Error('this reader build does not expose segment bytes for chain parsing');
757
+ }
758
+
759
+ export { FUSION_DEFAULTS };