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,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
|
+
}
|