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,319 @@
1
+ // `compact` and the lineage segment — LAYERED_PROFILE.md 6.3.
2
+ //
3
+ // Compaction is SEMANTIC maintenance, not only physical consolidation. It is the
4
+ // step that restores every exact property the layers approximated: exact lexical
5
+ // statistics over the live set (5.4), a vocabulary bloom over the live
6
+ // vocabulary only (4.5), a calibration fit against the corpus actually served
7
+ // (5.5), no tombstones, no supersession walks, depth zero, and reclaimed id
8
+ // space (3.3). The drift and depth limits exist to force it to happen.
9
+ //
10
+ // What it preserves, and what it deliberately does not:
11
+ //
12
+ // Corpus meaning and provenance are preserved exactly — the compacted base's
13
+ // records are the old head's live records, in ascending old-id order, bytes
14
+ // unchanged. RANKINGS ARE NOT, and are not meant to be: layered BM25 scored
15
+ // with statistics that counted tombstoned documents, compaction scores with
16
+ // exact live statistics. "If the layered approximations had any observable
17
+ // effect, a compaction that reproduced the chain's rankings would have failed
18
+ // at its job." Conformance therefore tests corpus preservation and retrieval
19
+ // correctness separately, and records rankings before and after as a
20
+ // comparison, never an equality.
21
+
22
+ import { testBit, popcountBytes, bitsetBytesFor } from './tombstones.mjs';
23
+
24
+ export const LINEAGE_HEADER_BYTES = 64;
25
+ export const LINEAGE_HEAD_ENTRY_BYTES = 48;
26
+ export const LINEAGE_VERSION = 1;
27
+ // Depth is at most 16, so the head table holds at most 17 entries — the old
28
+ // base at depth 0 through the old head.
29
+ export const MAX_HEADS = 17;
30
+
31
+ const isHex64 = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
32
+ const hexToBytes = (hex) => {
33
+ const out = new Uint8Array(32);
34
+ for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
35
+ return out;
36
+ };
37
+ const bytesToHex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
38
+
39
+ /**
40
+ * Plan a compaction: which ids survive, and the dense renumbering.
41
+ *
42
+ * Live ids in ascending global order; survivors renumbered densely in the same
43
+ * order (step 1-2 of 6.3). The translation is positional, which is what makes
44
+ * compaction byte-deterministic given the chain.
45
+ */
46
+ export function planCompaction({ rowTotal, headBitset }) {
47
+ if (!Number.isSafeInteger(rowTotal) || rowTotal < 0) throw new Error('compact: rowTotal must be a non-negative safe integer');
48
+ const liveIds = [];
49
+ for (let id = 0; id < rowTotal; id++) if (!testBit(headBitset, id)) liveIds.push(id);
50
+ if (!liveIds.length) throw new Error('compact: the chain has no live records');
51
+ // oldId -> newId, dense, order-preserving.
52
+ const forward = new Map();
53
+ liveIds.forEach((oldId, newId) => forward.set(oldId, newId));
54
+ return {
55
+ liveIds,
56
+ liveRecords: liveIds.length,
57
+ tombstones: rowTotal - liveIds.length,
58
+ newIdOf: (oldId) => (forward.has(oldId) ? forward.get(oldId) : null),
59
+ forward,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build a `lineage-v1` segment (kind 7), REQUIRED for a base compacted from a
65
+ * chain.
66
+ *
67
+ * The HEAD TABLE is what lets a citation against any head of the compacted
68
+ * chain translate — not only the last one. Draft 2's segment named only the old
69
+ * head, so once layers were retired a citation made while H3 was published was
70
+ * unresolvable, and a chain compacted twice could not recognize the intermediate
71
+ * base. The previous compacted base is this table's depth-0 entry, so retaining
72
+ * intermediate compacted bases gives multi-hop translation with nothing further.
73
+ *
74
+ * @param {{heads: Array<{identity: string, rowTotal: number, depth: number}>,
75
+ * headBitset: Uint8Array, oldRowTotal: number,
76
+ * supersessions: Array<[number, number, number]>}} spec
77
+ * supersessions are (oldId, newId, depth) triples — every edge of the old
78
+ * chain. Each carries its depth, so the immediate/all/current views of 3.5 are
79
+ * computed exactly as on a live chain, with nothing inferred from ordering.
80
+ */
81
+ export function buildLineageSegment({ heads, headBitset, oldRowTotal, supersessions = [] }) {
82
+ if (!Array.isArray(heads) || heads.length < 1 || heads.length > MAX_HEADS) {
83
+ throw new Error(`lineage: headCount must be in [1, ${MAX_HEADS}], got ${heads?.length}`);
84
+ }
85
+ heads.forEach((h, i) => {
86
+ if (!isHex64(h.identity)) throw new Error(`lineage: head ${i} identity must be 64 lowercase hex characters`);
87
+ if (h.depth !== i) throw new Error(`lineage: head table depths must be exactly 0..${heads.length - 1} in order; entry ${i} says ${h.depth}`);
88
+ if (!Number.isSafeInteger(h.rowTotal) || h.rowTotal < 0) throw new Error(`lineage: head ${i} rowTotal is not a safe integer`);
89
+ if (i > 0 && h.rowTotal < heads[i - 1].rowTotal) throw new Error('lineage: head table rowTotal values must be non-decreasing');
90
+ });
91
+ if (heads[heads.length - 1].rowTotal !== oldRowTotal) {
92
+ throw new Error(`lineage: the last head's rowTotal ${heads[heads.length - 1].rowTotal} must equal oldRowTotal ${oldRowTotal}`);
93
+ }
94
+ if (new Set(heads.map((h) => h.identity)).size !== heads.length) {
95
+ throw new Error('lineage: head table identities must be pairwise distinct');
96
+ }
97
+
98
+ const bitsetBytes = bitsetBytesFor(oldRowTotal);
99
+ const bitset = new Uint8Array(bitsetBytes);
100
+ bitset.set(headBitset.subarray(0, Math.min(headBitset.length, bitsetBytes)));
101
+ const tombstoneCount = popcountBytes(bitset);
102
+
103
+ const edges = [...supersessions].map(([oldId, newId, depth]) => {
104
+ if (!(oldId < newId && newId < oldRowTotal)) {
105
+ throw new Error(`lineage: edge (${oldId} -> ${newId}) must satisfy oldId < newId < oldRowTotal ${oldRowTotal}`);
106
+ }
107
+ if (!testBit(bitset, oldId)) throw new Error(`lineage: edge oldId ${oldId} is not tombstoned`);
108
+ if (!Number.isInteger(depth) || depth < 1 || depth > heads.length - 1) {
109
+ throw new Error(`lineage: edge depth ${depth} must be in [1, ${heads.length - 1}]`);
110
+ }
111
+ // newId must lie in the range the layer at that depth owned.
112
+ const lo = heads[depth - 1].rowTotal;
113
+ const hi = heads[depth].rowTotal;
114
+ if (newId < lo || newId >= hi) {
115
+ throw new Error(`lineage: edge newId ${newId} is outside the range [${lo}, ${hi}) the layer at depth ${depth} owned`);
116
+ }
117
+ return [oldId, newId, depth];
118
+ });
119
+ edges.sort((a, b) => (a[0] - b[0]) || (a[2] - b[2]));
120
+ for (let i = 1; i < edges.length; i++) {
121
+ if (edges[i][0] === edges[i - 1][0] && edges[i][2] === edges[i - 1][2]) {
122
+ throw new Error(`lineage: (oldId ${edges[i][0]}, depth ${edges[i][2]}) appears twice`);
123
+ }
124
+ }
125
+
126
+ const total = LINEAGE_HEADER_BYTES + LINEAGE_HEAD_ENTRY_BYTES * heads.length + bitsetBytes + edges.length * 12;
127
+ const out = new Uint8Array(total);
128
+ const view = new DataView(out.buffer);
129
+ view.setUint32(0, LINEAGE_VERSION, true);
130
+ view.setUint32(4, 0, true);
131
+ view.setUint32(8, heads.length, true);
132
+ view.setUint32(12, edges.length, true);
133
+ view.setBigUint64(16, BigInt(oldRowTotal), true);
134
+ view.setBigUint64(24, BigInt(tombstoneCount), true);
135
+ let at = LINEAGE_HEADER_BYTES;
136
+ for (const h of heads) {
137
+ out.set(hexToBytes(h.identity), at);
138
+ view.setBigUint64(at + 32, BigInt(h.rowTotal), true);
139
+ view.setUint32(at + 40, h.depth, true);
140
+ view.setUint32(at + 44, 0, true);
141
+ at += LINEAGE_HEAD_ENTRY_BYTES;
142
+ }
143
+ out.set(bitset, at);
144
+ at += bitsetBytes;
145
+ for (const [oldId, newId, depth] of edges) {
146
+ view.setUint32(at, oldId, true);
147
+ view.setUint32(at + 4, newId, true);
148
+ view.setUint32(at + 8, depth, true);
149
+ at += 12;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Parse and fully validate a lineage segment. A digest-valid lineage segment is
156
+ * still untrusted input (section 9), so every rule of 6.3's validation list is
157
+ * enforced here rather than assumed.
158
+ *
159
+ * @param {Uint8Array} bytes
160
+ * @param {{identity: string, liveRecords: number, tombstones: number}} [compactedFrom]
161
+ * The manifest's compactedFrom block. When given, the segment must agree.
162
+ */
163
+ export function parseLineageSegment(bytes, compactedFrom = null) {
164
+ if (!(bytes instanceof Uint8Array)) throw new Error('lineage: segment must be a Uint8Array');
165
+ if (bytes.length < LINEAGE_HEADER_BYTES) throw new Error(`lineage: segment is shorter than its ${LINEAGE_HEADER_BYTES}-byte header`);
166
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
167
+ const version = view.getUint32(0, true);
168
+ if (version !== LINEAGE_VERSION) throw new Error(`lineage: unsupported version ${version}`);
169
+ const flags = view.getUint32(4, true);
170
+ if (flags !== 0) throw new Error(`lineage: flags must be 0, got ${flags}`);
171
+ const headCount = view.getUint32(8, true);
172
+ if (headCount < 1 || headCount > MAX_HEADS) throw new Error(`lineage: headCount ${headCount} is outside [1, ${MAX_HEADS}]`);
173
+ const supersessionCount = view.getUint32(12, true);
174
+ const oldRowTotalBig = view.getBigUint64(16, true);
175
+ const tombBig = view.getBigUint64(24, true);
176
+ if (oldRowTotalBig > BigInt(Number.MAX_SAFE_INTEGER) || tombBig > BigInt(Number.MAX_SAFE_INTEGER)) {
177
+ throw new Error('lineage: a u64 field exceeds MAX_SAFE_INTEGER');
178
+ }
179
+ const oldRowTotal = Number(oldRowTotalBig);
180
+ const tombstoneCount = Number(tombBig);
181
+ for (let off = 32; off < LINEAGE_HEADER_BYTES; off += 4) {
182
+ if (view.getUint32(off, true) !== 0) throw new Error('lineage: reserved header bytes must be zero');
183
+ }
184
+
185
+ // The regions MUST tile the segment exactly, computed with subtraction.
186
+ const bitsetBytes = bitsetBytesFor(oldRowTotal);
187
+ const headBytes = LINEAGE_HEAD_ENTRY_BYTES * headCount;
188
+ const need = headBytes + bitsetBytes + supersessionCount * 12;
189
+ if (bytes.length - LINEAGE_HEADER_BYTES !== need) {
190
+ throw new Error(`lineage: regions do not tile the segment (${bytes.length - LINEAGE_HEADER_BYTES} bytes after the header, need ${need})`);
191
+ }
192
+
193
+ const heads = [];
194
+ let at = LINEAGE_HEADER_BYTES;
195
+ for (let i = 0; i < headCount; i++) {
196
+ const identity = bytesToHex(bytes.subarray(at, at + 32));
197
+ const rowTotalBig = view.getBigUint64(at + 32, true);
198
+ if (rowTotalBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('lineage: a head rowTotal exceeds MAX_SAFE_INTEGER');
199
+ const rowTotal = Number(rowTotalBig);
200
+ const depth = view.getUint32(at + 40, true);
201
+ if (view.getUint32(at + 44, true) !== 0) throw new Error(`lineage: head ${i} reserved word must be zero`);
202
+ if (depth !== i) throw new Error(`lineage: head table depths must be exactly 0..${headCount - 1} in order; entry ${i} says ${depth}`);
203
+ if (i > 0 && rowTotal < heads[i - 1].rowTotal) throw new Error('lineage: head table rowTotal values must be non-decreasing');
204
+ heads.push({ identity, rowTotal, depth });
205
+ at += LINEAGE_HEAD_ENTRY_BYTES;
206
+ }
207
+ if (heads[headCount - 1].rowTotal !== oldRowTotal) {
208
+ throw new Error(`lineage: the last head's rowTotal ${heads[headCount - 1].rowTotal} must equal oldRowTotal ${oldRowTotal}`);
209
+ }
210
+ if (new Set(heads.map((h) => h.identity)).size !== headCount) {
211
+ throw new Error('lineage: head table identities must be pairwise distinct');
212
+ }
213
+
214
+ const bitset = bytes.subarray(at, at + bitsetBytes);
215
+ at += bitsetBytes;
216
+ const padBits = bitsetBytes * 8 - oldRowTotal;
217
+ if (padBits > 0 && bitsetBytes > 0) {
218
+ const validMask = (1 << (8 - padBits)) - 1;
219
+ if ((bitset[bitsetBytes - 1] & ~validMask & 0xff) !== 0) {
220
+ throw new Error(`lineage: ${padBits} pad bit(s) above oldRowTotal are non-zero`);
221
+ }
222
+ }
223
+ const actualTomb = popcountBytes(bitset);
224
+ if (actualTomb !== tombstoneCount) {
225
+ throw new Error(`lineage: header says ${tombstoneCount} tombstones, bitset popcount is ${actualTomb}`);
226
+ }
227
+
228
+ const supersessions = [];
229
+ let prev = null;
230
+ for (let i = 0; i < supersessionCount; i++) {
231
+ const oldId = view.getUint32(at, true);
232
+ const newId = view.getUint32(at + 4, true);
233
+ const depth = view.getUint32(at + 8, true);
234
+ at += 12;
235
+ if (!(oldId < newId && newId < oldRowTotal)) {
236
+ throw new Error(`lineage: edge (${oldId} -> ${newId}) must satisfy oldId < newId < oldRowTotal ${oldRowTotal}`);
237
+ }
238
+ if (!testBit(bitset, oldId)) throw new Error(`lineage: edge oldId ${oldId} is not tombstoned`);
239
+ if (depth < 1 || depth > headCount - 1) throw new Error(`lineage: edge depth ${depth} must be in [1, ${headCount - 1}]`);
240
+ const lo = heads[depth - 1].rowTotal;
241
+ const hi = heads[depth].rowTotal;
242
+ if (newId < lo || newId >= hi) {
243
+ throw new Error(`lineage: edge newId ${newId} is outside the range [${lo}, ${hi}) the layer at depth ${depth} owned`);
244
+ }
245
+ if (prev && (oldId < prev[0] || (oldId === prev[0] && depth <= prev[2]))) {
246
+ throw new Error('lineage: edges must be sorted by (oldId, depth) and unique');
247
+ }
248
+ prev = [oldId, newId, depth];
249
+ supersessions.push([oldId, newId, depth]);
250
+ }
251
+
252
+ if (compactedFrom) {
253
+ if (heads[headCount - 1].identity !== compactedFrom.identity) {
254
+ throw new Error(`lineage: the last head ${heads[headCount - 1].identity.slice(0, 12)}… must equal the manifest's compactedFrom.identity ${String(compactedFrom.identity).slice(0, 12)}…`);
255
+ }
256
+ if (Number.isInteger(compactedFrom.tombstones) && compactedFrom.tombstones !== tombstoneCount) {
257
+ throw new Error(`lineage: segment tombstoneCount ${tombstoneCount} disagrees with manifest compactedFrom.tombstones ${compactedFrom.tombstones}`);
258
+ }
259
+ if (Number.isInteger(compactedFrom.liveRecords) && compactedFrom.liveRecords !== oldRowTotal - tombstoneCount) {
260
+ throw new Error(`lineage: segment implies ${oldRowTotal - tombstoneCount} live records, manifest compactedFrom says ${compactedFrom.liveRecords}`);
261
+ }
262
+ }
263
+
264
+ return { version, headCount, heads, oldRowTotal, tombstoneCount, bitset, supersessions };
265
+ }
266
+
267
+ /**
268
+ * `translate(identity, id)` by a reader of a compacted base — 6.3.
269
+ *
270
+ * This is FORWARD translation: it answers "what became of the record cited at
271
+ * H3 by the time H8 was compacted", which is the question a citation asks of a
272
+ * compacted base. It is computed from the FINAL pre-compaction state — the old
273
+ * head's mask and the full edge set — regardless of which head was cited. It is
274
+ * deliberately not historical state reconstruction: the head table does not make
275
+ * it able to answer "what did H3 serve".
276
+ *
277
+ * @param {object} lineage parsed lineage segment
278
+ * @param {Map<number, number>|((oldId:number)=>number|null)} compactMap oldId -> new dense id
279
+ */
280
+ export function translateCitation(lineage, compactMap, identity, id) {
281
+ const entry = lineage.heads.find((h) => h.identity === identity);
282
+ if (!entry) return { ok: false, reason: 'not-on-this-history' };
283
+ if (!Number.isSafeInteger(id) || id < 0) return { ok: false, reason: 'invalid-id' };
284
+ // The id did not exist when that artifact was the head.
285
+ if (id >= entry.rowTotal) return { ok: false, reason: 'id-postdates-that-head' };
286
+
287
+ const lookup = typeof compactMap === 'function' ? compactMap : (oldId) => (compactMap.has(oldId) ? compactMap.get(oldId) : null);
288
+ const live = lookup(id);
289
+ if (live !== null && live !== undefined) {
290
+ return { ok: true, newId: live, via: 'live', citedAtDepth: entry.depth };
291
+ }
292
+
293
+ // The cited record was tombstoned. Follow the edge set to the current
294
+ // successor, by DEPTH, exactly as 3.5 prescribes: start from the
295
+ // greatest-depth edge and keep following each reached id's greatest-depth
296
+ // edge until one has none.
297
+ const byOld = new Map();
298
+ for (const [oldId, newId, depth] of lineage.supersessions) {
299
+ if (!byOld.has(oldId)) byOld.set(oldId, []);
300
+ byOld.get(oldId).push({ newId, depth });
301
+ }
302
+ for (const edges of byOld.values()) edges.sort((a, b) => a.depth - b.depth);
303
+
304
+ let cursor = id;
305
+ const seen = new Set([id]);
306
+ for (let hops = 0; hops < MAX_HEADS + 1; hops++) {
307
+ const edges = byOld.get(cursor);
308
+ if (!edges || !edges.length) break;
309
+ const next = edges[edges.length - 1].newId;
310
+ if (seen.has(next)) break;
311
+ seen.add(next);
312
+ cursor = next;
313
+ const mapped = lookup(cursor);
314
+ if (mapped !== null && mapped !== undefined) {
315
+ return { ok: true, newId: mapped, via: 'superseded', supersededChain: [...seen].slice(1), citedAtDepth: entry.depth };
316
+ }
317
+ }
318
+ return { ok: false, reason: 'deleted', citedAtDepth: entry.depth };
319
+ }
@@ -0,0 +1,223 @@
1
+ // Query-interpretation kind 4, `inherited-v1` — LAYERED_PROFILE.md 4.5.
2
+ //
3
+ // A layer never carries encoder bytes. It carries a commitment to the base's
4
+ // encoder instead, and the reader resolves the real encoder from the base it
5
+ // actually opened. Decision 5's invariant:
6
+ //
7
+ // inheritFrom MUST equal manifest layer.baseIdentity
8
+ // queryInterpSha256 MUST equal the base manifest's segments[kind=query-interp].sha256
9
+ //
10
+ // Those two together bind the base's encoder bytes AND its calibration under
11
+ // the base's identity, which the layer's baseIdentity in turn commits to. So a
12
+ // layer cannot substitute an encoder, and cannot point at a different base's
13
+ // encoder, without breaking a digest the head's identity covers.
14
+ //
15
+ // The segment reuses the complete profile's query-interp header (version,
16
+ // kind, encoderLen, calibrationLen, then two regions that tile exactly):
17
+ //
18
+ // [0,4) u32 version = 1
19
+ // [4,8) u32 kind = 4
20
+ // [8,12) u32 encoderLen the inherited-v1 JSON below
21
+ // [12,16) u32 calibrationLen layer-vocab-v1 JSON, or {"kind":"none"}
22
+ //
23
+ // The calibration region is this layer's own vocabulary bloom, built with the
24
+ // base's geometry so the union of 4.5 is an exact bitwise OR. A layer whose
25
+ // region is `{"kind":"none"}` while the base carries a fit degrades the chain
26
+ // to `unscored` (5.5), so a producer SHOULD always ship the bloom.
27
+
28
+ const encoder = new TextEncoder();
29
+ const decoder = new TextDecoder();
30
+
31
+ export const QI_HEADER_BYTES = 16;
32
+ export const QI_KIND_INHERITED = 4;
33
+ export const INHERITED_VERSION = 1;
34
+ export const LAYER_VOCAB_KIND = 'layer-vocab-v1';
35
+
36
+ const isHex64 = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
37
+
38
+ /**
39
+ * Build a kind-4 query-interp segment.
40
+ *
41
+ * @param {{baseIdentity: string, queryInterpSha256: string,
42
+ * vocabBloom?: {bits: number, hashes: string[]}|null,
43
+ * vocabBloomBytes?: Uint8Array|null}} spec
44
+ * Omitting the bloom writes `{"kind":"none"}`, which is legal but degrades a
45
+ * scored chain to unscored — the caller is expected to know that.
46
+ */
47
+ export function buildInheritedQuerySegment({ baseIdentity, queryInterpSha256, vocabBloom = null, vocabBloomBytes = null }) {
48
+ if (!isHex64(baseIdentity)) throw new Error('inherited-v1: baseIdentity must be 64 lowercase hex characters');
49
+ if (!isHex64(queryInterpSha256)) throw new Error('inherited-v1: queryInterpSha256 must be 64 lowercase hex characters');
50
+ const encoderJson = encoder.encode(JSON.stringify({ inheritFrom: baseIdentity, queryInterpSha256 }));
51
+
52
+ let calibrationJson;
53
+ if (vocabBloom && vocabBloomBytes) {
54
+ if (!Number.isSafeInteger(vocabBloom.bits) || vocabBloom.bits <= 0 || vocabBloom.bits % 8 !== 0) {
55
+ throw new Error('inherited-v1: vocabBloom.bits must be a positive multiple of 8');
56
+ }
57
+ if (vocabBloomBytes.length * 8 !== vocabBloom.bits) {
58
+ throw new Error(`inherited-v1: bloom is ${vocabBloomBytes.length * 8} bits but vocabBloom.bits says ${vocabBloom.bits}`);
59
+ }
60
+ if (!Array.isArray(vocabBloom.hashes) || vocabBloom.hashes.length === 0) {
61
+ throw new Error('inherited-v1: vocabBloom.hashes must be a non-empty array');
62
+ }
63
+ calibrationJson = encoder.encode(JSON.stringify({
64
+ kind: LAYER_VOCAB_KIND,
65
+ vocabBloom: { bits: vocabBloom.bits, hashes: vocabBloom.hashes },
66
+ vocabBloomBase64: base64FromBytes(vocabBloomBytes),
67
+ }));
68
+ } else if (vocabBloom || vocabBloomBytes) {
69
+ throw new Error('inherited-v1: vocabBloom and vocabBloomBytes must be supplied together');
70
+ } else {
71
+ calibrationJson = encoder.encode(JSON.stringify({ kind: 'none' }));
72
+ }
73
+
74
+ const out = new Uint8Array(QI_HEADER_BYTES + encoderJson.length + calibrationJson.length);
75
+ const view = new DataView(out.buffer);
76
+ view.setUint32(0, INHERITED_VERSION, true);
77
+ view.setUint32(4, QI_KIND_INHERITED, true);
78
+ view.setUint32(8, encoderJson.length, true);
79
+ view.setUint32(12, calibrationJson.length, true);
80
+ out.set(encoderJson, QI_HEADER_BYTES);
81
+ out.set(calibrationJson, QI_HEADER_BYTES + encoderJson.length);
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Parse and validate a kind-4 segment against the layer's manifest and the
87
+ * base a reader actually opened.
88
+ *
89
+ * @param {Uint8Array} bytes
90
+ * @param {{layerBaseIdentity: string, baseQueryInterpSha256: string,
91
+ * baseBloom?: {bits: number, hashes: string[]}|null}} expect
92
+ * `layerBaseIdentity` comes from the layer's own manifest; the other two come
93
+ * from the BASE's manifest. Passing them separately is the point: the check
94
+ * is that the layer's claim matches the base actually resolved, so neither
95
+ * side can be supplied by the other.
96
+ */
97
+ export function parseInheritedQuerySegment(bytes, expect) {
98
+ if (!(bytes instanceof Uint8Array)) throw new Error('inherited-v1: segment must be a Uint8Array');
99
+ if (bytes.length < QI_HEADER_BYTES) throw new Error('inherited-v1: segment is shorter than its 16-byte header');
100
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
101
+ const version = view.getUint32(0, true);
102
+ if (version !== INHERITED_VERSION) throw new Error(`inherited-v1: unsupported version ${version}`);
103
+ const kind = view.getUint32(4, true);
104
+ if (kind !== QI_KIND_INHERITED) throw new Error(`inherited-v1: segment kind is ${kind}, expected ${QI_KIND_INHERITED}`);
105
+ const encoderLen = view.getUint32(8, true);
106
+ const calibrationLen = view.getUint32(12, true);
107
+ // Regions must tile the segment exactly, computed without overflow.
108
+ if (bytes.length - QI_HEADER_BYTES !== encoderLen + calibrationLen
109
+ || encoderLen > bytes.length || calibrationLen > bytes.length) {
110
+ throw new Error('inherited-v1: segment layout is inconsistent');
111
+ }
112
+
113
+ let decl;
114
+ try {
115
+ decl = JSON.parse(decoder.decode(bytes.subarray(QI_HEADER_BYTES, QI_HEADER_BYTES + encoderLen)));
116
+ } catch (err) {
117
+ throw new Error('inherited-v1: encoder region is not valid JSON', { cause: err });
118
+ }
119
+ if (!decl || typeof decl !== 'object' || Array.isArray(decl)) {
120
+ throw new Error('inherited-v1: encoder region must be a JSON object');
121
+ }
122
+ if (!isHex64(decl.inheritFrom)) throw new Error('inherited-v1: inheritFrom must be 64 lowercase hex characters');
123
+ if (!isHex64(decl.queryInterpSha256)) throw new Error('inherited-v1: queryInterpSha256 must be 64 lowercase hex characters');
124
+
125
+ if (!expect || typeof expect !== 'object') throw new Error('inherited-v1: no expectation supplied');
126
+ // MUST equal manifest layer.baseIdentity (4.5).
127
+ if (decl.inheritFrom !== expect.layerBaseIdentity) {
128
+ throw new Error(`inherited-v1: inheritFrom ${decl.inheritFrom.slice(0, 12)}… does not equal the manifest's layer.baseIdentity ${String(expect.layerBaseIdentity).slice(0, 12)}…`);
129
+ }
130
+ // MUST equal the base manifest's query-interp segment digest (4.5). This is
131
+ // the encoder invariant: it binds the base's encoder bytes and calibration
132
+ // together, so a layer cannot load a different encoder.
133
+ if (decl.queryInterpSha256 !== expect.baseQueryInterpSha256) {
134
+ throw new Error(`inherited-v1: queryInterpSha256 ${decl.queryInterpSha256.slice(0, 12)}… does not match the base's query-interp digest ${String(expect.baseQueryInterpSha256).slice(0, 12)}…`);
135
+ }
136
+
137
+ let calibration;
138
+ try {
139
+ calibration = JSON.parse(decoder.decode(bytes.subarray(QI_HEADER_BYTES + encoderLen)));
140
+ } catch (err) {
141
+ throw new Error('inherited-v1: calibration region is not valid JSON', { cause: err });
142
+ }
143
+ if (!calibration || typeof calibration !== 'object' || Array.isArray(calibration)) {
144
+ throw new Error('inherited-v1: calibration region must be a JSON object');
145
+ }
146
+
147
+ let bloomBytes = null;
148
+ if (calibration.kind === LAYER_VOCAB_KIND) {
149
+ const geom = calibration.vocabBloom;
150
+ if (!geom || typeof geom !== 'object') throw new Error('inherited-v1: layer-vocab-v1 needs a vocabBloom object');
151
+ if (typeof calibration.vocabBloomBase64 !== 'string') {
152
+ throw new Error('inherited-v1: layer-vocab-v1 needs vocabBloomBase64');
153
+ }
154
+ bloomBytes = bytesFromBase64(calibration.vocabBloomBase64);
155
+ if (bloomBytes.length * 8 !== geom.bits) {
156
+ throw new Error(`inherited-v1: bloom decodes to ${bloomBytes.length * 8} bits but vocabBloom.bits says ${geom.bits}`);
157
+ }
158
+ // The geometry MUST match the base asset's, which is what makes the
159
+ // union of 4.5 an exact bitwise OR rather than an approximation.
160
+ if (expect.baseBloom) {
161
+ if (geom.bits !== expect.baseBloom.bits) {
162
+ throw new Error(`inherited-v1: layer bloom is ${geom.bits} bits but the base's is ${expect.baseBloom.bits}; the union would not be exact`);
163
+ }
164
+ const a = JSON.stringify(geom.hashes);
165
+ const b = JSON.stringify(expect.baseBloom.hashes);
166
+ if (a !== b) {
167
+ throw new Error(`inherited-v1: layer bloom hashes ${a} differ from the base's ${b}`);
168
+ }
169
+ }
170
+ } else if (calibration.kind !== 'none') {
171
+ throw new Error(`inherited-v1: calibration kind must be ${LAYER_VOCAB_KIND} or "none", got ${JSON.stringify(calibration.kind)}`);
172
+ }
173
+
174
+ return {
175
+ version, kind,
176
+ inheritFrom: decl.inheritFrom,
177
+ queryInterpSha256: decl.queryInterpSha256,
178
+ calibrationKind: calibration.kind,
179
+ bloomBytes,
180
+ bloomGeometry: bloomBytes ? { bits: calibration.vocabBloom.bits, hashes: calibration.vocabBloom.hashes } : null,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * The union bloom of 4.5: the bitwise OR of the base's and every layer's. The
186
+ * identical geometry makes it exact. A reader MUST compute this before scoring
187
+ * — without it the known-token signal under-reads every query about a layer's
188
+ * content, and the scorer abstains on exactly the records the layer added.
189
+ *
190
+ * Note what the union means: it covers the vocabulary of every record EVER
191
+ * appended, live or tombstoned. Nothing can be removed from a bloom, so this
192
+ * is not, and cannot be made, a bloom over the live vocabulary. Section 13.2
193
+ * measured the consequence.
194
+ */
195
+ export function unionBloom(blooms) {
196
+ const present = blooms.filter(Boolean);
197
+ if (!present.length) return null;
198
+ const bytes = present[0].length;
199
+ for (const b of present) {
200
+ if (b.length !== bytes) {
201
+ throw new Error(`unionBloom: geometry mismatch — ${b.length} bytes against ${bytes}; every layer's bloom must use the base's bits`);
202
+ }
203
+ }
204
+ const out = new Uint8Array(bytes);
205
+ out.set(present[0]);
206
+ for (let i = 1; i < present.length; i++) {
207
+ const b = present[i];
208
+ for (let j = 0; j < bytes; j++) out[j] |= b[j];
209
+ }
210
+ return out;
211
+ }
212
+
213
+ function base64FromBytes(bytes) {
214
+ if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
215
+ let s = '';
216
+ for (const b of bytes) s += String.fromCharCode(b);
217
+ return btoa(s);
218
+ }
219
+
220
+ function bytesFromBase64(text) {
221
+ if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(text, 'base64'));
222
+ return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
223
+ }