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
@@ -8,6 +8,10 @@ const decoder = new TextDecoder();
8
8
  // here, host-side, before the first forward.
9
9
  export const KERNEL_LAYOUT = { V: 30522, P: 512, T: 2, D: 384, F: 1536, L: 6, B: 64, H: 12 };
10
10
  const KERNEL_MAX_SEQ = 512;
11
+ // Smallest window that still frames interior tokens between [CLS] and [SEP]
12
+ // with room to make progress; see the maxTokens check in
13
+ // parseInlineTransformerEncoder().
14
+ const MIN_DECLARED_MAX_TOKENS = 8;
11
15
 
12
16
  // Byte size the kernel's fill_layout() will consume for a given layout:
13
17
  // each quantized matrix is rows*cols u8 plus per-block f32 scales and
@@ -43,6 +47,22 @@ export function parseInlineTransformerEncoder(encoderBytes) {
43
47
  if (declaration.dim !== KERNEL_LAYOUT.D) {
44
48
  throw new Error(`.pikelet inline-encoder declares dim ${declaration.dim}; the kernel emits ${KERNEL_LAYOUT.D}`);
45
49
  }
50
+ // maxTokens sizes the kernel's ids/hidden heap buffers, and the windowing
51
+ // arithmetic (windowLen = maxSeq - 2) assumes it leaves room for [CLS] and
52
+ // [SEP] plus interior tokens. A pack declaring 1 makes windowLen negative,
53
+ // so interior.slice(w * windowLen, (w + 1) * windowLen) returns nearly the
54
+ // whole sequence and the forward pass writes far past both allocations; 2
55
+ // makes windowLen 0, so the window count is Infinity. Reject the whole
56
+ // range here, at parse, so nothing is allocated from an unusable value.
57
+ // The floor is well under any value the toolchain emits (the builder
58
+ // declares 512; the BEIR encoder's own --max-tokens floor is 16).
59
+ if (declaration.maxTokens !== undefined && declaration.maxTokens !== null) {
60
+ const declared = declaration.maxTokens;
61
+ if (!Number.isInteger(declared) || declared < MIN_DECLARED_MAX_TOKENS || declared > KERNEL_MAX_SEQ) {
62
+ throw new Error(`.pikelet inline-encoder declares maxTokens ${declared}; `
63
+ + `must be an integer in [${MIN_DECLARED_MAX_TOKENS}, ${KERNEL_MAX_SEQ}]`);
64
+ }
65
+ }
46
66
  const expected = expectedBlobBytes(layout);
47
67
  if (blob.length !== expected) {
48
68
  throw new Error(`.pikelet inline-encoder blob is ${blob.length} bytes but its declared layout implies ${expected}`);
@@ -63,7 +83,14 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
63
83
  const tokenizer = createWordPiece(vocabText);
64
84
  const EM = await createEncoder();
65
85
  const dim = declaration.dim;
66
- const maxSeq = Math.min(declaration.maxTokens || KERNEL_MAX_SEQ, KERNEL_MAX_SEQ);
86
+ // Clamped on BOTH sides: parseInlineTransformerEncoder() rejects an
87
+ // out-of-range declaration, but this function is also called directly (the
88
+ // BEIR ladder, encoder ablations), so the buffer sizing must not depend on
89
+ // that check having run.
90
+ const maxSeq = Math.min(
91
+ Math.max(MIN_DECLARED_MAX_TOKENS, Math.trunc(declaration.maxTokens) || KERNEL_MAX_SEQ),
92
+ KERNEL_MAX_SEQ,
93
+ );
67
94
  const blobPtr = EM._malloc(blob.length);
68
95
  EM.HEAPU8.set(blob, blobPtr);
69
96
  const idsPtr = EM._malloc(maxSeq * 4);
@@ -88,6 +115,11 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
88
115
  const windowsToEncode = usesClsPooling ? 1 : windows;
89
116
  for (let w = 0; w < windowsToEncode; w++) {
90
117
  const tokenIds = [allIds[0], ...interior.slice(w * windowLen, (w + 1) * windowLen), allIds[allIds.length - 1]];
118
+ // The windowing above must never produce more tokens than the buffers
119
+ // were sized for; fail closed instead of writing past them.
120
+ if (tokenIds.length > maxSeq) {
121
+ throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
122
+ }
91
123
  new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
92
124
  const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
93
125
  if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
@@ -112,11 +144,75 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
112
144
  return { vector: pooled, text, windows };
113
145
  }
114
146
 
147
+ // Per-word vectors from the same forward pass embed() already runs — the
148
+ // kernel's outHidden buffer holds every token's final-layer hidden state
149
+ // (encoder.cpp's encoder_forward does no pooling itself; JS pools it),
150
+ // so this is not a second, slower embedding path: it is the same
151
+ // forward pass, with the per-token buffer copied out and pooled per
152
+ // WORD (via encodeWithWordMap's word alignment, merging WordPiece
153
+ // sub-tokens like "quant"+"##ization" into one vector) instead of
154
+ // discarded after sentence-level pooling. Used for MaxSim-style
155
+ // token-level grounding (complete/retrieval-abstention.mjs, calibrate.
156
+ // mjs), an alternative to coverage1's exact/stemmed word match that
157
+ // measures semantic closeness instead of lexical identity — so a query
158
+ // and passage that use different words for the same idea can still
159
+ // ground each other, the case coverage1 structurally cannot handle.
160
+ // Windowing/CLS-pooling limits mirror embed(): a word that falls past
161
+ // the first window under CLS pooling, or past the last window under
162
+ // mean pooling's cap, has no vector and is left out of the result.
163
+ async function embedWords(text) {
164
+ const { ids: allIds, wordOf: allWordOf, words } = tokenizer.encodeWithWordMap(String(text || ''));
165
+ const interior = allIds.slice(1, -1);
166
+ const interiorWordOf = allWordOf.slice(1, -1);
167
+ const windowLen = maxSeq - 2;
168
+ const windows = Math.max(1, Math.ceil(interior.length / windowLen));
169
+ const windowsToEncode = usesClsPooling ? 1 : windows;
170
+ // Accumulate per-word sums, then average — a word split into several
171
+ // WordPiece pieces gets one vector, not several.
172
+ const wordSums = words.map(() => new Float64Array(dim));
173
+ const wordCounts = words.map(() => 0);
174
+ for (let w = 0; w < windowsToEncode; w++) {
175
+ const idSlice = interior.slice(w * windowLen, (w + 1) * windowLen);
176
+ const wordOfSlice = interiorWordOf.slice(w * windowLen, (w + 1) * windowLen);
177
+ const tokenIds = [allIds[0], ...idSlice, allIds[allIds.length - 1]];
178
+ // The windowing above must never produce more tokens than the buffers
179
+ // were sized for; fail closed instead of writing past them.
180
+ if (tokenIds.length > maxSeq) {
181
+ throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
182
+ }
183
+ new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
184
+ const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
185
+ if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
186
+ const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
187
+ // Offset by 1: tokenIds[0] is [CLS], so idSlice's tokens start at
188
+ // hidden row 1, matching wordOfSlice's own indexing into idSlice.
189
+ for (let t = 0; t < wordOfSlice.length; t++) {
190
+ const wi = wordOfSlice[t];
191
+ if (wi === -1) continue;
192
+ const row = (t + 1) * dim;
193
+ for (let d = 0; d < dim; d++) wordSums[wi][d] += hidden[row + d];
194
+ wordCounts[wi]++;
195
+ }
196
+ }
197
+ const vectors = new Map();
198
+ for (let wi = 0; wi < words.length; wi++) {
199
+ if (wordCounts[wi] === 0) continue;
200
+ const v = new Float32Array(dim);
201
+ let norm = 0;
202
+ for (let d = 0; d < dim; d++) { v[d] = wordSums[wi][d] / wordCounts[wi]; norm += v[d] ** 2; }
203
+ norm = Math.sqrt(norm) || 1;
204
+ for (let d = 0; d < dim; d++) v[d] /= norm;
205
+ vectors.set(words[wi], v);
206
+ }
207
+ return vectors;
208
+ }
209
+
115
210
  const embedder = {
116
211
  declaration,
117
212
  dim,
118
213
  maxSeq,
119
214
  embed,
215
+ embedWords,
120
216
  dispose() {
121
217
  EM._free(hiddenPtr);
122
218
  EM._free(idsPtr);
@@ -0,0 +1,246 @@
1
+ // `append` — LAYERED_PROFILE.md 6.1.
2
+ //
3
+ // Compiles one layer on top of a mounted chain: new records, a cumulative
4
+ // tombstone bitset, and a commitment to the parent's identity. The hard parts
5
+ // are not the bytes but the preconditions, so they are checked first and
6
+ // separately, and each refusal names the rule it enforces.
7
+ //
8
+ // Two things the complete profile never promised are required to append:
9
+ //
10
+ // 1. An encoder that can embed PASSAGES. A kind-3 base carries the teacher,
11
+ // so a pack is sufficient to compile its own successors. A kind-2 base
12
+ // names its encoder and the host supplies it. A KIND-1 base carries a
13
+ // corpus-distilled QUERY encoder whose passages were embedded by a teacher
14
+ // the artifact neither carries nor verifies — so a kind-1 base is readable
15
+ // but NOT appendable, and append MUST refuse it.
16
+ // 2. An ingestion declaration, because record granularity is part of what the
17
+ // calibration saw and MUST NOT change mid-chain. A base compiled after
18
+ // this profile carries `corpus.ingest`; an older one does not, and append
19
+ // MUST refuse unless the operator asserts one.
20
+ //
21
+ // This module does the planning and assembly that is testable in-process. It
22
+ // takes already-embedded vectors rather than embedding itself, so it stays
23
+ // environment-neutral and so a test can drive it without a 24 MiB encoder.
24
+
25
+ import { buildTombstoneSegment, zeroExtend, bitsetBytesFor, testBit, popcountBytes, MAX_RECORDS } from './tombstones.mjs';
26
+ import { buildInheritedQuerySegment } from './layer-encoder.mjs';
27
+ import { canonicalJson } from './layer-manifest.mjs';
28
+
29
+ // 6.1: the producer default is 8 — "an operational recommendation until the
30
+ // range-proof measurements of section 13 say otherwise; the format ceiling of
31
+ // 16 is enforced regardless". 13.5 still lists it as unmeasured.
32
+ export const PRODUCER_MAX_DEPTH = 8;
33
+ export const FORMAT_MAX_DEPTH = 16;
34
+
35
+ /**
36
+ * Decide whether a base can be appended to, and how its encoder is obtained.
37
+ * Separated from the assembly because it is the part with a normative answer
38
+ * for every base ever released.
39
+ *
40
+ * @param {{qiKind: number, corpusIngest: object|null, assertIngest: object|null}} spec
41
+ * @returns {{appendable: true, encoderSource: 'pack'|'host', ingest: object, ingestAsserted: boolean}}
42
+ */
43
+ export function planAppendability({ qiKind, corpusIngest = null, assertIngest = null }) {
44
+ if (qiKind === 1) {
45
+ throw new Error('a kind-1 base carries a corpus-distilled query encoder whose passages were embedded by a teacher the artifact does not carry: readable, but not appendable (6.1)');
46
+ }
47
+ if (qiKind === 4) {
48
+ throw new Error('a layer is not a base: append to the chain\'s head, whose base supplies the encoder (4.5)');
49
+ }
50
+ let encoderSource;
51
+ if (qiKind === 3) encoderSource = 'pack'; // the pack carries its teacher
52
+ else if (qiKind === 2) encoderSource = 'host'; // the host supplies the named encoder
53
+ else throw new Error(`unsupported query-interpretation kind ${qiKind} for append`);
54
+
55
+ if (corpusIngest !== null && corpusIngest !== undefined) {
56
+ if (assertIngest) {
57
+ throw new Error('the base declares corpus.ingest; --assert-ingest would override an artifact\'s own declaration (6.1)');
58
+ }
59
+ return { appendable: true, encoderSource, ingest: corpusIngest, ingestAsserted: false };
60
+ }
61
+ if (!assertIngest) {
62
+ throw new Error('the base carries no corpus.ingest, so record granularity cannot be enforced; supply --assert-ingest to state it (6.1). The assertion is the operator\'s statement, not the artifact\'s.');
63
+ }
64
+ if (typeof assertIngest !== 'object' || Array.isArray(assertIngest)) {
65
+ throw new Error('--assert-ingest must be a JSON object');
66
+ }
67
+ if ('ingestAsserted' in assertIngest) {
68
+ throw new Error('the asserted declaration must not contain ingestAsserted: it is a sibling field, so asserting must not change the declaration\'s canonical form (4.1)');
69
+ }
70
+ return { appendable: true, encoderSource, ingest: assertIngest, ingestAsserted: true };
71
+ }
72
+
73
+ /**
74
+ * Plan a layer's ids, mask and supersessions — step 4 and 5 of 6.1.
75
+ *
76
+ * @param {{parentRowBase: number, parentRecords: number, parentBitset: Uint8Array,
77
+ * newRecordCount: number, remove?: number[], supersede?: Array<[number, string|number]>,
78
+ * maxDepth?: number, parentDepth: number}} spec
79
+ */
80
+ export function planLayer({
81
+ parentRowBase, parentRecords, parentBitset,
82
+ newRecordCount, remove = [], supersede = [],
83
+ parentDepth, maxDepth = PRODUCER_MAX_DEPTH,
84
+ }) {
85
+ const depth = parentDepth + 1;
86
+ if (depth > FORMAT_MAX_DEPTH) {
87
+ throw new Error(`appending would reach depth ${depth}, past the format ceiling of ${FORMAT_MAX_DEPTH} (2.7)`);
88
+ }
89
+ if (depth > maxDepth) {
90
+ throw new Error(`appending would reach depth ${depth}, past --max-depth ${maxDepth}; compact the chain instead (6.1)`);
91
+ }
92
+ const rowBase = parentRowBase + parentRecords;
93
+ if (!Number.isSafeInteger(rowBase) || rowBase < 0) throw new Error('parent row arithmetic is not a safe integer');
94
+ // 3.3: a producer MUST refuse an append whose rowBase + records would
95
+ // exceed the id ceiling. There is no other mechanism.
96
+ if (rowBase + newRecordCount > MAX_RECORDS) {
97
+ throw new Error(`appending ${newRecordCount} records at rowBase ${rowBase} would exhaust the id space (${MAX_RECORDS}); compact the chain (3.3)`);
98
+ }
99
+
100
+ // Step 5: the cumulative bitset is zeroExtend(T_parent, rowBase), then
101
+ // every --remove id and every --supersede old id.
102
+ const inherited = zeroExtend(parentBitset, rowBase);
103
+ const bitset = new Uint8Array(bitsetBytesFor(rowBase));
104
+ bitset.set(inherited.subarray(0, bitset.length));
105
+ const introduced = [];
106
+ const setOne = (id, what) => {
107
+ if (!Number.isSafeInteger(id) || id < 0) throw new Error(`${what} id ${id} is not a valid id`);
108
+ // A layer cannot retract its own records: ids at or above rowBase are
109
+ // out of range by construction (3.4).
110
+ if (id >= rowBase) {
111
+ throw new Error(`${what} id ${id} is at or above this layer's rowBase ${rowBase}: a layer cannot retract its own records (3.4)`);
112
+ }
113
+ if (testBit(bitset, id)) return false; // already deleted is a no-op
114
+ bitset[id >> 3] |= 1 << (id & 7);
115
+ introduced.push(id);
116
+ return true;
117
+ };
118
+ for (const id of remove) setOne(id, '--remove');
119
+
120
+ // Each pair is [oldId, target]. `target` is the CALLER's way of naming
121
+ // which new record supersedes oldId: a number is a resolved local index
122
+ // into this layer's own records, which the producer computed by matching
123
+ // the --supersede path against the chunks that path produced. A
124
+ // non-numeric target (a raw path the producer could not resolve) falls
125
+ // back to positional assignment, which is what this did for every pair —
126
+ // so `--supersede 5=a.md --supersede 9=b.md` bound 5 to whichever chunk
127
+ // happened to land first, regardless of which file produced it.
128
+ const supersessions = [];
129
+ let nextNewId = rowBase;
130
+ const claimed = new Set();
131
+ for (const [oldId, target] of supersede) {
132
+ let newId;
133
+ if (Number.isSafeInteger(target)) {
134
+ if (target < 0 || target >= newRecordCount) {
135
+ throw new Error(`--supersede resolved to local record ${target}, outside this layer's ${newRecordCount} record(s) (3.5)`);
136
+ }
137
+ newId = rowBase + target;
138
+ if (claimed.has(newId)) {
139
+ throw new Error(`--supersede maps two old ids onto new record ${newId}: each new record supersedes at most one predecessor (3.5)`);
140
+ }
141
+ } else {
142
+ while (claimed.has(nextNewId)) nextNewId += 1;
143
+ if (nextNewId >= rowBase + newRecordCount) {
144
+ throw new Error('more --supersede pairs than new records: every newId must lie in this layer\'s own id range (3.5)');
145
+ }
146
+ newId = nextNewId;
147
+ nextNewId += 1;
148
+ }
149
+ setOne(oldId, '--supersede');
150
+ supersessions.push([oldId, newId]);
151
+ claimed.add(newId);
152
+ }
153
+ // 3.5: within one layer each oldId appears at most once.
154
+ const seen = new Set();
155
+ for (const [oldId] of supersessions) {
156
+ if (seen.has(oldId)) throw new Error(`--supersede names id ${oldId} more than once: an oldId has at most one edge per layer (3.5)`);
157
+ seen.add(oldId);
158
+ }
159
+
160
+ // 6.1: "A layer must do something." A layer that introduces neither a
161
+ // record nor a new tombstone would still lengthen every chain carrying it,
162
+ // cost a mount round trip and count against the depth limits, for no
163
+ // corpus meaning. This is also why 4.3 requires a tombstone-only layer's
164
+ // tombstoneCount to EXCEED its parent's.
165
+ if (newRecordCount === 0 && introduced.length === 0) {
166
+ throw new Error('refusing to emit a layer that introduces neither a record nor a new tombstone (6.1): --remove of already-deleted ids alone produces nothing');
167
+ }
168
+
169
+ return {
170
+ depth, rowBase, records: newRecordCount,
171
+ bitset, tombstones: popcountBytes(bitset),
172
+ delta: introduced.sort((a, b) => a - b),
173
+ supersessions,
174
+ tombstoneOnly: newRecordCount === 0,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Assemble the layer's manifest fields and its tombstone + kind-4 segments.
180
+ * The caller supplies the index/corpus/lexical segment bytes, since building
181
+ * those is the existing builder's job.
182
+ */
183
+ export function buildLayerSegments({ plan, baseIdentity, parentIdentity, baseQueryInterpSha256, parentLocator = null, ingest, ingestAsserted, dim, metric, encoder, corpus, layerBloom = null }) {
184
+ const tombstones = buildTombstoneSegment({
185
+ rowBase: plan.rowBase,
186
+ tombstonedIds: idsSetIn(plan.bitset, plan.rowBase),
187
+ supersessions: plan.supersessions,
188
+ records: plan.records,
189
+ });
190
+ const queryInterp = buildInheritedQuerySegment({
191
+ baseIdentity, queryInterpSha256: baseQueryInterpSha256,
192
+ vocabBloom: layerBloom ? layerBloom.geometry : null,
193
+ vocabBloomBytes: layerBloom ? layerBloom.bytes : null,
194
+ });
195
+ const manifestFields = {
196
+ profile: 'pikelet-layer-v1',
197
+ dim, metric, encoder,
198
+ layer: {
199
+ parent: { identity: parentIdentity, ...(parentLocator ? { locator: parentLocator } : {}) },
200
+ baseIdentity,
201
+ depth: plan.depth,
202
+ rowBase: plan.rowBase,
203
+ records: plan.records,
204
+ tombstones: plan.tombstones,
205
+ supersessions: plan.supersessions.length,
206
+ ingest,
207
+ // 4.1: a SIBLING of layer.ingest, never a member — asserting must
208
+ // not change the declaration's canonical form.
209
+ ...(ingestAsserted ? { ingestAsserted: true } : {}),
210
+ },
211
+ // A tombstone-only layer still carries a corpus object, with records 0,
212
+ // and omits the layout fields (4.1).
213
+ corpus: plan.tombstoneOnly ? { records: 0, ...(corpus?.provenance ? { provenance: corpus.provenance } : {}) } : corpus,
214
+ // A layer with records carries an index object; assemblePikeletFile
215
+ // fills in headerSha256 from the sketch bytes, but the object must be
216
+ // present for the manifest to be a valid layer manifest (4.1). A
217
+ // tombstone-only layer omits it, having no index segment (4.3).
218
+ ...(plan.tombstoneOnly ? {} : { index: {} }),
219
+ };
220
+ return { manifestFields, tombstones, queryInterp };
221
+ }
222
+
223
+ /**
224
+ * The drift an append would produce, and whether the producer may emit it.
225
+ * 6.1: append MUST refuse a layer whose drift would exceed the chain's
226
+ * effective limit unless --allow-drift is given.
227
+ */
228
+ export function checkAppendDrift({ baseRecords, appendedBefore, newRecords, headTombstones, effectiveLimit, allowDrift = false }) {
229
+ if (!Number.isSafeInteger(baseRecords) || baseRecords <= 0) {
230
+ throw new Error('drift needs a base with at least one record');
231
+ }
232
+ const drift = (appendedBefore + newRecords + headTombstones) / baseRecords;
233
+ if (drift > effectiveLimit && !allowDrift) {
234
+ throw new Error(`this append would take calibrationDrift to ${drift.toFixed(4)}, past the chain's effective limit ${effectiveLimit}; pass --allow-drift to emit it anyway (the chain then serves unscored) (6.1)`);
235
+ }
236
+ return { drift, exceedsLimit: drift > effectiveLimit };
237
+ }
238
+
239
+ /** Every id set in a bitset, ascending. */
240
+ export function idsSetIn(bitset, rowBase) {
241
+ const out = [];
242
+ for (let id = 0; id < rowBase; id++) if (testBit(bitset, id)) out.push(id);
243
+ return out;
244
+ }
245
+
246
+ export { canonicalJson };
@@ -0,0 +1,284 @@
1
+ // Chain state — LAYERED_PROFILE.md 5.1 (steps 4-5), 5.2, 5.3, 5.5, 5.6.
2
+ //
3
+ // Everything a mounted chain needs that is not bytes: the ordered interval
4
+ // table of search tiers, id ownership, the head mask projected onto each tier's
5
+ // local range, the union supersession map, calibration drift, and the ancestry
6
+ // used to scope citations.
7
+ //
8
+ // Pure: the caller supplies already-verified per-member facts. That keeps the
9
+ // whole of 5.2/5.3/5.6 testable without building files, and keeps the sharp
10
+ // edges in one place.
11
+
12
+ import { testBit, bitsetBytesFor } from './tombstones.mjs';
13
+
14
+ /**
15
+ * A **search tier** (3.1) is the base, or any layer with records >= 1: a chain
16
+ * member that owns ids. Tombstone-only layers are chain members but not search
17
+ * tiers, and take no part in id resolution.
18
+ *
19
+ * Every per-tier construction in section 5 ranges over search tiers, so the
20
+ * base is always included — "for every layer" would literally have excluded it,
21
+ * which is the bug the term exists to prevent.
22
+ */
23
+ export function buildIntervalTable(members) {
24
+ if (!Array.isArray(members) || members.length === 0) {
25
+ throw new Error('chain has no members');
26
+ }
27
+ if (!members[0] || members[0].depth !== 0) {
28
+ throw new Error('the first chain member must be the base (depth 0)');
29
+ }
30
+ const tiers = [];
31
+ let expectedRowBase = 0;
32
+ let expectedDepth = 0;
33
+ for (const m of members) {
34
+ if (m.depth !== expectedDepth) {
35
+ throw new Error(`chain member at index ${expectedDepth} declares depth ${m.depth}`);
36
+ }
37
+ const rowBase = m.depth === 0 ? 0 : m.rowBase;
38
+ if (rowBase !== expectedRowBase) {
39
+ throw new Error(`chain member at depth ${m.depth} has rowBase ${rowBase}, expected ${expectedRowBase}`);
40
+ }
41
+ if (!Number.isSafeInteger(m.records) || m.records < 0) {
42
+ throw new Error(`chain member at depth ${m.depth} has a bad record count`);
43
+ }
44
+ if (m.records > 0) {
45
+ tiers.push({
46
+ tier: tiers.length,
47
+ depth: m.depth,
48
+ identity: m.identity,
49
+ rowBase,
50
+ records: m.records,
51
+ end: rowBase + m.records,
52
+ });
53
+ }
54
+ expectedRowBase = rowBase + m.records;
55
+ expectedDepth += 1;
56
+ }
57
+ if (!tiers.length) throw new Error('a chain must have at least one search tier');
58
+ // Search tiers' rowBase values are strictly increasing, since each owns at
59
+ // least one id. This is the property that makes binary search valid over
60
+ // THIS table and invalid over all members (5.2).
61
+ for (let i = 1; i < tiers.length; i++) {
62
+ if (tiers[i].rowBase <= tiers[i - 1].rowBase) {
63
+ throw new Error('search tier rowBase values must be strictly increasing');
64
+ }
65
+ }
66
+ return { tiers, rowTotal: expectedRowBase };
67
+ }
68
+
69
+ /**
70
+ * `owner(id)` — 5.2. The unique search tier whose interval contains `id`.
71
+ *
72
+ * Binary search over the SEARCH TIERS only. Searching "the largest rowBase <=
73
+ * id" over all chain members is wrong: consecutive tombstone-only layers give
74
+ * the next layer the same rowBase (3.3), so rowBase is not a unique key over
75
+ * members and such a search can land on a member that owns no ids at all.
76
+ */
77
+ export function ownerOf(table, id) {
78
+ if (!Number.isSafeInteger(id) || id < 0) return null;
79
+ const { tiers } = table;
80
+ let lo = 0;
81
+ let hi = tiers.length - 1;
82
+ while (lo <= hi) {
83
+ const mid = (lo + hi) >> 1;
84
+ const t = tiers[mid];
85
+ if (id < t.rowBase) hi = mid - 1;
86
+ else if (id >= t.end) lo = mid + 1;
87
+ else return { tier: t, localId: id - t.rowBase };
88
+ }
89
+ // Ids outside every interval are invalid and MUST be rejected (5.2).
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Project the head's bitset onto each search tier's local range (5.3):
95
+ * `mask_j[i] = T_head[rowBase_j + i]`, computed once at mount.
96
+ *
97
+ * Returned as one Uint8Array per tier, LSB-first, so a tier's scan can test a
98
+ * local row with one bit test and no arithmetic against the global id space.
99
+ */
100
+ export function projectMasks(table, headBitset) {
101
+ const masks = [];
102
+ for (const t of table.tiers) {
103
+ const bytes = bitsetBytesFor(t.records);
104
+ const mask = new Uint8Array(bytes);
105
+ if (headBitset && headBitset.length) {
106
+ for (let i = 0; i < t.records; i++) {
107
+ if (testBit(headBitset, t.rowBase + i)) mask[i >> 3] |= 1 << (i & 7);
108
+ }
109
+ }
110
+ masks.push(mask);
111
+ }
112
+ return masks;
113
+ }
114
+
115
+ /** Live record count: rowTotal minus the head's tombstone popcount. */
116
+ export function liveCount(table, headTombstoneCount) {
117
+ return table.rowTotal - (headTombstoneCount || 0);
118
+ }
119
+
120
+ /**
121
+ * The union supersession map (5.1 step 4, 3.5). Each layer's segment carries
122
+ * only the edges that layer recorded; the reader forms the union at mount.
123
+ *
124
+ * Edges are ordered by the DEPTH of the layer that recorded them — never by
125
+ * segment order, file order or id — so "earliest" and "latest" are always
126
+ * unique (within one layer an oldId has at most one edge).
127
+ */
128
+ export function buildSupersessionMap(perLayer) {
129
+ const map = new Map();
130
+ for (const { depth, supersessions } of perLayer) {
131
+ for (const [oldId, newId] of supersessions || []) {
132
+ if (!map.has(oldId)) map.set(oldId, []);
133
+ map.get(oldId).push({ depth, newId });
134
+ }
135
+ }
136
+ for (const edges of map.values()) {
137
+ edges.sort((a, b) => a.depth - b.depth);
138
+ for (let i = 1; i < edges.length; i++) {
139
+ if (edges[i].depth === edges[i - 1].depth) {
140
+ throw new Error(`two supersessions for the same oldId at depth ${edges[i].depth}: within one layer an oldId has at most one edge`);
141
+ }
142
+ }
143
+ }
144
+ return map;
145
+ }
146
+
147
+ /**
148
+ * The three views of 3.5.
149
+ * - immediate: the edge recorded at the LEAST depth
150
+ * - all: every edge, ascending by depth
151
+ * - current: start from the GREATEST-depth edge and repeatedly follow each
152
+ * reached id's greatest-depth edge until an id with none
153
+ *
154
+ * Cycles are structurally impossible (newId >= rowBase_j > oldId, so edges
155
+ * always point to higher ids), but the walk is bounded anyway: a corrupted map
156
+ * must not hang a reader.
157
+ */
158
+ export function resolveSupersession(map, id) {
159
+ const edges = map.get(id);
160
+ if (!edges || !edges.length) return { immediate: null, all: [], current: null };
161
+ const immediate = edges[0].newId;
162
+ const all = edges.map((e) => e.newId);
163
+ let current = edges[edges.length - 1].newId;
164
+ const seen = new Set([id, current]);
165
+ for (let hops = 0; hops < 64; hops++) {
166
+ const next = map.get(current);
167
+ if (!next || !next.length) break;
168
+ const candidate = next[next.length - 1].newId;
169
+ if (seen.has(candidate)) break;
170
+ seen.add(candidate);
171
+ current = candidate;
172
+ }
173
+ return { immediate, all, current };
174
+ }
175
+
176
+ /**
177
+ * Calibration drift (3.1):
178
+ * (sum of layer records for depth >= 1 + head tombstones) / base records
179
+ *
180
+ * It counts HISTORY, not current divergence: a record appended and later
181
+ * tombstoned counts twice, and a supersession counts as one append plus one
182
+ * tombstone. Intentionally conservative — the fit's population lost a member it
183
+ * had seen and gained one it had not.
184
+ */
185
+ export function calibrationDrift(members, headTombstoneCount) {
186
+ const base = members[0];
187
+ if (!base || base.depth !== 0) throw new Error('calibrationDrift needs the base as the first member');
188
+ if (!Number.isSafeInteger(base.records) || base.records <= 0) {
189
+ throw new Error('calibrationDrift needs a base with at least one record');
190
+ }
191
+ let appended = 0;
192
+ for (const m of members) if (m.depth >= 1) appended += m.records;
193
+ return (appended + (headTombstoneCount || 0)) / base.records;
194
+ }
195
+
196
+ /**
197
+ * Calibration status (5.5). The effective limit is
198
+ * `min(producerEnvelope, readerLimit)`: a host can always be stricter than the
199
+ * artifact, and an artifact can never make a host less strict.
200
+ *
201
+ * An absent producer envelope is +Infinity (no claim is no constraint), and the
202
+ * reader's limit MUST be finite, so an artifact that declares nothing is still
203
+ * gated.
204
+ */
205
+ export function calibrationStatus({ drift, producerEnvelope, readerLimit, baseHasFit, everyLayerShipsBloom }) {
206
+ if (!Number.isFinite(readerLimit)) {
207
+ throw new Error('readerLimit MUST be finite (5.5)');
208
+ }
209
+ const envelope = Number.isFinite(producerEnvelope) ? producerEnvelope : Infinity;
210
+ const effectiveLimit = Math.min(envelope, readerLimit);
211
+ if (!baseHasFit) {
212
+ return { status: 'none', drift, effectiveLimit, producerEnvelope: envelope, readerLimit };
213
+ }
214
+ // A layer whose calibration region is `none` while the base carries a fit
215
+ // degrades the chain to unscored (4.5, 5.5).
216
+ const status = (drift <= effectiveLimit && everyLayerShipsBloom) ? 'inherited' : 'drift-exceeded';
217
+ return { status, drift, effectiveLimit, producerEnvelope: envelope, readerLimit };
218
+ }
219
+
220
+ /**
221
+ * Global lexical statistics over search tiers (5.4). Tombstoned records are
222
+ * counted in N, avgdl and df: removing them would require reading every posting
223
+ * list at mount.
224
+ *
225
+ * The resulting scores differ from a from-scratch build over the live set. The
226
+ * drift gate of 5.5 is a change-volume gate and implies no bound on that
227
+ * difference; 13.3 measured it at under 0.005 nDCG@10 up to 25% drift on two
228
+ * BEIR datasets, which is why no correction table ships. A reader MUST report
229
+ * `statsIncludeTombstoned: true`.
230
+ */
231
+ export function globalLexicalStats(perTier) {
232
+ let N = 0;
233
+ let totalTokens = 0;
234
+ for (const t of perTier) {
235
+ if (!Number.isSafeInteger(t.docCount) || t.docCount < 0) throw new Error('a tier has a bad docCount');
236
+ N += t.docCount;
237
+ totalTokens += t.totalTokens;
238
+ }
239
+ return {
240
+ N,
241
+ totalTokens,
242
+ avgdl: N > 0 ? Math.max(1, totalTokens / N) : 1,
243
+ statsIncludeTombstoned: true,
244
+ };
245
+ }
246
+
247
+ /** df(t) summed across tiers, and the idf the reader scores with (5.4). */
248
+ export function idfFor(N, df) {
249
+ return Math.log(1 + (N - df + 0.5) / (df + 0.5));
250
+ }
251
+
252
+ /**
253
+ * Ancestry for citation scoping (5.6): identity -> { depth, rowTotal }.
254
+ *
255
+ * A bare id is an id in the mounted head; a citation is (identity, id), and it
256
+ * resolves only along the mounted ancestry. A citation against a head that is
257
+ * on this ancestry is valid when its id existed when that head was current —
258
+ * that is, `id < rowTotal(thatHead)`.
259
+ */
260
+ export function buildAncestry(members) {
261
+ const ancestry = new Map();
262
+ let rowTotal = 0;
263
+ for (const m of members) {
264
+ rowTotal += m.records;
265
+ ancestry.set(m.identity, { depth: m.depth, rowTotal });
266
+ }
267
+ return ancestry;
268
+ }
269
+
270
+ /**
271
+ * Resolve a citation (identity, id) against the mounted ancestry (5.6).
272
+ * Returns a reason rather than throwing, because "not on this history" is a
273
+ * legitimate answer a consumer needs to be able to act on.
274
+ */
275
+ export function resolveCitation(ancestry, identity, id) {
276
+ const entry = ancestry.get(identity);
277
+ if (!entry) return { ok: false, reason: 'not-on-this-history' };
278
+ if (!Number.isSafeInteger(id) || id < 0) return { ok: false, reason: 'invalid-id' };
279
+ // An id at or above that head's rowTotal did not exist when it was current,
280
+ // so a citation naming it is invalid under that identity even though the id
281
+ // is valid in the current head.
282
+ if (id >= entry.rowTotal) return { ok: false, reason: 'id-postdates-that-head' };
283
+ return { ok: true, depth: entry.depth, id };
284
+ }