pikelet-wasm 0.7.0 → 0.8.1
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 +340 -4
- package/NOTICE +5 -0
- package/README.md +226 -475
- package/complete/builder.d.ts +4 -4
- package/complete/builder.mjs +26 -9
- package/complete/encoder-kernels/encoder.node.wasm +0 -0
- package/complete/encoder-kernels/encoder.wasm +0 -0
- package/complete/fusion.mjs +72 -0
- package/complete/index.d.ts +34 -7
- package/complete/index.mjs +258 -129
- package/complete/inline-transformer.mjs +119 -10
- package/complete/lexical.mjs +11 -11
- package/complete/retrieval-abstention.mjs +136 -31
- package/complete/sources.mjs +22 -1
- 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 +47 -82
- package/{pancake-artifact-common.js → src/artifact/pikelet-artifact-common.js} +42 -42
- package/{pancake-artifact-range.js → src/artifact/pikelet-artifact-range.js} +43 -36
- package/{pancake-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +105 -68
- package/{pancake-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +11 -11
- package/{pancake-artifact.js → src/artifact/pikelet-artifact.js} +10 -10
- package/{pancake-core.js → src/core/pikelet-core.js} +141 -140
- package/{pancake-errors.js → src/errors/pikelet-errors.js} +9 -8
- package/{pancake.d.ts → src/pikelet.d.ts} +55 -55
- package/{pancake.js → src/pikelet.js} +47 -47
- package/{pancake.node.mjs → src/pikelet.node.mjs} +49 -49
- package/{pancake.web.d.ts → src/pikelet.web.d.ts} +13 -13
- package/{pancake.web.mjs → src/pikelet.web.mjs} +24 -24
- package/{pancake.workerd.mjs → src/pikelet.workerd.mjs} +22 -22
- /package/{pancake-loader.js → src/loader/pikelet-loader.js} +0 -0
|
@@ -7,7 +7,11 @@ const decoder = new TextDecoder();
|
|
|
7
7
|
// reads the declaration. Declaration/blob skew is therefore only catchable
|
|
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
|
-
const KERNEL_MAX_SEQ =
|
|
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
|
|
@@ -29,23 +33,39 @@ export function parseInlineTransformerEncoder(encoderBytes) {
|
|
|
29
33
|
const vocabLen = view.getUint32(4, true);
|
|
30
34
|
const blobLen = view.getUint32(8, true);
|
|
31
35
|
if (12 + declLen + vocabLen + blobLen !== encoderBytes.length) {
|
|
32
|
-
throw new Error('.
|
|
36
|
+
throw new Error('.pikelet inline-encoder layout is inconsistent');
|
|
33
37
|
}
|
|
34
38
|
const declaration = JSON.parse(decoder.decode(encoderBytes.subarray(12, 12 + declLen)));
|
|
35
39
|
const blob = encoderBytes.subarray(12 + declLen + vocabLen);
|
|
36
40
|
const layout = declaration.layout || {};
|
|
37
41
|
for (const key of Object.keys(KERNEL_LAYOUT)) {
|
|
38
42
|
if (layout[key] !== KERNEL_LAYOUT[key]) {
|
|
39
|
-
throw new Error(`.
|
|
43
|
+
throw new Error(`.pikelet inline-encoder declares layout ${key}=${layout[key]}; `
|
|
40
44
|
+ `the compiled kernel requires ${key}=${KERNEL_LAYOUT[key]}`);
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
if (declaration.dim !== KERNEL_LAYOUT.D) {
|
|
44
|
-
throw new Error(`.
|
|
48
|
+
throw new Error(`.pikelet inline-encoder declares dim ${declaration.dim}; the kernel emits ${KERNEL_LAYOUT.D}`);
|
|
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
|
+
}
|
|
45
65
|
}
|
|
46
66
|
const expected = expectedBlobBytes(layout);
|
|
47
67
|
if (blob.length !== expected) {
|
|
48
|
-
throw new Error(`.
|
|
68
|
+
throw new Error(`.pikelet inline-encoder blob is ${blob.length} bytes but its declared layout implies ${expected}`);
|
|
49
69
|
}
|
|
50
70
|
return {
|
|
51
71
|
declaration,
|
|
@@ -63,27 +83,52 @@ 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);
|
|
70
97
|
const hiddenPtr = EM._malloc(maxSeq * dim * 4);
|
|
71
98
|
|
|
99
|
+
const usesClsPooling = declaration.pooling === 'cls';
|
|
100
|
+
|
|
72
101
|
async function embed(text) {
|
|
73
102
|
const allIds = tokenizer.encode(String(text || ''));
|
|
74
|
-
// Inputs longer than maxSeq are
|
|
75
|
-
//
|
|
103
|
+
// Inputs longer than maxSeq are windowed across [CLS]…[SEP]-framed
|
|
104
|
+
// chunks instead of truncated, so long chunks keep their tail content.
|
|
76
105
|
const interior = allIds.slice(1, -1);
|
|
77
106
|
const windowLen = maxSeq - 2;
|
|
78
107
|
const windows = Math.max(1, Math.ceil(interior.length / windowLen));
|
|
79
108
|
const pooled = new Float32Array(dim);
|
|
80
109
|
let pooledTokens = 0;
|
|
81
|
-
|
|
110
|
+
// CLS pooling has no defined multi-window semantics (unlike mean
|
|
111
|
+
// pooling, there's no natural way to combine multiple windows' [CLS]
|
|
112
|
+
// tokens into one), so only the first window is encoded; long
|
|
113
|
+
// documents lose tail content under this pooling mode — mean pooling
|
|
114
|
+
// is the better choice for a corpus with chunks near or past maxSeq.
|
|
115
|
+
const windowsToEncode = usesClsPooling ? 1 : windows;
|
|
116
|
+
for (let w = 0; w < windowsToEncode; w++) {
|
|
82
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
|
+
}
|
|
83
123
|
new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
|
|
84
124
|
const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
|
|
85
125
|
if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
|
|
86
126
|
const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
|
|
127
|
+
if (usesClsPooling) {
|
|
128
|
+
for (let d = 0; d < dim; d++) pooled[d] = hidden[d];
|
|
129
|
+
pooledTokens = 1;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
87
132
|
for (let t = 0; t < tokenIds.length; t++) {
|
|
88
133
|
for (let d = 0; d < dim; d++) pooled[d] += hidden[t * dim + d];
|
|
89
134
|
}
|
|
@@ -91,7 +136,7 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
|
|
|
91
136
|
}
|
|
92
137
|
let norm = 0;
|
|
93
138
|
for (let d = 0; d < dim; d++) {
|
|
94
|
-
pooled[d] /= pooledTokens;
|
|
139
|
+
if (!usesClsPooling) pooled[d] /= pooledTokens;
|
|
95
140
|
norm += pooled[d] ** 2;
|
|
96
141
|
}
|
|
97
142
|
norm = Math.sqrt(norm);
|
|
@@ -99,11 +144,75 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
|
|
|
99
144
|
return { vector: pooled, text, windows };
|
|
100
145
|
}
|
|
101
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
|
+
|
|
102
210
|
const embedder = {
|
|
103
211
|
declaration,
|
|
104
212
|
dim,
|
|
105
213
|
maxSeq,
|
|
106
214
|
embed,
|
|
215
|
+
embedWords,
|
|
107
216
|
dispose() {
|
|
108
217
|
EM._free(hiddenPtr);
|
|
109
218
|
EM._free(idsPtr);
|
package/complete/lexical.mjs
CHANGED
|
@@ -43,7 +43,7 @@ const tokenize = (text) => (String(text).toLowerCase().match(/[a-z0-9']+/g) || [
|
|
|
43
43
|
export async function openLexicalIndexLazy(read, segLength) {
|
|
44
44
|
const headerBytes = await read(0, 64);
|
|
45
45
|
if (!(headerBytes instanceof Uint8Array) || headerBytes.length < 64 || segLength < 64) {
|
|
46
|
-
throw new Error('.
|
|
46
|
+
throw new Error('.pikelet lexical segment is too short');
|
|
47
47
|
}
|
|
48
48
|
const hv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
49
49
|
const version = hv.getUint32(0, true);
|
|
@@ -59,7 +59,7 @@ export async function openLexicalIndexLazy(read, segLength) {
|
|
|
59
59
|
|| termTableOffset !== doclenOffset + 4 * docCount
|
|
60
60
|
|| postingsOffset !== termTableOffset + 24 * termCount
|
|
61
61
|
|| postingsOffset + postingsBytes !== segLength) {
|
|
62
|
-
throw new Error('.
|
|
62
|
+
throw new Error('.pikelet lexical segment regions do not tile the segment');
|
|
63
63
|
}
|
|
64
64
|
const avgdl = docCount > 0 ? Math.max(1, totalTokens / docCount) : 1;
|
|
65
65
|
const doclenBytes = docCount > 0 ? await read(doclenOffset, 4 * docCount) : new Uint8Array(0);
|
|
@@ -150,19 +150,19 @@ export async function openLexicalIndexLazy(read, segLength) {
|
|
|
150
150
|
let value = 0;
|
|
151
151
|
let shift = 0;
|
|
152
152
|
for (;;) {
|
|
153
|
-
if (cursor.at >= cursor.end) throw new Error('.
|
|
153
|
+
if (cursor.at >= cursor.end) throw new Error('.pikelet lexical postings truncated');
|
|
154
154
|
const b = bytes[cursor.at++];
|
|
155
155
|
value += (b & 0x7f) * 2 ** shift;
|
|
156
156
|
if (!(b & 0x80)) return value;
|
|
157
157
|
shift += 7;
|
|
158
|
-
if (shift > 35) throw new Error('.
|
|
158
|
+
if (shift > 35) throw new Error('.pikelet lexical postings varint overflow');
|
|
159
159
|
}
|
|
160
160
|
};
|
|
161
161
|
let docId = 0;
|
|
162
162
|
for (let p = 0; p < e.df; p++) {
|
|
163
163
|
docId = p === 0 ? readVarint() : docId + readVarint();
|
|
164
164
|
const tf = readVarint();
|
|
165
|
-
if (docId >= docCount) throw new Error('.
|
|
165
|
+
if (docId >= docCount) throw new Error('.pikelet lexical postings doc id out of range');
|
|
166
166
|
const dl = doclenOf(docId);
|
|
167
167
|
const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
|
|
168
168
|
scores.set(docId, (scores.get(docId) || 0) + (idf * tf * (BM25_K1 + 1)) / norm);
|
|
@@ -178,7 +178,7 @@ export async function openLexicalIndexLazy(read, segLength) {
|
|
|
178
178
|
|
|
179
179
|
export function openLexicalIndex(bytes) {
|
|
180
180
|
if (!(bytes instanceof Uint8Array) || bytes.length < 64) {
|
|
181
|
-
throw new Error('.
|
|
181
|
+
throw new Error('.pikelet lexical segment is too short');
|
|
182
182
|
}
|
|
183
183
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
184
184
|
const version = view.getUint32(0, true);
|
|
@@ -194,7 +194,7 @@ export function openLexicalIndex(bytes) {
|
|
|
194
194
|
|| termTableOffset !== doclenOffset + 4 * docCount
|
|
195
195
|
|| postingsOffset !== termTableOffset + 24 * termCount
|
|
196
196
|
|| postingsOffset + postingsBytes !== bytes.length) {
|
|
197
|
-
throw new Error('.
|
|
197
|
+
throw new Error('.pikelet lexical segment regions do not tile the segment');
|
|
198
198
|
}
|
|
199
199
|
const avgdl = docCount > 0 ? Math.max(1, totalTokens / docCount) : 1;
|
|
200
200
|
|
|
@@ -217,12 +217,12 @@ export function openLexicalIndex(bytes) {
|
|
|
217
217
|
let value = 0;
|
|
218
218
|
let shift = 0;
|
|
219
219
|
for (;;) {
|
|
220
|
-
if (cursor.at >= cursor.end) throw new Error('.
|
|
220
|
+
if (cursor.at >= cursor.end) throw new Error('.pikelet lexical postings truncated');
|
|
221
221
|
const b = bytes[cursor.at++];
|
|
222
222
|
value += (b & 0x7f) * 2 ** shift;
|
|
223
223
|
if (!(b & 0x80)) return value;
|
|
224
224
|
shift += 7;
|
|
225
|
-
if (shift > 35) throw new Error('.
|
|
225
|
+
if (shift > 35) throw new Error('.pikelet lexical postings varint overflow');
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
@@ -242,14 +242,14 @@ export function openLexicalIndex(bytes) {
|
|
|
242
242
|
const rel = Number(view.getBigUint64(entryAt(i) + 8, true));
|
|
243
243
|
const len = view.getUint32(entryAt(i) + 16, true);
|
|
244
244
|
const df = view.getUint32(entryAt(i) + 20, true);
|
|
245
|
-
if (rel + len > postingsBytes) throw new Error('.
|
|
245
|
+
if (rel + len > postingsBytes) throw new Error('.pikelet lexical postings out of bounds');
|
|
246
246
|
const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
|
|
247
247
|
const cursor = { at: postingsOffset + rel, end: postingsOffset + rel + len };
|
|
248
248
|
let docId = 0;
|
|
249
249
|
for (let p = 0; p < df; p++) {
|
|
250
250
|
docId = p === 0 ? readVarint(cursor) : docId + readVarint(cursor);
|
|
251
251
|
const tf = readVarint(cursor);
|
|
252
|
-
if (docId >= docCount) throw new Error('.
|
|
252
|
+
if (docId >= docCount) throw new Error('.pikelet lexical postings doc id out of range');
|
|
253
253
|
const dl = view.getUint32(doclenOffset + 4 * docId, true);
|
|
254
254
|
const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
|
|
255
255
|
scores.set(docId, (scores.get(docId) || 0) + (idf * tf * (BM25_K1 + 1)) / norm);
|
|
@@ -5,16 +5,34 @@
|
|
|
5
5
|
// Verdict semantics: 'answer' (strong match), 'weak' (closest match is
|
|
6
6
|
// distant — shown with a caveat), 'abstain' (nothing useful in the pack).
|
|
7
7
|
//
|
|
8
|
-
// Assets calibrated by pikelet's self-templates-v2+ may carry
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// feature measures topic similarity, so
|
|
12
|
-
// "
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
8
|
+
// Assets calibrated by pikelet's self-templates-v2+ may carry an additional
|
|
9
|
+
// grounding term (asset.coverage): the fraction of the query's content
|
|
10
|
+
// words that appear in the top retrieved passage's text (coverage1,
|
|
11
|
+
// exact/stemmed match). Every base feature measures topic similarity, so
|
|
12
|
+
// "the corpus discusses this area" and "this passage answers this
|
|
13
|
+
// question" are indistinguishable without a grounding term. The term is
|
|
14
|
+
// serialized outside features[]/weights[] deliberately: a reader that
|
|
15
|
+
// predates it scores the topic-only model against the same thresholds (a
|
|
16
|
+
// conservative degradation) instead of hitting an unknown feature name and
|
|
17
|
+
// computing NaN. Word rules (min length, stopwords) ship in the asset so
|
|
17
18
|
// builder and reader cannot drift.
|
|
19
|
+
//
|
|
20
|
+
// A semantic word-cosine grounding term (maxSim1, folded into grounding1
|
|
21
|
+
// via Math.max with coverage1, gated by asset.coverage.useMaxSim) was
|
|
22
|
+
// tried twice and reverted twice — see pikelet/src/calibrate.mjs's
|
|
23
|
+
// GROUNDING_FEAT design note for the two failure modes (an unrescaled
|
|
24
|
+
// version inflated the fitted threshold and caused widespread false
|
|
25
|
+
// abstention; a rescaled/damped version fixed that but still made
|
|
26
|
+
// real-query behavior worse than plain coverage1 alone). The mechanism
|
|
27
|
+
// itself was also measured to cost 200-400 individual per-word encoder
|
|
28
|
+
// calls per query once whatever built the calibration asset turned the
|
|
29
|
+
// flag on — a 30-80ms search became 1.5-4.6 seconds, with nothing in the
|
|
30
|
+
// response indicating why. Removed entirely (not just disabled) rather
|
|
31
|
+
// than left as a dormant code path: dead code an external asset can
|
|
32
|
+
// silently reactivate isn't actually dead, and both attempts already
|
|
33
|
+
// failed for a structural reason (see the design note), not a tuning
|
|
34
|
+
// one — there is no scenario where re-adding this via a config flag was
|
|
35
|
+
// going to end differently a third time.
|
|
18
36
|
|
|
19
37
|
export function createAbstentionScorer(asset, bloomBytes) {
|
|
20
38
|
if (!asset || !Array.isArray(asset.weights) || !asset.thresholds) return null;
|
|
@@ -39,24 +57,86 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
39
57
|
}) : null;
|
|
40
58
|
|
|
41
59
|
// Max over the scored passages, with corpus-common words counted at
|
|
42
|
-
// reduced weight — mirrors
|
|
60
|
+
// reduced weight — mirrors pikelet/src/calibrate.mjs
|
|
43
61
|
// coverageFrac exactly (the weight ships in the asset).
|
|
44
62
|
const commonWordWeight = coverageCfg?.commonWordWeight ?? 1 / 3;
|
|
45
|
-
|
|
63
|
+
// Light suffix stripping so "quantize" grounds against "quantization"
|
|
64
|
+
// and "configure" against "configuration" — kept byte-identical to
|
|
65
|
+
// pikelet/src/calibrate.mjs's stem() (same suffix list and order):
|
|
66
|
+
// present() otherwise only forgave plurals, so any word-form mismatch
|
|
67
|
+
// at all scored zero coverage for a passage that plainly answers the
|
|
68
|
+
// query. Deliberately conservative (STEM_MIN_LEN guards short words
|
|
69
|
+
// like "king" from over-stripping); not a general stemmer.
|
|
70
|
+
const STEM_MIN_LEN = 4;
|
|
71
|
+
const STEM_SUFFIXES = ['ization', 'isation', 'ication', 'ation', 'ition', 'tion', 'ing', 'ed', 'ate', 'ize', 'ise'];
|
|
72
|
+
function stem(w) {
|
|
73
|
+
for (const suf of STEM_SUFFIXES) {
|
|
74
|
+
if (w.length - suf.length >= STEM_MIN_LEN && w.endsWith(suf)) return w.slice(0, -suf.length);
|
|
75
|
+
}
|
|
76
|
+
if (w.length - 1 >= STEM_MIN_LEN && w.endsWith('e')) return w.slice(0, -1);
|
|
77
|
+
return w;
|
|
78
|
+
}
|
|
79
|
+
// Kept byte-identical to pikelet/src/calibrate.mjs's stripPossessive:
|
|
80
|
+
// the tokenizer regex keeps the apostrophe, so a possessive ("darcy's")
|
|
81
|
+
// is one token that never matches a plain mention ("darcy") without
|
|
82
|
+
// this, in either direction.
|
|
83
|
+
function stripPossessive(w) {
|
|
84
|
+
if (w.endsWith("'s")) return w.slice(0, -2);
|
|
85
|
+
if (w.endsWith("s'")) return w.slice(0, -1);
|
|
86
|
+
if (w.endsWith("'")) return w.slice(0, -1);
|
|
87
|
+
return w;
|
|
88
|
+
}
|
|
89
|
+
// Kept byte-identical to pikelet/src/calibrate.mjs's isNumeral: a
|
|
90
|
+
// numeral is exempted from the coverageMinLen floor because it is
|
|
91
|
+
// often the only token distinguishing two otherwise-identical
|
|
92
|
+
// passages ("Chamber 4" vs "Chamber 5"), unlike an ordinary short
|
|
93
|
+
// word the floor exists to drop.
|
|
94
|
+
const isNumeral = (w) => /^\d+$/.test(w);
|
|
95
|
+
// A word present only in a passage's heading, not its body, still
|
|
96
|
+
// grounds the query, at reduced credit — kept byte-identical to
|
|
97
|
+
// pikelet/src/calibrate.mjs's HEADING_COVERAGE_WEIGHT. Full credit
|
|
98
|
+
// there is the free-coverage bug body/heading splitting exists to fix
|
|
99
|
+
// (a title-templated positive's own words ARE the heading); zero
|
|
100
|
+
// credit is the opposite failure (a real rank-1 hit whose match is in
|
|
101
|
+
// the heading scoring no coverage at all).
|
|
102
|
+
const HEADING_COVERAGE_WEIGHT = 0.5;
|
|
103
|
+
// Returns { value, grounding } rather than writing grounding detail to
|
|
104
|
+
// a shared variable: score() is async, so a module- or closure-level
|
|
105
|
+
// "last grounding" write would race between two concurrent queries
|
|
106
|
+
// against the same open pack if anything ever awaited between this
|
|
107
|
+
// call and reading it back — the second call's write could land, and
|
|
108
|
+
// get read back, before the first call's own return. Threading the
|
|
109
|
+
// value through the return keeps it local to this call regardless.
|
|
110
|
+
function coverageFrac(text, passages) {
|
|
46
111
|
const content = (String(text).toLowerCase().match(/[a-z0-9']+/g) || [])
|
|
47
|
-
.
|
|
48
|
-
|
|
112
|
+
.map(stripPossessive)
|
|
113
|
+
.filter((w) => (w.length >= coverageMinLen || isNumeral(w)) && !coverageStopwords.has(w));
|
|
114
|
+
if (!content.length) return { value: 0, grounding: null };
|
|
49
115
|
const weights = content.map((w) => (isCommon && isCommon(w) ? commonWordWeight : 1));
|
|
50
116
|
const weightSum = weights.reduce((a, c) => a + c, 0);
|
|
51
|
-
let best = 0;
|
|
52
|
-
for (const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
117
|
+
let best = 0; let bestGrounding = null; let passageIndex = 0;
|
|
118
|
+
for (const { heading, body } of passages || []) {
|
|
119
|
+
const bodyWords = (String(body || '').toLowerCase().match(/[a-z0-9']+/g) || []).map(stripPossessive);
|
|
120
|
+
const bodySet = new Set(bodyWords);
|
|
121
|
+
const bodyStems = new Set(bodyWords.map(stem));
|
|
122
|
+
const headingSet = new Set((String(heading || '').toLowerCase().match(/[a-z0-9']+/g) || []).map(stripPossessive));
|
|
123
|
+
const presentIn = (set, stems, w) => set.has(w) || set.has(`${w}s`) || set.has(`${w}es`)
|
|
124
|
+
|| (w.endsWith('s') && set.has(w.slice(0, -1)))
|
|
125
|
+
|| (stems && stems.has(stem(w)));
|
|
126
|
+
const creditFor = (w) => {
|
|
127
|
+
if (presentIn(bodySet, bodyStems, w)) return 1;
|
|
128
|
+
if (presentIn(headingSet, null, w)) return HEADING_COVERAGE_WEIGHT;
|
|
129
|
+
return 0;
|
|
130
|
+
};
|
|
131
|
+
const grounded = content.reduce((sum, w, i) => sum + creditFor(w) * weights[i], 0);
|
|
132
|
+
const frac = grounded / weightSum;
|
|
133
|
+
if (frac > best) {
|
|
134
|
+
best = frac;
|
|
135
|
+
bestGrounding = { passageIndex, coverage: +frac.toFixed(3), covered: content.filter((w) => creditFor(w) > 0), uncovered: content.filter((w) => creditFor(w) === 0) };
|
|
136
|
+
}
|
|
137
|
+
passageIndex++;
|
|
58
138
|
}
|
|
59
|
-
return best;
|
|
139
|
+
return { value: best, grounding: bestGrounding };
|
|
60
140
|
}
|
|
61
141
|
const bloom = new Uint8Array(bloomBytes);
|
|
62
142
|
const bits = asset.vocabBloom.bits;
|
|
@@ -87,23 +167,48 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
87
167
|
|
|
88
168
|
return {
|
|
89
169
|
// The caller must hydrate the top passagesNeeded results' text
|
|
90
|
-
// before scoring when usesPassage is true; the coverage
|
|
91
|
-
// them (older assets scored one passage; topK now
|
|
170
|
+
// before scoring when usesPassage is true; the coverage/maxSim
|
|
171
|
+
// terms read them (older assets scored one passage; topK now
|
|
172
|
+
// defaults to it). maxSim never ships without coverage (calibrate.mjs
|
|
173
|
+
// fits them together), so coverageCfg.topK already covers both.
|
|
92
174
|
usesPassage: !!coverageCfg,
|
|
93
175
|
passagesNeeded: coverageCfg ? (coverageCfg.topK || 1) : 0,
|
|
94
|
-
score(queryText, results, passageTexts) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
176
|
+
async score(queryText, results, passageTexts) {
|
|
177
|
+
// Fit at build time (pikelet/src/calibrate.mjs) always scores a
|
|
178
|
+
// fixed top-10 window (K = min(10, candidates)), independent of
|
|
179
|
+
// whatever k a caller later passes to query(). Slicing to the
|
|
180
|
+
// caller's k here before windowing would shrink margin/mean10/
|
|
181
|
+
// coverage's inputs at small k and change the verdict for an
|
|
182
|
+
// identical retrieval — the window, not the caller's k, is what
|
|
183
|
+
// must match the fit.
|
|
184
|
+
const top = results.slice(0, Math.min(10, results.length));
|
|
185
|
+
const d0 = top.length ? top[0].distance : 1;
|
|
186
|
+
const margin = top.length > 1
|
|
187
|
+
? top[Math.min(4, top.length - 1)].distance - d0 : 0;
|
|
188
|
+
const mean10 = top.length
|
|
189
|
+
? top.reduce((s, r) => s + r.distance, 0) / top.length : 1;
|
|
100
190
|
const signals = { d0, margin, mean10, known_frac: knownFrac(queryText) };
|
|
101
191
|
let z = asset.bias;
|
|
102
192
|
asset.features.forEach((f, j) => {
|
|
103
193
|
z += ((signals[f] - asset.standardize.mean[f]) / asset.standardize.std[f]) * asset.weights[j];
|
|
104
194
|
});
|
|
195
|
+
// Local to this call (not module/closure state — see
|
|
196
|
+
// coverageFrac's comment) so concurrent queries against the
|
|
197
|
+
// same open pack can never see each other's grounding detail.
|
|
198
|
+
let grounding = null;
|
|
105
199
|
if (coverageCfg) {
|
|
106
|
-
|
|
200
|
+
// passageTexts is hydrated by the caller for the fixed
|
|
201
|
+
// top-COVERAGE_TOP_PASSAGES window (see passagesNeeded
|
|
202
|
+
// below), independent of the caller's k, matching
|
|
203
|
+
// calibrate.mjs's top.slice(0, COVERAGE_TOP_PASSAGES).
|
|
204
|
+
const passages = Array.isArray(passageTexts) ? passageTexts : [passageTexts];
|
|
205
|
+
const coverage = coverageFrac(queryText, passages);
|
|
206
|
+
signals.coverage1 = coverage.value;
|
|
207
|
+
grounding = coverage.grounding;
|
|
208
|
+
// grounding1 is coverage1 alone — see this file's header
|
|
209
|
+
// comment and calibrate.mjs's GROUNDING_FEAT design note
|
|
210
|
+
// for why a semantic (maxSim1) blend was tried twice and
|
|
211
|
+
// removed both times.
|
|
107
212
|
z += ((signals.coverage1 - coverageCfg.mean) / (coverageCfg.std || 1)) * coverageCfg.weight;
|
|
108
213
|
}
|
|
109
214
|
// A malformed asset (unknown feature name, non-numeric term) must
|
|
@@ -113,7 +218,7 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
113
218
|
const p = 1 / (1 + Math.exp(-z));
|
|
114
219
|
const verdict = p < asset.thresholds.hard ? 'abstain'
|
|
115
220
|
: p < asset.thresholds.weak ? 'weak' : 'answer';
|
|
116
|
-
return { p, verdict, signals };
|
|
221
|
+
return { p, verdict, signals, grounding };
|
|
117
222
|
},
|
|
118
223
|
};
|
|
119
224
|
}
|
package/complete/sources.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { pikeletError, PIKELET_ERROR_CODES } from '../src/errors/pikelet-errors.js';
|
|
2
|
+
|
|
1
3
|
export function httpRangeSource(url, options = {}) {
|
|
2
4
|
const stats = { requests: 0, bytes: 0, acceptRanges: null, etag: null, fullFallback: false, retries: 0, redirects: 0 };
|
|
3
5
|
const maxFullFallbackBytes = options.maxFullFallbackBytes ?? 64 * 1024 * 1024;
|
|
@@ -139,6 +141,25 @@ export function httpRangeSource(url, options = {}) {
|
|
|
139
141
|
return body;
|
|
140
142
|
}
|
|
141
143
|
if (response.status === 200) {
|
|
144
|
+
// If-Range has a defined meaning: a 200 instead of the
|
|
145
|
+
// requested 206 means either the host ignores Range, or the
|
|
146
|
+
// pinned ETag no longer matches (the artifact was replaced).
|
|
147
|
+
// Those need different handling — slices of a replaced file
|
|
148
|
+
// don't match what the manifest describes, and format-2's
|
|
149
|
+
// lexical segment above 8 MiB carries only a whole-segment
|
|
150
|
+
// digest, so postings read from it would go unverified.
|
|
151
|
+
// Only fall back when the ETag still matches (or neither
|
|
152
|
+
// side has one to compare).
|
|
153
|
+
if (stats.etag) {
|
|
154
|
+
const responseEtag = response.headers.get('etag');
|
|
155
|
+
if (responseEtag !== stats.etag) {
|
|
156
|
+
throw pikeletError(
|
|
157
|
+
PIKELET_ERROR_CODES.ARTIFACT_CHANGED,
|
|
158
|
+
`artifact changed on the host: expected ETag ${stats.etag}, got ${responseEtag || '(missing)'}`,
|
|
159
|
+
{ expectedEtag: stats.etag, actualEtag: responseEtag },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
142
163
|
// An unknown size must refuse, not pass the gate: with no
|
|
143
164
|
// Content-Length (chunked body) and init() never called,
|
|
144
165
|
// size would be 0 and the cap check silently vacuous.
|
|
@@ -177,7 +198,7 @@ export function httpRangeSource(url, options = {}) {
|
|
|
177
198
|
for (const chunk of chunks) { full.set(chunk, at); at += chunk.byteLength; }
|
|
178
199
|
}
|
|
179
200
|
stats.fullFallback = true;
|
|
180
|
-
console.warn('.
|
|
201
|
+
console.warn('.pikelet host does not honor Range; fell back to a one-time full download');
|
|
181
202
|
return full.subarray(offset, offset + length);
|
|
182
203
|
}
|
|
183
204
|
throw new Error(`range read failed: HTTP ${response.status}`);
|
package/complete/wordpiece.mjs
CHANGED
|
@@ -64,5 +64,27 @@ export function createWordPiece(vocabText) {
|
|
|
64
64
|
ids.push(SEP);
|
|
65
65
|
return ids;
|
|
66
66
|
},
|
|
67
|
+
// Same as encode(), but also reports which basic (whitespace/punct-
|
|
68
|
+
// split) word each output token belongs to, as a same-length array of
|
|
69
|
+
// word indices (CLS and SEP get -1 — they belong to no word). A word
|
|
70
|
+
// that WordPiece splits into several sub-tokens (e.g. "quantization"
|
|
71
|
+
// -> "quant" + "##ization") reports the same word index for all of
|
|
72
|
+
// them, so a caller building one vector per word can average or
|
|
73
|
+
// otherwise pool the sub-token hidden states back into a single
|
|
74
|
+
// word-level vector — needed for MaxSim-style token-level grounding,
|
|
75
|
+
// where coverage must be measured in words, not WordPiece pieces.
|
|
76
|
+
encodeWithWordMap(text) {
|
|
77
|
+
const ids = [CLS];
|
|
78
|
+
const wordOf = [-1];
|
|
79
|
+
const words = basicTokenize(text);
|
|
80
|
+
words.forEach((word, wi) => {
|
|
81
|
+
const pieces = wordpiece(word);
|
|
82
|
+
ids.push(...pieces);
|
|
83
|
+
for (let i = 0; i < pieces.length; i++) wordOf.push(wi);
|
|
84
|
+
});
|
|
85
|
+
ids.push(SEP);
|
|
86
|
+
wordOf.push(-1);
|
|
87
|
+
return { ids, wordOf, words };
|
|
88
|
+
},
|
|
67
89
|
};
|
|
68
90
|
}
|