pikelet-wasm 0.7.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.
@@ -0,0 +1,205 @@
1
+ import { createWordPiece } from './wordpiece.mjs';
2
+
3
+ const decoder = new TextDecoder();
4
+
5
+ // The wasm kernel compiles these in (encoder-spike/encoder.cpp `constexpr`
6
+ // block) and walks the weight blob with a bare running cursor — it never
7
+ // reads the declaration. Declaration/blob skew is therefore only catchable
8
+ // here, host-side, before the first forward.
9
+ export const KERNEL_LAYOUT = { V: 30522, P: 512, T: 2, D: 384, F: 1536, L: 6, B: 64, H: 12 };
10
+ const KERNEL_MAX_SEQ = 128;
11
+
12
+ // Byte size the kernel's fill_layout() will consume for a given layout:
13
+ // each quantized matrix is rows*cols u8 plus per-block f32 scales and
14
+ // offsets; biases and layer norms are plain f32.
15
+ export function expectedBlobBytes(layout = KERNEL_LAYOUT) {
16
+ const { V, P, T, D, F, L, B } = layout;
17
+ const quant = (rows, cols) => rows * cols + rows * (cols / B) * 4 * 2;
18
+ const layer = 4 * quant(D, D) + 4 * (D * 4) // wq,wk,wv,wo + their biases
19
+ + 2 * (D * 4) // ln1 gamma/beta
20
+ + quant(F, D) + F * 4 // wu + bu
21
+ + quant(D, F) + D * 4 // wd + bd
22
+ + 2 * (D * 4); // ln2 gamma/beta
23
+ return quant(V, D) + quant(P, D) + quant(T, D) + 2 * (D * 4) + L * layer;
24
+ }
25
+
26
+ export function parseInlineTransformerEncoder(encoderBytes) {
27
+ const view = new DataView(encoderBytes.buffer, encoderBytes.byteOffset, encoderBytes.byteLength);
28
+ const declLen = view.getUint32(0, true);
29
+ const vocabLen = view.getUint32(4, true);
30
+ const blobLen = view.getUint32(8, true);
31
+ if (12 + declLen + vocabLen + blobLen !== encoderBytes.length) {
32
+ throw new Error('.pancake inline-encoder layout is inconsistent');
33
+ }
34
+ const declaration = JSON.parse(decoder.decode(encoderBytes.subarray(12, 12 + declLen)));
35
+ const blob = encoderBytes.subarray(12 + declLen + vocabLen);
36
+ const layout = declaration.layout || {};
37
+ for (const key of Object.keys(KERNEL_LAYOUT)) {
38
+ if (layout[key] !== KERNEL_LAYOUT[key]) {
39
+ throw new Error(`.pancake inline-encoder declares layout ${key}=${layout[key]}; `
40
+ + `the compiled kernel requires ${key}=${KERNEL_LAYOUT[key]}`);
41
+ }
42
+ }
43
+ if (declaration.dim !== KERNEL_LAYOUT.D) {
44
+ throw new Error(`.pancake inline-encoder declares dim ${declaration.dim}; the kernel emits ${KERNEL_LAYOUT.D}`);
45
+ }
46
+ const expected = expectedBlobBytes(layout);
47
+ if (blob.length !== expected) {
48
+ throw new Error(`.pancake inline-encoder blob is ${blob.length} bytes but its declared layout implies ${expected}`);
49
+ }
50
+ return {
51
+ declaration,
52
+ vocabText: decoder.decode(encoderBytes.subarray(12 + declLen, 12 + declLen + vocabLen)),
53
+ blob,
54
+ };
55
+ }
56
+
57
+ export async function createInlineTransformerEmbedder({ declaration, vocabText, blob, createEncoder, verify = true }) {
58
+ const vocabLines = vocabText.split('\n');
59
+ const vocabSize = vocabLines[vocabLines.length - 1] === '' ? vocabLines.length - 1 : vocabLines.length;
60
+ if (vocabSize > KERNEL_LAYOUT.V) {
61
+ throw new Error(`inline-encoder vocab has ${vocabSize} entries; the kernel's embedding table holds ${KERNEL_LAYOUT.V}`);
62
+ }
63
+ const tokenizer = createWordPiece(vocabText);
64
+ const EM = await createEncoder();
65
+ const dim = declaration.dim;
66
+ const maxSeq = Math.min(declaration.maxTokens || KERNEL_MAX_SEQ, KERNEL_MAX_SEQ);
67
+ const blobPtr = EM._malloc(blob.length);
68
+ EM.HEAPU8.set(blob, blobPtr);
69
+ const idsPtr = EM._malloc(maxSeq * 4);
70
+ const hiddenPtr = EM._malloc(maxSeq * dim * 4);
71
+
72
+ async function embed(text) {
73
+ const allIds = tokenizer.encode(String(text || ''));
74
+ // Inputs longer than maxSeq are mean-pooled across [CLS]…[SEP]-framed
75
+ // windows instead of truncated, so long chunks keep their tail content.
76
+ const interior = allIds.slice(1, -1);
77
+ const windowLen = maxSeq - 2;
78
+ const windows = Math.max(1, Math.ceil(interior.length / windowLen));
79
+ const pooled = new Float32Array(dim);
80
+ let pooledTokens = 0;
81
+ for (let w = 0; w < windows; w++) {
82
+ const tokenIds = [allIds[0], ...interior.slice(w * windowLen, (w + 1) * windowLen), allIds[allIds.length - 1]];
83
+ new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
84
+ const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
85
+ if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
86
+ const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
87
+ for (let t = 0; t < tokenIds.length; t++) {
88
+ for (let d = 0; d < dim; d++) pooled[d] += hidden[t * dim + d];
89
+ }
90
+ pooledTokens += tokenIds.length;
91
+ }
92
+ let norm = 0;
93
+ for (let d = 0; d < dim; d++) {
94
+ pooled[d] /= pooledTokens;
95
+ norm += pooled[d] ** 2;
96
+ }
97
+ norm = Math.sqrt(norm);
98
+ for (let d = 0; d < dim; d++) pooled[d] /= norm;
99
+ return { vector: pooled, text, windows };
100
+ }
101
+
102
+ const embedder = {
103
+ declaration,
104
+ dim,
105
+ maxSeq,
106
+ embed,
107
+ dispose() {
108
+ EM._free(hiddenPtr);
109
+ EM._free(idsPtr);
110
+ EM._free(blobPtr);
111
+ },
112
+ };
113
+ if (verify && Array.isArray(declaration.testVectors) && declaration.testVectors.length > 0) {
114
+ try {
115
+ await verifyInlineTestVectors(embedder);
116
+ } catch (err) {
117
+ embedder.dispose();
118
+ throw err;
119
+ }
120
+ }
121
+ return embedder;
122
+ }
123
+
124
+ // Conformance probes for kind-3 declarations (contract section 4.4 mode 1:
125
+ // an inline encoder carries verification vectors). The last probe exceeds
126
+ // the kernel window so the windowed mean-pool path stays pinned alongside
127
+ // the single-window path.
128
+ export const INLINE_TEST_VECTOR_TEXTS = [
129
+ 'how do volcanoes form and why do they erupt',
130
+ 'which treaty ended the first world war',
131
+ 'The water cycle describes how water moves between the oceans, the atmosphere, and the land. '
132
+ + 'Heat from the sun evaporates water from the surface of the sea, and plants release more through their leaves. '
133
+ + 'The rising vapor cools as it climbs, condensing into droplets that gather into clouds. '
134
+ + 'When the droplets grow too heavy they fall as rain, snow, or hail, depending on the temperature of the air they pass through. '
135
+ + 'Some of the water soaks into the soil and filters down to recharge underground aquifers, '
136
+ + 'while the rest runs off into streams and rivers that carry it back toward the coast. '
137
+ + 'Along the way it erodes rock, deposits sediment, and sustains wetlands, forests, and farmland. '
138
+ + 'Glaciers and ice sheets hold a share of it frozen for centuries before releasing meltwater in spring. '
139
+ + 'Eventually nearly every drop returns to the ocean, where the sun lifts it again and the whole journey repeats.',
140
+ ];
141
+
142
+ export async function buildInlineTestVectors(embedder, texts = INLINE_TEST_VECTOR_TEXTS) {
143
+ const out = [];
144
+ for (const text of texts) {
145
+ const { vector, windows } = await embedder.embed(text);
146
+ out.push({
147
+ text,
148
+ windows,
149
+ embedding: Array.from(vector, (v) => Number(v.toFixed(6))),
150
+ tolerance: 1e-3,
151
+ });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // A verification vector's expected embedding must itself be verifiable:
157
+ // exactly dim real, finite numbers. A malformed entry (string, null, NaN,
158
+ // missing component, wrong length) would otherwise make every comparison
159
+ // NaN, and `NaN > maxDiff` is false — the vector would "pass" without a
160
+ // single meaningful comparison. Shared by the kind-2 host verifier and the
161
+ // kind-3 inline verifier.
162
+ export function validateExpectedEmbedding(values, dim, label) {
163
+ if (!Array.isArray(values) || values.length !== dim) {
164
+ throw new Error(`${label}: expected embedding must be an array of ${dim} numbers, got ${Array.isArray(values) ? `length ${values.length}` : typeof values}`);
165
+ }
166
+ for (let d = 0; d < dim; d++) {
167
+ if (typeof values[d] !== 'number' || !Number.isFinite(values[d])) {
168
+ throw new Error(`${label}: expected embedding component ${d} is not a finite number`);
169
+ }
170
+ }
171
+ }
172
+
173
+ export function validateTestVectorTolerance(tolerance, label) {
174
+ if (tolerance === undefined) return 1e-3;
175
+ if (typeof tolerance !== 'number' || !Number.isFinite(tolerance) || tolerance <= 0 || tolerance >= 1) {
176
+ throw new Error(`${label}: tolerance is implausible`);
177
+ }
178
+ return tolerance;
179
+ }
180
+
181
+ export async function verifyInlineTestVectors(embedder) {
182
+ for (const tv of embedder.declaration.testVectors) {
183
+ if (!tv || typeof tv.text !== 'string') {
184
+ throw new Error('inline encoder verification vector is malformed (no text)');
185
+ }
186
+ const label = `inline encoder verification vector "${tv.text.slice(0, 40)}…"`;
187
+ const { vector, windows } = await embedder.embed(tv.text);
188
+ validateExpectedEmbedding(tv.embedding, vector.length, label);
189
+ const tolerance = validateTestVectorTolerance(tv.tolerance, label);
190
+ if (tv.windows !== undefined && windows !== tv.windows) {
191
+ throw new Error(`inline encoder verification: "${tv.text.slice(0, 40)}…" pooled over ${windows} window(s); `
192
+ + `the declaration recorded ${tv.windows}`);
193
+ }
194
+ let maxDiff = 0;
195
+ for (let d = 0; d < vector.length; d++) {
196
+ const diff = Math.abs(vector[d] - tv.embedding[d]);
197
+ if (diff > maxDiff) maxDiff = diff;
198
+ }
199
+ if (!(maxDiff <= tolerance)) {
200
+ throw new Error(`inline encoder failed its declaration's verification vector "${tv.text.slice(0, 40)}…": `
201
+ + `max component diff ${maxDiff.toExponential(2)} exceeds tolerance ${tolerance}`);
202
+ }
203
+ }
204
+ return { checked: embedder.declaration.testVectors.length };
205
+ }
@@ -0,0 +1,264 @@
1
+ // Reader for the lexical index segment (kind 5, layout bm25-v1) — the
2
+ // static inverted index a complete artifact MAY carry for hybrid retrieval.
3
+ // Byte layout and hashing mirror buildLexicalSegment in builder.mjs; this
4
+ // module must stay runtime-neutral (browsers, Workers, Node).
5
+ //
6
+ // The phase-1 reader receives the whole hash-verified segment; the format
7
+ // itself is lazy-friendly (fixed-width hash-sorted term table, absolute
8
+ // offsets) so a range-reading opener can replace this one without a format
9
+ // revision.
10
+
11
+ const BM25_K1 = 1.2;
12
+ const BM25_B = 0.75;
13
+
14
+ function fnv1a32(str, seed) {
15
+ let h = 0x811c9dc5 ^ seed;
16
+ for (let i = 0; i < str.length; i++) {
17
+ h ^= str.charCodeAt(i);
18
+ h = Math.imul(h, 0x01000193);
19
+ }
20
+ return h >>> 0;
21
+ }
22
+
23
+ // Query tokens follow the builder's shape (lowercase [a-z0-9']+, 2..32
24
+ // chars). No stopword list ships or is needed: a token the builder skipped
25
+ // simply finds no postings.
26
+ const tokenize = (text) => (String(text).toLowerCase().match(/[a-z0-9']+/g) || [])
27
+ .filter((w) => w.length >= 2 && w.length <= 32);
28
+
29
+ // Lazy opener for wiki-scale lexical segments: only the 64-byte header and
30
+ // the doclen array (4 bytes per record — 4 MiB at a million chunks) become
31
+ // resident; term lookups and postings stay remote. Term lookup exploits the
32
+ // format's own structure: fnv hashes are uniformly distributed and the term
33
+ // table is sorted by them, so the table is an implicit interpolation index —
34
+ // predict the entry position from the hash value, read a ~512-entry window
35
+ // (12 KiB), and the term is almost always inside on the first read, with
36
+ // bounded re-aims for the tail. A query term then costs ~1 window read plus
37
+ // 1 postings read, issued in parallel across the query's terms.
38
+ //
39
+ // Integrity stance is transitional, like format-1 sketch rows: the segment
40
+ // is committed via the manifest digest but lazy reads are not individually
41
+ // verified (the eager opener verifies the whole segment; per-page digests
42
+ // are the format-v2 path if this gap needs closing).
43
+ export async function openLexicalIndexLazy(read, segLength) {
44
+ const headerBytes = await read(0, 64);
45
+ if (!(headerBytes instanceof Uint8Array) || headerBytes.length < 64 || segLength < 64) {
46
+ throw new Error('.pancake lexical segment is too short');
47
+ }
48
+ const hv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
49
+ const version = hv.getUint32(0, true);
50
+ if (version !== 1) throw new Error(`unsupported lexical segment version ${version}`);
51
+ const docCount = hv.getUint32(4, true);
52
+ const termCount = hv.getUint32(8, true);
53
+ const totalTokens = Number(hv.getBigUint64(12, true));
54
+ const doclenOffset = hv.getUint32(20, true);
55
+ const termTableOffset = hv.getUint32(24, true);
56
+ const postingsOffset = hv.getUint32(28, true);
57
+ const postingsBytes = Number(hv.getBigUint64(32, true));
58
+ if (doclenOffset !== 64
59
+ || termTableOffset !== doclenOffset + 4 * docCount
60
+ || postingsOffset !== termTableOffset + 24 * termCount
61
+ || postingsOffset + postingsBytes !== segLength) {
62
+ throw new Error('.pancake lexical segment regions do not tile the segment');
63
+ }
64
+ const avgdl = docCount > 0 ? Math.max(1, totalTokens / docCount) : 1;
65
+ const doclenBytes = docCount > 0 ? await read(doclenOffset, 4 * docCount) : new Uint8Array(0);
66
+ const doclenView = new DataView(doclenBytes.buffer, doclenBytes.byteOffset, doclenBytes.byteLength);
67
+ const doclenOf = (id) => doclenView.getUint32(4 * id, true);
68
+
69
+ const WINDOW = 512;
70
+ const windowCache = new Map(); // startIdx -> entry bytes (small LRU)
71
+ async function readWindow(startIdx, count) {
72
+ const key = `${startIdx}:${count}`;
73
+ const hit = windowCache.get(key);
74
+ if (hit) return hit;
75
+ const bytes = await read(termTableOffset + 24 * startIdx, 24 * count);
76
+ if (windowCache.size >= 64) windowCache.delete(windowCache.keys().next().value);
77
+ windowCache.set(key, bytes);
78
+ return bytes;
79
+ }
80
+ const entryAt = (bytes, i) => {
81
+ const view = new DataView(bytes.buffer, bytes.byteOffset + 24 * i, 24);
82
+ return {
83
+ hashLo: view.getUint32(0, true),
84
+ hashHi: view.getUint32(4, true),
85
+ postingsRel: Number(view.getBigUint64(8, true)),
86
+ postingsLen: view.getUint32(16, true),
87
+ df: view.getUint32(20, true),
88
+ };
89
+ };
90
+ const cmp = (aHi, aLo, bHi, bLo) => (aHi !== bHi ? (aHi < bHi ? -1 : 1) : aLo === bLo ? 0 : (aLo < bLo ? -1 : 1));
91
+
92
+ async function findTerm(hashLo, hashHi) {
93
+ if (termCount === 0) return null;
94
+ let loIdx = 0;
95
+ let hiIdx = termCount - 1;
96
+ let loHash = 0;
97
+ let hiHash = 0xffffffff;
98
+ for (let iter = 0; iter < 10 && loIdx <= hiIdx; iter++) {
99
+ const span = hiHash > loHash ? hiHash - loHash : 1;
100
+ let guess = loIdx + Math.floor((hiIdx - loIdx) * ((hashHi - loHash) / span));
101
+ guess = Math.min(hiIdx, Math.max(loIdx, guess));
102
+ const start = Math.min(Math.max(loIdx, guess - (WINDOW >> 1)), Math.max(loIdx, hiIdx - WINDOW + 1));
103
+ const count = Math.min(WINDOW, hiIdx - start + 1);
104
+ const bytes = await readWindow(start, count);
105
+ const first = entryAt(bytes, 0);
106
+ const last = entryAt(bytes, count - 1);
107
+ if (cmp(hashHi, hashLo, first.hashHi, first.hashLo) < 0) {
108
+ hiIdx = start - 1;
109
+ hiHash = first.hashHi;
110
+ continue;
111
+ }
112
+ if (cmp(hashHi, hashLo, last.hashHi, last.hashLo) > 0) {
113
+ loIdx = start + count;
114
+ loHash = last.hashHi;
115
+ continue;
116
+ }
117
+ let a = 0;
118
+ let b = count - 1;
119
+ while (a <= b) {
120
+ const mid = (a + b) >> 1;
121
+ const e = entryAt(bytes, mid);
122
+ const c = cmp(hashHi, hashLo, e.hashHi, e.hashLo);
123
+ if (c === 0) return e;
124
+ if (c < 0) b = mid - 1;
125
+ else a = mid + 1;
126
+ }
127
+ return null; // window brackets the hash; absence is definitive
128
+ }
129
+ return null;
130
+ }
131
+
132
+ return {
133
+ docCount,
134
+ termCount,
135
+ totalTokens,
136
+ lazy: true,
137
+ async search(text, n) {
138
+ const terms = [...new Set(tokenize(text))];
139
+ if (!terms.length || docCount === 0) return [];
140
+ const found = (await Promise.all(terms.map((t) => findTerm(fnv1a32(t, 0), fnv1a32(t, 0x9e3779b9)))))
141
+ .filter(Boolean);
142
+ if (!found.length) return [];
143
+ const postings = await Promise.all(found.map((e) => read(postingsOffset + e.postingsRel, e.postingsLen)));
144
+ const scores = new Map();
145
+ found.forEach((e, i) => {
146
+ const bytes = postings[i];
147
+ const idf = Math.log(1 + (docCount - e.df + 0.5) / (e.df + 0.5));
148
+ const cursor = { at: 0, end: bytes.length };
149
+ const readVarint = () => {
150
+ let value = 0;
151
+ let shift = 0;
152
+ for (;;) {
153
+ if (cursor.at >= cursor.end) throw new Error('.pancake lexical postings truncated');
154
+ const b = bytes[cursor.at++];
155
+ value += (b & 0x7f) * 2 ** shift;
156
+ if (!(b & 0x80)) return value;
157
+ shift += 7;
158
+ if (shift > 35) throw new Error('.pancake lexical postings varint overflow');
159
+ }
160
+ };
161
+ let docId = 0;
162
+ for (let p = 0; p < e.df; p++) {
163
+ docId = p === 0 ? readVarint() : docId + readVarint();
164
+ const tf = readVarint();
165
+ if (docId >= docCount) throw new Error('.pancake lexical postings doc id out of range');
166
+ const dl = doclenOf(docId);
167
+ const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
168
+ scores.set(docId, (scores.get(docId) || 0) + (idf * tf * (BM25_K1 + 1)) / norm);
169
+ }
170
+ });
171
+ return [...scores.entries()]
172
+ .map(([id, score]) => ({ id, score }))
173
+ .sort((a, b) => (b.score - a.score) || (a.id - b.id))
174
+ .slice(0, n);
175
+ },
176
+ };
177
+ }
178
+
179
+ export function openLexicalIndex(bytes) {
180
+ if (!(bytes instanceof Uint8Array) || bytes.length < 64) {
181
+ throw new Error('.pancake lexical segment is too short');
182
+ }
183
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
184
+ const version = view.getUint32(0, true);
185
+ if (version !== 1) throw new Error(`unsupported lexical segment version ${version}`);
186
+ const docCount = view.getUint32(4, true);
187
+ const termCount = view.getUint32(8, true);
188
+ const totalTokens = Number(view.getBigUint64(12, true));
189
+ const doclenOffset = view.getUint32(20, true);
190
+ const termTableOffset = view.getUint32(24, true);
191
+ const postingsOffset = view.getUint32(28, true);
192
+ const postingsBytes = Number(view.getBigUint64(32, true));
193
+ if (doclenOffset !== 64
194
+ || termTableOffset !== doclenOffset + 4 * docCount
195
+ || postingsOffset !== termTableOffset + 24 * termCount
196
+ || postingsOffset + postingsBytes !== bytes.length) {
197
+ throw new Error('.pancake lexical segment regions do not tile the segment');
198
+ }
199
+ const avgdl = docCount > 0 ? Math.max(1, totalTokens / docCount) : 1;
200
+
201
+ const entryAt = (i) => termTableOffset + 24 * i;
202
+ function findTerm(hashLo, hashHi) {
203
+ let lo = 0;
204
+ let hi = termCount - 1;
205
+ while (lo <= hi) {
206
+ const mid = (lo + hi) >> 1;
207
+ const eLo = view.getUint32(entryAt(mid), true);
208
+ const eHi = view.getUint32(entryAt(mid) + 4, true);
209
+ if (eHi === hashHi && eLo === hashLo) return mid;
210
+ if (eHi < hashHi || (eHi === hashHi && eLo < hashLo)) lo = mid + 1;
211
+ else hi = mid - 1;
212
+ }
213
+ return -1;
214
+ }
215
+
216
+ function readVarint(cursor) {
217
+ let value = 0;
218
+ let shift = 0;
219
+ for (;;) {
220
+ if (cursor.at >= cursor.end) throw new Error('.pancake lexical postings truncated');
221
+ const b = bytes[cursor.at++];
222
+ value += (b & 0x7f) * 2 ** shift;
223
+ if (!(b & 0x80)) return value;
224
+ shift += 7;
225
+ if (shift > 35) throw new Error('.pancake lexical postings varint overflow');
226
+ }
227
+ }
228
+
229
+ return {
230
+ docCount,
231
+ termCount,
232
+ totalTokens,
233
+ // BM25 over the query's unique tokens; returns [{id, score}] sorted
234
+ // by score descending (ties by id), at most n entries.
235
+ search(text, n) {
236
+ const terms = [...new Set(tokenize(text))];
237
+ if (!terms.length || docCount === 0) return [];
238
+ const scores = new Map();
239
+ for (const term of terms) {
240
+ const i = findTerm(fnv1a32(term, 0), fnv1a32(term, 0x9e3779b9));
241
+ if (i < 0) continue;
242
+ const rel = Number(view.getBigUint64(entryAt(i) + 8, true));
243
+ const len = view.getUint32(entryAt(i) + 16, true);
244
+ const df = view.getUint32(entryAt(i) + 20, true);
245
+ if (rel + len > postingsBytes) throw new Error('.pancake lexical postings out of bounds');
246
+ const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
247
+ const cursor = { at: postingsOffset + rel, end: postingsOffset + rel + len };
248
+ let docId = 0;
249
+ for (let p = 0; p < df; p++) {
250
+ docId = p === 0 ? readVarint(cursor) : docId + readVarint(cursor);
251
+ const tf = readVarint(cursor);
252
+ if (docId >= docCount) throw new Error('.pancake lexical postings doc id out of range');
253
+ const dl = view.getUint32(doclenOffset + 4 * docId, true);
254
+ const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
255
+ scores.set(docId, (scores.get(docId) || 0) + (idf * tf * (BM25_K1 + 1)) / norm);
256
+ }
257
+ }
258
+ return [...scores.entries()]
259
+ .map(([id, score]) => ({ id, score }))
260
+ .sort((a, b) => (b.score - a.score) || (a.id - b.id))
261
+ .slice(0, n);
262
+ },
263
+ };
264
+ }
@@ -0,0 +1,119 @@
1
+ // Client-side abstention scorer for the wiki pack. Mirrors the math in
2
+ // ../../calibrate_abstention.mjs exactly: retrieval signals (d0, margin,
3
+ // mean10) plus the corpus-vocabulary known-token fraction from the shipped
4
+ // bloom filter, standardized and passed through the fitted logistic model.
5
+ // Verdict semantics: 'answer' (strong match), 'weak' (closest match is
6
+ // distant — shown with a caveat), 'abstain' (nothing useful in the pack).
7
+ //
8
+ // Assets calibrated by pikelet's self-templates-v2+ may carry
9
+ // an additional grounding term (asset.coverage): the fraction of the query's
10
+ // content words that appear in the top retrieved passage's text. Every base
11
+ // feature measures topic similarity, so "the corpus discusses this area" and
12
+ // "this passage answers this question" are indistinguishable without it. The
13
+ // term is serialized outside features[]/weights[] deliberately: a reader
14
+ // that predates it scores the topic-only model against the same thresholds
15
+ // (a conservative degradation) instead of hitting an unknown feature name
16
+ // and computing NaN. Word rules (min length, stopwords) ship in the asset so
17
+ // builder and reader cannot drift.
18
+
19
+ export function createAbstentionScorer(asset, bloomBytes) {
20
+ if (!asset || !Array.isArray(asset.weights) || !asset.thresholds) return null;
21
+ const coverageCfg = asset.coverage && typeof asset.coverage.weight === 'number'
22
+ && Number.isFinite(asset.coverage.mean) && Number.isFinite(asset.coverage.std)
23
+ ? asset.coverage : null;
24
+ const coverageStopwords = coverageCfg ? new Set(coverageCfg.stopwords || []) : null;
25
+ const coverageMinLen = coverageCfg ? (coverageCfg.minWordLen || 3) : 3;
26
+ // Words the corpus uses everywhere ground any query that mentions them,
27
+ // so they are excluded from coverage; the builder ships them as a bloom.
28
+ const commonBloom = coverageCfg?.commonBloom?.base64
29
+ ? (typeof Buffer !== 'undefined'
30
+ ? new Uint8Array(Buffer.from(coverageCfg.commonBloom.base64, 'base64'))
31
+ : Uint8Array.from(atob(coverageCfg.commonBloom.base64), (c) => c.charCodeAt(0)))
32
+ : null;
33
+ const commonBits = coverageCfg?.commonBloom?.bits;
34
+ const isCommon = commonBloom ? (w) => SEEDS.every((seed) => {
35
+ let h = 0x811c9dc5 ^ seed;
36
+ for (let i = 0; i < w.length; i++) { h ^= w.charCodeAt(i); h = Math.imul(h, 0x01000193); }
37
+ const bit = (h >>> 0) % commonBits;
38
+ return (commonBloom[bit >> 3] >> (bit & 7)) & 1;
39
+ }) : null;
40
+
41
+ // Max over the scored passages, with corpus-common words counted at
42
+ // reduced weight — mirrors create-pancake-search/src/calibrate.mjs
43
+ // coverageFrac exactly (the weight ships in the asset).
44
+ const commonWordWeight = coverageCfg?.commonWordWeight ?? 1 / 3;
45
+ function coverageFrac(text, passageTexts) {
46
+ const content = (String(text).toLowerCase().match(/[a-z0-9']+/g) || [])
47
+ .filter((w) => w.length >= coverageMinLen && !coverageStopwords.has(w));
48
+ if (!content.length) return 0;
49
+ const weights = content.map((w) => (isCommon && isCommon(w) ? commonWordWeight : 1));
50
+ const weightSum = weights.reduce((a, c) => a + c, 0);
51
+ let best = 0;
52
+ for (const passageText of passageTexts || []) {
53
+ const passage = new Set(String(passageText || '').toLowerCase().match(/[a-z0-9']+/g) || []);
54
+ const present = (w) => passage.has(w) || passage.has(`${w}s`) || passage.has(`${w}es`)
55
+ || (w.endsWith('s') && passage.has(w.slice(0, -1)));
56
+ const grounded = content.reduce((sum, w, i) => sum + (present(w) ? weights[i] : 0), 0);
57
+ best = Math.max(best, grounded / weightSum);
58
+ }
59
+ return best;
60
+ }
61
+ const bloom = new Uint8Array(bloomBytes);
62
+ const bits = asset.vocabBloom.bits;
63
+
64
+ function fnv1a(str, seed) {
65
+ let h = 0x811c9dc5 ^ seed;
66
+ for (let i = 0; i < str.length; i++) {
67
+ h ^= str.charCodeAt(i);
68
+ h = Math.imul(h, 0x01000193);
69
+ }
70
+ return (h >>> 0) % bits;
71
+ }
72
+ const SEEDS = [0, 0x9e3779b9];
73
+
74
+ function knownFrac(text) {
75
+ const words = text.toLowerCase().match(/[a-z0-9']+/g) || [];
76
+ if (!words.length) return 0;
77
+ let known = 0;
78
+ for (const w of words) {
79
+ const hit = SEEDS.every((seed) => {
80
+ const bit = fnv1a(w, seed);
81
+ return (bloom[bit >> 3] >> (bit & 7)) & 1;
82
+ });
83
+ if (hit) known++;
84
+ }
85
+ return known / words.length;
86
+ }
87
+
88
+ return {
89
+ // The caller must hydrate the top passagesNeeded results' text
90
+ // before scoring when usesPassage is true; the coverage term reads
91
+ // them (older assets scored one passage; topK now defaults to it).
92
+ usesPassage: !!coverageCfg,
93
+ passagesNeeded: coverageCfg ? (coverageCfg.topK || 1) : 0,
94
+ score(queryText, results, passageTexts) {
95
+ const d0 = results.length ? results[0].distance : 1;
96
+ const margin = results.length > 1
97
+ ? results[Math.min(4, results.length - 1)].distance - d0 : 0;
98
+ const mean10 = results.length
99
+ ? results.reduce((s, r) => s + r.distance, 0) / results.length : 1;
100
+ const signals = { d0, margin, mean10, known_frac: knownFrac(queryText) };
101
+ let z = asset.bias;
102
+ asset.features.forEach((f, j) => {
103
+ z += ((signals[f] - asset.standardize.mean[f]) / asset.standardize.std[f]) * asset.weights[j];
104
+ });
105
+ if (coverageCfg) {
106
+ signals.coverage1 = coverageFrac(queryText, Array.isArray(passageTexts) ? passageTexts : [passageTexts]);
107
+ z += ((signals.coverage1 - coverageCfg.mean) / (coverageCfg.std || 1)) * coverageCfg.weight;
108
+ }
109
+ // A malformed asset (unknown feature name, non-numeric term) must
110
+ // degrade to unscored, never to a NaN that compares false against
111
+ // both thresholds and answers everything.
112
+ if (!Number.isFinite(z)) return { p: null, verdict: 'unscored', signals };
113
+ const p = 1 / (1 + Math.exp(-z));
114
+ const verdict = p < asset.thresholds.hard ? 'abstain'
115
+ : p < asset.thresholds.weak ? 'weak' : 'answer';
116
+ return { p, verdict, signals };
117
+ },
118
+ };
119
+ }