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