pikelet-wasm 0.8.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 (29) hide show
  1. package/CHANGELOG.md +321 -1
  2. package/README.md +65 -257
  3. package/complete/builder.mjs +18 -1
  4. package/complete/fusion.mjs +72 -0
  5. package/complete/index.d.ts +23 -0
  6. package/complete/index.mjs +100 -20
  7. package/complete/inline-transformer.mjs +97 -1
  8. package/complete/retrieval-abstention.mjs +118 -25
  9. package/complete/sources.mjs +1 -1
  10. package/complete/wordpiece.mjs +22 -0
  11. package/dist/engine.js +1 -1
  12. package/dist/engine.scalar.js +1 -1
  13. package/dist/engine.scalar.wasm +0 -0
  14. package/dist/engine.wasm +0 -0
  15. package/package.json +35 -77
  16. package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
  17. package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
  18. package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +30 -3
  19. package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
  20. package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
  21. package/{pikelet.js → src/pikelet.js} +7 -7
  22. package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
  23. package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
  24. package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
  25. /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
  26. /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
  27. /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
  28. /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
  29. /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
@@ -25,7 +25,7 @@ import { loadStudentModel, embedTextWithStudent } from './student-embedder.mjs';
25
25
  import { computeMatchQuality, computePreSearchAbstention } from './student-abstention.mjs';
26
26
  import { createAbstentionScorer } from './retrieval-abstention.mjs';
27
27
  import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
28
- import { PikeletSketchArtifact } from '../pikelet-artifact.js';
28
+ import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
29
29
  import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
30
30
 
