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
|
@@ -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 };
|