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.
Files changed (34) hide show
  1. package/CHANGELOG.md +340 -4
  2. package/NOTICE +5 -0
  3. package/README.md +226 -475
  4. package/complete/builder.d.ts +4 -4
  5. package/complete/builder.mjs +26 -9
  6. package/complete/encoder-kernels/encoder.node.wasm +0 -0
  7. package/complete/encoder-kernels/encoder.wasm +0 -0
  8. package/complete/fusion.mjs +72 -0
  9. package/complete/index.d.ts +34 -7
  10. package/complete/index.mjs +258 -129
  11. package/complete/inline-transformer.mjs +119 -10
  12. package/complete/lexical.mjs +11 -11
  13. package/complete/retrieval-abstention.mjs +136 -31
  14. package/complete/sources.mjs +22 -1
  15. package/complete/wordpiece.mjs +22 -0
  16. package/dist/engine.js +1 -1
  17. package/dist/engine.scalar.js +1 -1
  18. package/dist/engine.scalar.wasm +0 -0
  19. package/dist/engine.wasm +0 -0
  20. package/package.json +47 -82
  21. package/{pancake-artifact-common.js → src/artifact/pikelet-artifact-common.js} +42 -42
  22. package/{pancake-artifact-range.js → src/artifact/pikelet-artifact-range.js} +43 -36
  23. package/{pancake-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +105 -68
  24. package/{pancake-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +11 -11
  25. package/{pancake-artifact.js → src/artifact/pikelet-artifact.js} +10 -10
  26. package/{pancake-core.js → src/core/pikelet-core.js} +141 -140
  27. package/{pancake-errors.js → src/errors/pikelet-errors.js} +9 -8
  28. package/{pancake.d.ts → src/pikelet.d.ts} +55 -55
  29. package/{pancake.js → src/pikelet.js} +47 -47
  30. package/{pancake.node.mjs → src/pikelet.node.mjs} +49 -49
  31. package/{pancake.web.d.ts → src/pikelet.web.d.ts} +13 -13
  32. package/{pancake.web.mjs → src/pikelet.web.mjs} +24 -24
  33. package/{pancake.workerd.mjs → src/pikelet.workerd.mjs} +22 -22
  34. /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 = 128;
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('.pancake inline-encoder layout is inconsistent');
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(`.pancake inline-encoder declares layout ${key}=${layout[key]}; `
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(`.pancake inline-encoder declares dim ${declaration.dim}; the kernel emits ${KERNEL_LAYOUT.D}`);
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(`.pancake inline-encoder blob is ${blob.length} bytes but its declared layout implies ${expected}`);
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
- const maxSeq = Math.min(declaration.maxTokens || KERNEL_MAX_SEQ, KERNEL_MAX_SEQ);
86
+ // Clamped on BOTH sides: parseInlineTransformerEncoder() rejects an
87
+ // out-of-range declaration, but this function is also called directly (the
88
+ // BEIR ladder, encoder ablations), so the buffer sizing must not depend on
89
+ // that check having run.
90
+ const maxSeq = Math.min(
91
+ Math.max(MIN_DECLARED_MAX_TOKENS, Math.trunc(declaration.maxTokens) || KERNEL_MAX_SEQ),
92
+ KERNEL_MAX_SEQ,
93
+ );
67
94
  const blobPtr = EM._malloc(blob.length);
68
95
  EM.HEAPU8.set(blob, blobPtr);
69
96
  const idsPtr = EM._malloc(maxSeq * 4);
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 mean-pooled across [CLS]…[SEP]-framed
75
- // windows instead of truncated, so long chunks keep their tail content.
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
- for (let w = 0; w < windows; w++) {
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);
@@ -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('.pancake lexical segment is too short');
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('.pancake lexical segment regions do not tile the segment');
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('.pancake lexical postings truncated');
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('.pancake lexical postings varint overflow');
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('.pancake lexical postings doc id out of range');
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('.pancake lexical segment is too short');
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('.pancake lexical segment regions do not tile the segment');
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('.pancake lexical postings truncated');
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('.pancake lexical postings varint overflow');
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('.pancake lexical postings out of bounds');
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('.pancake lexical postings doc id out of range');
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
- // 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
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 create-pancake-search/src/calibrate.mjs
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
- function coverageFrac(text, passageTexts) {
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
- .filter((w) => w.length >= coverageMinLen && !coverageStopwords.has(w));
48
- if (!content.length) return 0;
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 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);
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 term reads
91
- // them (older assets scored one passage; topK now defaults to it).
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
- 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;
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
- signals.coverage1 = coverageFrac(queryText, Array.isArray(passageTexts) ? passageTexts : [passageTexts]);
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
  }
@@ -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('.pancake host does not honor Range; fell back to a one-time full download');
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}`);
@@ -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
  }