31
31
  import {
@@ -41,6 +41,8 @@ export { httpRangeSource } from './sources.mjs';
41
41
  // in-memory lexical searcher over the segment bytes already assembled by
42
42
  // buildLexicalSegment, before the artifact is written to disk.
43
43
  export { openLexicalIndex } from './lexical.mjs';
44
+ export { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
45
+ import { fuseCandidates } from './fusion.mjs';
44
46
  export {
45
47
  KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
46
48
  createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
@@ -75,9 +77,21 @@ const RECORD_CACHE = 256;
75
77
  // its fixed-size header is what format 2 commits to in the manifest.
76
78
  const SKETCH_HEADER_BYTES = 256;
77
79
  // Hybrid retrieval: BM25 candidates fetched from the lexical segment per
78
- // query, and the reciprocal-rank-fusion constant (the standard untuned 60).
80
+ // query; the fusion rule and its constants live in fusion.mjs, shared with
81
+ // the calibrator so fit-time rankings match what is served.
79
82
  const LEXICAL_CANDIDATES = 24;
80
- const RRF_K = 60;
83
+ // scoreQuality's coverage term hydrates the fused top passagesNeeded
84
+ // records (asset.coverage.topK, 5 by default — see calibrate.mjs's
85
+ // COVERAGE_TOP_PASSAGES) once retrieval returns. The scorer that knows
86
+ // the real number is created inside retrievalScorer()'s own closure, not
87
+ // reachable from here, so this is a fixed approximation used to decide
88
+ // how many of fusedFull's records to include in the batched hydration
89
+ // below, alongside phrase-pinning's own candidates — hydrating a couple
90
+ // more than coverage strictly needs just costs a little extra bandwidth
91
+ // on a cache entry that goes unread; hydrating fewer only narrows how
92
+ // much of coverage's later hydration hits warm cache. Either way is
93
+ // harmless: it's a batch-sizing choice, not a correctness one.
94
+ const COVERAGE_BATCH_COUNT = 5;
81
95
  // Only BM25 hits within this fraction of the top lexical score join fusion.
82
96
  // Common query terms give every matching document a near-tied score (idf
83
97
  // collapses them flat); a wide cutoff let that tied mass ride into fusion on
@@ -125,13 +139,23 @@ function toHex(bytes) {
125
139
  const isSha256Hex = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
126
140
 
127
141
  // SHA-256 via WebCrypto (browser, workerd, Node 18+); node:crypto fallback
128
- // for older Node. Fails closed: verification is not optional in this reader.
142
+ // for older Node, gated on an actual Node runtime check rather than just
143
+ // "WebCrypto is missing" — a browser can lack crypto.subtle too (privacy
144
+ // settings, a hardened build), and a bare dynamic import('node:crypto')
145
+ // there isn't a Node module resolution failure, it's a cross-origin module
146
+ // fetch a browser refuses outright, surfacing as an opaque module-loading
147
+ // error instead of the clear message below. Fails closed either way:
148
+ // verification is not optional in this reader.
129
149
  async function sha256hex(bytes) {
130
150
  if (globalThis.crypto?.subtle) {
131
151
  return toHex(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)));
132
152
  }
133
- const { createHash } = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node:crypto');
134
- return createHash('sha256').update(bytes).digest('hex');
153
+ if (typeof process !== 'undefined' && process.versions?.node) {
154
+ const { createHash } = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node:crypto');
155
+ return createHash('sha256').update(bytes).digest('hex');
156
+ }
157
+ throw new Error('No crypto backend available (crypto.subtle is missing) — '
158
+ + 'cannot verify artifact integrity in this environment');
135
159
  }
136
160
 
137
161
  function asUint8Array(bytes) {
@@ -628,12 +652,25 @@ export async function openPikeletFile(input, options = {}) {
628
652
  // record that fails its digest must fail the query, not
629
653
  // skew its verdict.
630
654
  const passageSource = fusedHits?.length ? fusedHits : hits;
655
+ // A record's text starts with its own heading echoed as the
656
+ // first line (ingest.mjs's section-to-chunk join). A
657
+ // heading match still grounds the query but at reduced
658
+ // credit — see retrieval-abstention.mjs's coverageFrac and
659
+ // calibrate.mjs's HEADING_COVERAGE_WEIGHT, which this split
660
+ // must match exactly (build-time and serve-time coverage
661
+ // must agree, or the fitted thresholds are scoring a
662
+ // different signal than the one they were calibrated
663
+ // against).
664
+ const splitHeadingBody = (text) => {
665
+ const nl = String(text || '').indexOf('\n');
666
+ return nl === -1 ? { heading: text, body: '' } : { heading: text.slice(0, nl), body: text.slice(nl + 1) };
667
+ };
631
668
  const topTexts = scorer.usesPassage && passageSource.length
632
669
  ? (await Promise.all(passageSource.slice(0, scorer.passagesNeeded || 1)
633
- .map((hit) => hydrate(hit.id)))).map((record) => record?.text)
670
+ .map((hit) => hydrate(hit.id)))).map((record) => splitHeadingBody(record?.text))
634
671
  : [];
635
- const scored = scorer.score(context.text, hits, topTexts);
636
- return { match_quality: VERDICTS[scored.verdict] || scored.verdict, confidence: scored.p };
672
+ const scored = await scorer.score(context.text, hits, topTexts);
673
+ return { match_quality: VERDICTS[scored.verdict] || scored.verdict, confidence: scored.p, grounding: scored.grounding || null };
637
674
  };
638
675
  };
639
676
  if (qiKind === 1) {
@@ -695,7 +732,7 @@ export async function openPikeletFile(input, options = {}) {
695
732
  } catch (err) {
696
733
  throw new Error('kind-3 artifact requires the inline-transformer kernels at '
697
734
  + 'complete/encoder-kernels/ (pikelet-wasm/complete); run '
698
- + 'examples/05-one-file-search/encoder-spike/build-encoder.sh to rebuild them',
735
+ + 'examples/one-file-search/encoder-spike/build-encoder.sh to rebuild them',
699
736
  { cause: err });
700
737
  }
701
738
  }
