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.
- package/CHANGELOG.md +321 -1
- package/README.md +65 -257
- package/complete/builder.mjs +18 -1
- package/complete/fusion.mjs +72 -0
- package/complete/index.d.ts +23 -0
- package/complete/index.mjs +100 -20
- package/complete/inline-transformer.mjs +97 -1
- package/complete/retrieval-abstention.mjs +118 -25
- package/complete/sources.mjs +1 -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 +35 -77
- package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
- package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
- package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +30 -3
- package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
- package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
- package/{pikelet.js → src/pikelet.js} +7 -7
- package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
- package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
- package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
- /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
- /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
- /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
- /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
- /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
package/complete/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
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/
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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;
|
|
@@ -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
|
-
|
|
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,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
|
|
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) {
|
|
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
|
-
|
|
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
|
}
|
package/complete/sources.mjs
CHANGED
|
@@ -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 };
|
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
|
}
|