@@ -813,7 +850,7 @@ export async function openPikeletFile(input, options = {}) {
813
850
  // Hidden from bundlers like the node encoder kernels: the
814
851
  // web entrypoint's ?url wasm imports would break a browser
815
852
  // build that statically pulled this in.
816
- const { default: Pikelet } = await import(/* webpackIgnore: true */ /* @vite-ignore */ '../pikelet.node.mjs');
853
+ const { default: Pikelet } = await import(/* webpackIgnore: true */ /* @vite-ignore */ '../src/pikelet.node.mjs');
817
854
  await sketch.fullyResident;
818
855
  const created = await Pikelet.createSketchScanner(sketch, {
819
856
  maxRerank: Math.min(sketch.count, SCANNER_MAX_RERANK),
@@ -1076,13 +1113,51 @@ export async function openPikeletFile(input, options = {}) {
1076
1113
  fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
1077
1114
  } else if (retrieval !== 'augmented' && lexicalHits.length) {
1078
1115
  const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
1079
- fusedFull = searched
1080
- .map((hit, vRank) => ({
1081
- hit,
1082
- score: 1 / (RRF_K + vRank) + (lexRank.has(hit.id) ? 1 / (RRF_K + lexRank.get(hit.id)) : 0),
1083
- }))
1084
- .sort((a, b) => (b.score - a.score) || (a.hit.distance - b.hit.distance))
1085
- .map((entry) => entry.hit);
1116
+ fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
1117
+ // One batched hydration for everything this block and
1118
+ // scoreQuality's coverage term (below) will need, in
1119
+ // place of two separate staged fetches (phrase-pin's
1120
+ // own, then a later cold start for coverage): over a
1121
+ // remote range-read source each hydrate() a caller
1122
+ // hasn't already paid for is its own network round
1123
+ // trip, and sequential/staged awaits pay that cost
1124
+ // once per stage instead of once per query. The
1125
+ // union of phrase-pin's candidates (top lexical
1126
+ // hits) and coverage's (fusedFull's top few — phrase
1127
+ // pinning only ever reorders that window, never
1128
+ // changes its membership) is known right here, so
1129
+ // both stages can share one round trip. A record
1130
+ // that fails to hydrate here does not fail the
1131
+ // query: the id is just missing from the map, and
1132
+ // each site below falls through exactly as it would
1133
+ // on a cold cache miss (recs[i] undefined; coverage
1134
+ // sees fewer usable passages) — hydrate() raising
1135
+ // for a bad id is still a real failure, but only
1136
+ // once a site actually needs that specific record,
1137
+ // not speculatively for every candidate touched here.
1138
+ const phraseCandidates = lexicalHits.slice(0, 3);
1139
+ const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
1140
+ const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
1141
+ const batch = new Map();
1142
+ await Promise.all(batchIds.map(async (id) => {
1143
+ try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
1144
+ }));
1145
+ // Phrase pinning: a verbatim occurrence of the query (>= 3
1146
+ // content words) in one of the top lexical hits is the
1147
+ // strongest evidence of support there is; rank it first.
1148
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
1149
+ const phrase = norm(trimmed);
1150
+ if (phrase.split(' ').length >= 3) {
1151
+ for (const h of phraseCandidates) {
1152
+ if (norm(batch.get(h.id)?.text).includes(phrase)) {
1153
+ fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
1154
+ break;
1155
+ }
1156
+ }
1157
+ }
1158
+ // Rank transparency: a consumer can see a verbatim hit sitting
1159
+ // at fused rank 3 behind two semantic near-misses.
1160
+ for (const [i, hit] of fusedFull.entries()) { hit.fusedRank = i + 1; hit.lexicalRank = lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null; hit.vectorRank = searched.findIndex((x) => x.id === hit.id) + 1; }
1086
1161
  }
1087
1162
  fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
1088
1163
  }
@@ -1111,9 +1186,14 @@ export async function openPikeletFile(input, options = {}) {
1111
1186
  // `distance` field cannot overwrite them (reserved names).
1112
1187
  const results = await Promise.all(returned.map(async (hit) => {
1113
1188
  const record = await hydrate(hit.id);
1114
- return { ...record, id: hit.id, distance: hit.distance };
1189
+ return { ...record, id: hit.id, distance: hit.distance, ...(hit.fusedRank ? { fusedRank: hit.fusedRank, lexicalRank: hit.lexicalRank, vectorRank: hit.vectorRank } : {}) };
1115
1190
  }));
1116
- return { matchQuality: quality.match_quality, confidence: quality.confidence, results };
1191
+ // Grounding transparency: which record's text grounded the verdict
1192
+ // and which query words it covered, so a consumer can tell a
1193
+ // topical match ("much money darcy make year") from an answer.
1194
+ const passageOrder = fusedFull?.length ? fusedFull : (searched ?? hits);
1195
+ const grounding = quality.grounding ? { ...quality.grounding, recordId: passageOrder[quality.grounding.passageIndex]?.id ?? null } : null;
1196
+ return { matchQuality: quality.match_quality, confidence: quality.confidence, results, ...(grounding ? { grounding } : {}) };
1117
1197
  },
1118
1198
 
1119
1199
  /** Hydrate one corpus record by id (verified per record on format 2). */
@@ -8,6 +8,10 @@ const decoder = new TextDecoder();
8
8
  // here, host-side, before the first forward.
9
9
  export const KERNEL_LAYOUT = { V: 30522, P: 512, T: 2, D: 384, F: 1536, L: 6, B: 64, H: 12 };
10
10
  const KERNEL_MAX_SEQ = 512;
11
+ // Smallest window that still frames interior tokens between [CLS] and [SEP]
12
+ // with room to make progress; see the maxTokens check in
13
+ // parseInlineTransformerEncoder().
14
+ const MIN_DECLARED_MAX_TOKENS = 8;
11
15
 
12
16
  // Byte size the kernel's fill_layout() will consume for a given layout:
13
17
  // each quantized matrix is rows*cols u8 plus per-block f32 scales and
@@ -43,6 +47,22 @@ export function parseInlineTransformerEncoder(encoderBytes) {
43
47
  if (declaration.dim !== KERNEL_LAYOUT.D) {
44
48
  throw new Error(`.pikelet inline-encoder declares dim ${declaration.dim}; the kernel emits ${KERNEL_LAYOUT.D}`);
45
49
  }
50
+ // maxTokens sizes the kernel's ids/hidden heap buffers, and the windowing
51
+ // arithmetic (windowLen = maxSeq - 2) assumes it leaves room for [CLS] and
52
+ // [SEP] plus interior tokens. A pack declaring 1 makes windowLen negative,
53
+ // so interior.slice(w * windowLen, (w + 1) * windowLen) returns nearly the
54
+ // whole sequence and the forward pass writes far past both allocations; 2
55
+ // makes windowLen 0, so the window count is Infinity. Reject the whole
56
+ // range here, at parse, so nothing is allocated from an unusable value.
57
+ // The floor is well under any value the toolchain emits (the builder
58
+ // declares 512; the BEIR encoder's own --max-tokens floor is 16).
59
+ if (declaration.maxTokens !== undefined && declaration.maxTokens !== null) {
60
+ const declared = declaration.maxTokens;
61
+ if (!Number.isInteger(declared) || declared < MIN_DECLARED_MAX_TOKENS || declared > KERNEL_MAX_SEQ) {
62
+ throw new Error(`.pikelet inline-encoder declares maxTokens ${declared}; `
63
+ + `must be an integer in [${MIN_DECLARED_MAX_TOKENS}, ${KERNEL_MAX_SEQ}]`);
64
+ }
65
+ }
46
66
  const expected = expectedBlobBytes(layout);
47
67
  if (blob.length !== expected) {
48
68
  throw new Error(`.pikelet inline-encoder blob is ${blob.length} bytes but its declared layout implies ${expected}`);
@@ -63,7 +83,14 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
63
83
  const tokenizer = createWordPiece(vocabText);
64
84
  const EM = await createEncoder();
65
85
  const dim = declaration.dim;
66
- const maxSeq = Math.min(declaration.maxTokens || KERNEL_MAX_SEQ, KERNEL_MAX_SEQ);
86
+ // Clamped on BOTH sides: parseInlineTransformerEncoder() rejects an
87
+ // out-of-range declaration, but this function is also called directly (the
88
+ // BEIR ladder, encoder ablations), so the buffer sizing must not depend on
89
+ // that check having run.
90
+ const maxSeq = Math.min(
91
+ Math.max(MIN_DECLARED_MAX_TOKENS, Math.trunc(declaration.maxTokens) || KERNEL_MAX_SEQ),
92
+ KERNEL_MAX_SEQ,
93
+ );
67
94
  const blobPtr = EM._malloc(blob.length);
68
95
  EM.HEAPU8.set(blob, blobPtr);
69
96
  const idsPtr = EM._malloc(maxSeq * 4);
@@ -88,6 +115,11 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
88
115
  const windowsToEncode = usesClsPooling ? 1 : windows;
89
116
  for (let w = 0; w < windowsToEncode; w++) {
90
117
  const tokenIds = [allIds[0], ...interior.slice(w * windowLen, (w + 1) * windowLen), allIds[allIds.length - 1]];
118
+ // The windowing above must never produce more tokens than the buffers
119
+ // were sized for; fail closed instead of writing past them.
120
+ if (tokenIds.length > maxSeq) {
121
+ throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
122
+ }
91
123
  new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
92
124
  const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
93
125
  if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
@@ -112,11 +144,75 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
112
144
  return { vector: pooled, text, windows };
113
145
  }
114
146
 
147
+ // Per-word vectors from the same forward pass embed() already runs — the
148
+ // kernel's outHidden buffer holds every token's final-layer hidden state
149
+ // (encoder.cpp's encoder_forward does no pooling itself; JS pools it),
150
+ // so this is not a second, slower embedding path: it is the same
151
+ // forward pass, with the per-token buffer copied out and pooled per
152
+ // WORD (via encodeWithWordMap's word alignment, merging WordPiece
153
+ // sub-tokens like "quant"+"##ization" into one vector) instead of
154
+ // discarded after sentence-level pooling. Used for MaxSim-style
155
+ // token-level grounding (complete/retrieval-abstention.mjs, calibrate.
156
+ // mjs), an alternative to coverage1's exact/stemmed word match that
157
+ // measures semantic closeness instead of lexical identity — so a query
158
+ // and passage that use different words for the same idea can still
159
+ // ground each other, the case coverage1 structurally cannot handle.
160
+ // Windowing/CLS-pooling limits mirror embed(): a word that falls past
161
+ // the first window under CLS pooling, or past the last window under
162
+ // mean pooling's cap, has no vector and is left out of the result.
163
+ async function embedWords(text) {
164
+ const { ids: allIds, wordOf: allWordOf, words } = tokenizer.encodeWithWordMap(String(text || ''));
165
+ const interior = allIds.slice(1, -1);
166
+ const interiorWordOf = allWordOf.slice(1, -1);
167
+ const windowLen = maxSeq - 2;
168
+ const windows = Math.max(1, Math.ceil(interior.length / windowLen));
169
+ const windowsToEncode = usesClsPooling ? 1 : windows;
170
+ // Accumulate per-word sums, then average — a word split into several
171
+ // WordPiece pieces gets one vector, not several.
172
+ const wordSums = words.map(() => new Float64Array(dim));
173
+ const wordCounts = words.map(() => 0);
174
+ for (let w = 0; w < windowsToEncode; w++) {
175
+ const idSlice = interior.slice(w * windowLen, (w + 1) * windowLen);
176
+ const wordOfSlice = interiorWordOf.slice(w * windowLen, (w + 1) * windowLen);
177
+ const tokenIds = [allIds[0], ...idSlice, allIds[allIds.length - 1]];
178
+ // The windowing above must never produce more tokens than the buffers
179
+ // were sized for; fail closed instead of writing past them.
180
+ if (tokenIds.length > maxSeq) {
181
+ throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
182
+ }
183
+ new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
184
+ const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
185
+ if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
186
+ const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
187
+ // Offset by 1: tokenIds[0] is [CLS], so idSlice's tokens start at
188
+ // hidden row 1, matching wordOfSlice's own indexing into idSlice.
189
+ for (let t = 0; t < wordOfSlice.length; t++) {
190
+ const wi = wordOfSlice[t];
191
+ if (wi === -1) continue;
192
+ const row = (t + 1) * dim;
193
+ for (let d = 0; d < dim; d++) wordSums[wi][d] += hidden[row + d];
194
+ wordCounts[wi]++;
195
+ }
196
+ }
197
+ const vectors = new Map();
198
+ for (let wi = 0; wi < words.length; wi++) {
199
+ if (wordCounts[wi] === 0) continue;
200
+ const v = new Float32Array(dim);
201
+ let norm = 0;
202
+ for (let d = 0; d < dim; d++) { v[d] = wordSums[wi][d] / wordCounts[wi]; norm += v[d] ** 2; }
203
+ norm = Math.sqrt(norm) || 1;
204
+ for (let d = 0; d < dim; d++) v[d] /= norm;
205
+ vectors.set(words[wi], v);
206
+ }
207
+ return vectors;
208
+ }
209
+
115
210
  const embedder = {
116
211
  declaration,
117
212
  dim,
118
213
  maxSeq,
119
214
  embed,
215
+ embedWords,
120
216
  dispose() {
121
217
  EM._free(hiddenPtr);
122
218
  EM._free(idsPtr);
@@ -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;
@@ -42,21 +60,83 @@ export function createAbstentionScorer(asset, bloomBytes) {
42
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,11 +167,13 @@ 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) {
176
+ async score(queryText, results, passageTexts) {
95
177
  // Fit at build time (pikelet/src/calibrate.mjs) always scores a
96
178
  // fixed top-10 window (K = min(10, candidates)), independent of
97
179
  // whatever k a caller later passes to query(). Slicing to the
@@ -110,12 +192,23 @@ export function createAbstentionScorer(asset, bloomBytes) {
110
192
  asset.features.forEach((f, j) => {
111
193
  z += ((signals[f] - asset.standardize.mean[f]) / asset.standardize.std[f]) * asset.weights[j];
112
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;
113
199
  if (coverageCfg) {
114
200
  // passageTexts is hydrated by the caller for the fixed
115
201
  // top-COVERAGE_TOP_PASSAGES window (see passagesNeeded
116
202
  // below), independent of the caller's k, matching
117
203
  // calibrate.mjs's top.slice(0, COVERAGE_TOP_PASSAGES).
118
- signals.coverage1 = coverageFrac(queryText, Array.isArray(passageTexts) ? passageTexts : [passageTexts]);
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.
119
212
  z += ((signals.coverage1 - coverageCfg.mean) / (coverageCfg.std || 1)) * coverageCfg.weight;
120
213
  }
121
214
  // A malformed asset (unknown feature name, non-numeric term) must
@@ -125,7 +218,7 @@ export function createAbstentionScorer(asset, bloomBytes) {
125
218
  const p = 1 / (1 + Math.exp(-z));
126
219
  const verdict = p < asset.thresholds.hard ? 'abstain'
127
220
  : p < asset.thresholds.weak ? 'weak' : 'answer';
128
- return { p, verdict, signals };
221
+ return { p, verdict, signals, grounding };
129
222
  },
130
223
  };
131
224
  }
@@ -1,4 +1,4 @@
1
- import { pikeletError, PIKELET_ERROR_CODES } from '../pikelet-errors.js';
1
+ import { pikeletError, PIKELET_ERROR_CODES } from '../src/errors/pikelet-errors.js';
2
2
 
3
3
  export function httpRangeSource(url, options = {}) {
4
4
  const stats = { requests: 0, bytes: 0, acceptRanges: null, etag: null, fullFallback: false, retries: 0, redirects: 0 };
@@ -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
  }