pikelet-wasm 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +399 -2
  2. package/README.md +81 -257
  3. package/complete/builder.mjs +35 -5
  4. package/complete/format.mjs +6 -1
  5. package/complete/fusion.mjs +72 -0
  6. package/complete/index.d.ts +23 -0
  7. package/complete/index.mjs +580 -76
  8. package/complete/inline-transformer.mjs +97 -1
  9. package/complete/layer-append.mjs +246 -0
  10. package/complete/layer-chain.mjs +284 -0
  11. package/complete/layer-compact.mjs +319 -0
  12. package/complete/layer-encoder.mjs +223 -0
  13. package/complete/layer-locator.mjs +228 -0
  14. package/complete/layer-manifest.mjs +271 -0
  15. package/complete/layer-reader.mjs +759 -0
  16. package/complete/layer-rebase.mjs +218 -0
  17. package/complete/lexical.mjs +78 -8
  18. package/complete/retrieval-abstention.mjs +132 -28
  19. package/complete/sources.mjs +1 -1
  20. package/complete/tombstones.mjs +281 -0
  21. package/complete/wordpiece.mjs +22 -0
  22. package/dist/engine.js +1 -1
  23. package/dist/engine.scalar.js +1 -1
  24. package/dist/engine.scalar.wasm +0 -0
  25. package/dist/engine.wasm +0 -0
  26. package/package.json +65 -77
  27. package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
  28. package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
  29. package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +97 -8
  30. package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
  31. package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
  32. package/{pikelet.js → src/pikelet.js} +7 -7
  33. package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
  34. package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
  35. package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
  36. /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
  37. /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
  38. /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
  39. /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
  40. /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
@@ -0,0 +1,218 @@
1
+ // `rebase` — LAYERED_PROFILE.md 6.2.
2
+ //
3
+ // Two producers appending to the same parent create a fork. Both chains are
4
+ // valid; the ref names one head. The other layer is re-based onto it: its rows,
5
+ // sketches, records, postings and bloom are copied verbatim (nothing is
6
+ // re-embedded, local ids do not change), and its rowBase, bitset, supersessions
7
+ // and evaluation material are recomputed.
8
+ //
9
+ // The correctness rule the spec was revised twice to get right:
10
+ //
11
+ // A rebase replays what the layer ITSELF did -- its delta over its old
12
+ // parent, and the edges physically in its own segment -- and NEVER its
13
+ // cumulative bitset. T_L contains every deletion any ancestor on the old
14
+ // branch performed; replaying it would import those ancestors' operations
15
+ // into a branch that never performed them, and worse, import an ancestor's
16
+ // tombstone without that ancestor's supersession edge: half of one operation.
17
+ //
18
+ // To compute the delta, the rebase MUST open L's ORIGINAL parent. If that
19
+ // artifact is unavailable the delta is unknowable and the rebase must refuse --
20
+ // it cannot be reconstructed from L alone.
21
+
22
+ import { zeroExtend, bitsetBytesFor, testBit, popcountBytes } from './tombstones.mjs';
23
+ import { canonicalJson } from './layer-manifest.mjs';
24
+
25
+ export const ON_CONFLICT = Object.freeze({ REFUSE: 'refuse', SKIP: 'skip', KEEP_BOTH: 'keep-both' });
26
+ export const ON_FOREIGN = Object.freeze({ REFUSE: 'refuse', DROP: 'drop' });
27
+
28
+ /**
29
+ * The fork point: the longest common prefix of L's original parent chain and
30
+ * H's chain, compared layer by layer from the base BY IDENTITY, and the total
31
+ * row count of that prefix.
32
+ *
33
+ * Every id below forkRowBase names the same record in both histories. Every id
34
+ * at or above it belongs to a layer the two histories do not share — fork
35
+ * siblings assign overlapping id ranges to different records, so such an id is
36
+ * foreign.
37
+ *
38
+ * @param {Array<{identity: string, records: number}>} oldChain base-first, L's ancestors
39
+ * @param {Array<{identity: string, records: number}>} newChain base-first, H's chain
40
+ */
41
+ export function forkPoint(oldChain, newChain) {
42
+ let i = 0;
43
+ let forkRowBase = 0;
44
+ while (i < oldChain.length && i < newChain.length
45
+ && oldChain[i].identity === newChain[i].identity) {
46
+ forkRowBase += oldChain[i].records;
47
+ i += 1;
48
+ }
49
+ if (i === 0) {
50
+ throw new Error('the two histories share no base: a rebase requires H.baseIdentity == L.baseIdentity (6.2)');
51
+ }
52
+ return { sharedPrefix: i, forkRowBase };
53
+ }
54
+
55
+ /**
56
+ * Preconditions a rebase must satisfy before it constructs any bytes (6.2).
57
+ * They are producer preconditions precisely so a rebase never emits a layer a
58
+ * reader would refuse.
59
+ */
60
+ export function checkRebasePreconditions({ layerManifest, headChainDecl, layerChainDecl, headIsLegacyBase = false, headBaseIdentity, layerBaseIdentity }) {
61
+ if (headBaseIdentity !== layerBaseIdentity) {
62
+ throw new Error(`rebase refuses: the head's base ${String(headBaseIdentity).slice(0, 12)}… is not the layer's base ${String(layerBaseIdentity).slice(0, 12)}… (6.2)`);
63
+ }
64
+ // Same base is NOT enough. Two depth-1 forks of a legacy base can each have
65
+ // asserted a DIFFERENT declaration, and copying L's layer.ingest onto H's
66
+ // history would emit a layer a reader must reject.
67
+ if (headIsLegacyBase) {
68
+ // H is the legacy depth-0 base itself and has no declaration yet, so a
69
+ // rebased depth-1 layer MAY introduce L's asserted declaration.
70
+ if (layerChainDecl && !layerChainDecl.asserted) {
71
+ throw new Error('rebase refuses: the head is a legacy base with no declaration, but the layer\'s declaration is not asserted (6.2)');
72
+ }
73
+ return { ingest: layerManifest.layer.ingest, ingestAsserted: true };
74
+ }
75
+ if (!headChainDecl || !layerChainDecl) {
76
+ throw new Error('rebase refuses: an ingestion declaration is missing on one of the two histories (6.1, 6.2)');
77
+ }
78
+ if (headChainDecl.canonical !== layerChainDecl.canonical) {
79
+ throw new Error('rebase refuses: the two histories disagree on the chain ingestion declaration; a copied layer.ingest would be rejected by a reader (6.2)');
80
+ }
81
+ return { ingest: layerManifest.layer.ingest, ingestAsserted: headChainDecl.asserted };
82
+ }
83
+
84
+ /**
85
+ * Plan a rebase.
86
+ *
87
+ * @param {object} spec
88
+ * @param {Uint8Array} spec.layerBitset T_L, the layer's own cumulative bitset
89
+ * @param {Uint8Array} spec.oldParentBitset T_oldParent — REQUIRED; the delta is
90
+ * unknowable without it, and a rebase must refuse rather than guess.
91
+ * @param {number} spec.oldRowBase L.layer.rowBase
92
+ * @param {number} spec.layerRecords R_L
93
+ * @param {Array<[number, number]>} spec.layerSupersessions S_L, physically recorded in L
94
+ * @param {number} spec.newRowBase H.rowBase + H.records
95
+ * @param {Uint8Array} spec.headBitset T_H
96
+ * @param {Map<number, Array<{depth:number,newId:number}>>} spec.headSupersessions S_H
97
+ * @param {number} spec.forkRowBase
98
+ * @param {'refuse'|'drop'} [spec.onForeign]
99
+ * @param {'refuse'|'skip'|'keep-both'} [spec.onConflict]
100
+ */
101
+ export function planRebase({
102
+ layerBitset, oldParentBitset, oldRowBase, layerRecords, layerSupersessions = [],
103
+ newRowBase, headBitset, headSupersessions = new Map(), forkRowBase,
104
+ onForeign = ON_FOREIGN.REFUSE, onConflict = ON_CONFLICT.REFUSE,
105
+ }) {
106
+ if (!(oldParentBitset instanceof Uint8Array)) {
107
+ throw new Error('rebase refuses: the layer\'s original parent is unavailable, so its delta over that parent is unknowable (6.2)');
108
+ }
109
+ if (!Object.values(ON_FOREIGN).includes(onForeign)) throw new Error(`--on-foreign must be one of ${Object.values(ON_FOREIGN).join(', ')}`);
110
+ if (!Object.values(ON_CONFLICT).includes(onConflict)) throw new Error(`--on-conflict must be one of ${Object.values(ON_CONFLICT).join(', ')}`);
111
+
112
+ // ΔT_L = T_L \ zeroExtend(T_oldParent, L.rowBase) — never T_L as a whole.
113
+ const inheritedOld = zeroExtend(oldParentBitset, oldRowBase);
114
+ const delta = [];
115
+ for (let id = 0; id < oldRowBase; id++) {
116
+ if (testBit(layerBitset, id) && !testBit(inheritedOld, id)) delta.push(id);
117
+ }
118
+
119
+ // Two kinds of id, treated oppositely (6.2). Ids L OWNS — its records and
120
+ // the y side of every edge — are offsets into its own range and must be
121
+ // translated. Ids L REFERENCES — ΔT_L and the x side — are ancestor ids,
122
+ // stable only below the fork point.
123
+ const translateY = (oldY) => newRowBase + (oldY - oldRowBase);
124
+
125
+ // Foreign ids: at or above forkRowBase they belong to a layer the two
126
+ // histories do not share. The test applies to ΔT_L, not to T_L: bits L
127
+ // inherited from its old branch are not L's to carry anywhere.
128
+ const foreignDeletions = delta.filter((x) => x >= forkRowBase);
129
+ const foreignEdges = layerSupersessions.filter(([x]) => x >= forkRowBase);
130
+ if ((foreignDeletions.length || foreignEdges.length) && onForeign === ON_FOREIGN.REFUSE) {
131
+ const ids = [...new Set([...foreignDeletions, ...foreignEdges.map(([x]) => x)])].sort((a, b) => a - b);
132
+ throw new Error(`rebase refuses: ${ids.length} foreign id(s) at or above the fork point ${forkRowBase} name different records in the two histories: ${ids.join(', ')} (pass --on-foreign drop to discard them) (6.2)`);
133
+ }
134
+ const survivingDeletions = delta.filter((x) => x < forkRowBase);
135
+ const survivingEdges = layerSupersessions.filter(([x]) => x < forkRowBase);
136
+ const droppedForeign = { deletions: foreignDeletions, edges: foreignEdges.map(([x]) => x) };
137
+
138
+ // Conflicts, deterministically (6.2).
139
+ const conflicts = [];
140
+ const recordedEdges = [];
141
+ const skippedEdges = [];
142
+ for (const [x, oldY] of survivingEdges) {
143
+ const newY = translateY(oldY);
144
+ // Every newId a rebased layer writes MUST lie in its new range.
145
+ if (newY < newRowBase || newY >= newRowBase + layerRecords) {
146
+ throw new Error(`rebase refuses: translated newId ${newY} for oldId ${x} falls outside the layer's new range [${newRowBase}, ${newRowBase + layerRecords}) (6.2)`);
147
+ }
148
+ const headEdges = headSupersessions.get(x);
149
+ const headY = headEdges && headEdges.length ? headEdges[headEdges.length - 1].newId : null;
150
+ if (headY !== null && headY !== newY) {
151
+ // H already records a DIFFERENT successor for x.
152
+ if (onConflict === ON_CONFLICT.REFUSE) { conflicts.push(x); continue; }
153
+ if (onConflict === ON_CONFLICT.SKIP) { skippedEdges.push(x); continue; }
154
+ // keep-both: L's edge is recorded as an additional edge, which makes
155
+ // 3.5's "all edges" view non-trivial — H's edge is the immediate
156
+ // successor and L's the current one (greatest depth wins).
157
+ recordedEdges.push([x, newY]);
158
+ continue;
159
+ }
160
+ // x not tombstoned in H, or tombstoned with no edge: recorded either
161
+ // way — L adds information H lacked.
162
+ recordedEdges.push([x, newY]);
163
+ }
164
+ if (conflicts.length) {
165
+ throw new Error(`rebase refuses: ${conflicts.length} supersession conflict(s) — the head already records a different successor for id(s) ${conflicts.join(', ')} (pass --on-conflict skip or keep-both) (6.2)`);
166
+ }
167
+
168
+ // The rebased bitset is zeroExtend(T_H, newRowBase) with the surviving
169
+ // ΔT_L bits set. A deletion already in T_H is a no-op, not a conflict:
170
+ // deletion is idempotent.
171
+ const bitset = new Uint8Array(bitsetBytesFor(newRowBase));
172
+ bitset.set(zeroExtend(headBitset, newRowBase).subarray(0, bitset.length));
173
+ const newlySet = [];
174
+ const setBit = (id) => {
175
+ if (id >= newRowBase) {
176
+ throw new Error(`rebase refuses: id ${id} is at or above the new rowBase ${newRowBase}`);
177
+ }
178
+ if (testBit(bitset, id)) return false;
179
+ bitset[id >> 3] |= 1 << (id & 7);
180
+ newlySet.push(id);
181
+ return true;
182
+ };
183
+ for (const x of survivingDeletions) setBit(x);
184
+ for (const [x] of recordedEdges) setBit(x);
185
+
186
+ // Terminal rule: an empty rebase emits NOTHING (6.2). If the layer owns no
187
+ // records and its surviving deletions set no bit the head does not already
188
+ // have, emitting would manufacture exactly the artifact 6.1 forbids and 4.3
189
+ // rejects — records 0 with no increase in tombstoneCount.
190
+ if (layerRecords === 0 && newlySet.length === 0) {
191
+ return {
192
+ emit: false,
193
+ reason: 'the rebased operation is empty: the target history already represents every deletion this layer performed (6.2)',
194
+ droppedForeign, skippedEdges,
195
+ };
196
+ }
197
+
198
+ return {
199
+ emit: true,
200
+ newRowBase,
201
+ records: layerRecords,
202
+ bitset,
203
+ tombstones: popcountBytes(bitset),
204
+ delta: survivingDeletions,
205
+ newlySet: newlySet.sort((a, b) => a - b),
206
+ supersessions: recordedEdges.sort((a, b) => a[0] - b[0]),
207
+ droppedForeign,
208
+ skippedEdges,
209
+ // Chain-level golden queries carry expected GLOBAL ids, which are wrong
210
+ // after a rebase: L's own ids moved and the surrounding chain changed.
211
+ // A rebase MUST either regenerate them against the rebased chain or drop
212
+ // them explicitly; it MUST NOT copy them (6.2).
213
+ goldenQueries: null,
214
+ goldenQueriesDropped: 'rebase',
215
+ };
216
+ }
217
+
218
+ export { canonicalJson };
@@ -40,6 +40,19 @@ const tokenize = (text) => (String(text).toLowerCase().match(/[a-z0-9']+/g) || [
40
40
  // is committed via the manifest digest but lazy reads are not individually
41
41
  // verified (the eager opener verifies the whole segment; per-page digests
42
42
  // are the format-v2 path if this gap needs closing).
43
+ // The df a term is scored with: the chain-global one from options.df when the
44
+ // caller supplied it (LAYERED_PROFILE.md 5.4), else the index's own. A global
45
+ // df below the local one is impossible for a real sum, so it is refused rather
46
+ // than producing a negative-count idf.
47
+ function globalDf(options, term, localDf) {
48
+ const g = options.df instanceof Map ? options.df.get(term) : undefined;
49
+ if (g === undefined) return localDf;
50
+ if (!Number.isSafeInteger(g) || g < localDf) {
51
+ throw new Error(`lexical options.df for "${term}" (${g}) is below this index's own df (${localDf})`);
52
+ }
53
+ return g;
54
+ }
55
+
43
56
  export async function openLexicalIndexLazy(read, segLength) {
44
57
  const headerBytes = await read(0, 64);
45
58
  if (!(headerBytes instanceof Uint8Array) || headerBytes.length < 64 || segLength < 64) {
@@ -134,17 +147,46 @@ export async function openLexicalIndexLazy(read, segLength) {
134
147
  termCount,
135
148
  totalTokens,
136
149
  lazy: true,
137
- async search(text, n) {
150
+ // This index's df for each of the query's terms (absent terms omitted).
151
+ async documentFrequencies(text) {
152
+ const terms = [...new Set(tokenize(text))];
153
+ const out = new Map();
154
+ await Promise.all(terms.map(async (t) => {
155
+ const e = await findTerm(fnv1a32(t, 0), fnv1a32(t, 0x9e3779b9));
156
+ if (e) out.set(t, e.df);
157
+ }));
158
+ return out;
159
+ },
160
+ // `options.exclude(id)` drops a row BEFORE the cap, and
161
+ // `options.stats` supplies chain-global {docCount, avgdl} for IDF and
162
+ // length normalization. Both exist for the layered profile
163
+ // (LAYERED_PROFILE.md 5.3, 5.4): a chain used to cap each tier at n
164
+ // and mask afterwards, so a tier whose top-n were all tombstoned
165
+ // contributed nothing while live rows below the cap were discarded;
166
+ // and each tier scored BM25 against its own docCount, making scores
167
+ // from different tiers incomparable before fusion. `options.df`
168
+ // (Map term -> df) is the chain-global df summed over tiers from each
169
+ // tier's documentFrequencies(); a term absent from it keeps this
170
+ // index's own df.
171
+ async search(text, n, options = {}) {
172
+ const exclude = typeof options.exclude === 'function' ? options.exclude : null;
173
+ const statDocCount = Number.isFinite(options.stats?.docCount) && options.stats.docCount > 0
174
+ ? options.stats.docCount : docCount;
175
+ const statAvgdl = Number.isFinite(options.stats?.avgdl) && options.stats.avgdl > 0
176
+ ? options.stats.avgdl : avgdl;
138
177
  const terms = [...new Set(tokenize(text))];
139
178
  if (!terms.length || docCount === 0) return [];
140
- const found = (await Promise.all(terms.map((t) => findTerm(fnv1a32(t, 0), fnv1a32(t, 0x9e3779b9)))))
141
- .filter(Boolean);
179
+ const found = (await Promise.all(terms.map(async (t) => {
180
+ const e = await findTerm(fnv1a32(t, 0), fnv1a32(t, 0x9e3779b9));
181
+ return e && { ...e, term: t };
182
+ }))).filter(Boolean);
142
183
  if (!found.length) return [];
143
184
  const postings = await Promise.all(found.map((e) => read(postingsOffset + e.postingsRel, e.postingsLen)));
144
185
  const scores = new Map();
145
186
  found.forEach((e, i) => {
146
187
  const bytes = postings[i];
147
- const idf = Math.log(1 + (docCount - e.df + 0.5) / (e.df + 0.5));
188
+ const df = globalDf(options, e.term, e.df);
189
+ const idf = Math.log(1 + (statDocCount - df + 0.5) / (df + 0.5));
148
190
  const cursor = { at: 0, end: bytes.length };
149
191
  const readVarint = () => {
150
192
  let value = 0;
@@ -163,8 +205,9 @@ export async function openLexicalIndexLazy(read, segLength) {
163
205
  docId = p === 0 ? readVarint() : docId + readVarint();
164
206
  const tf = readVarint();
165
207
  if (docId >= docCount) throw new Error('.pikelet lexical postings doc id out of range');
208
+ if (exclude && exclude(docId)) continue;
166
209
  const dl = doclenOf(docId);
167
- const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
210
+ const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / statAvgdl);
168
211
  scores.set(docId, (scores.get(docId) || 0) + (idf * tf * (BM25_K1 + 1)) / norm);
169
212
  }
170
213
  });
@@ -230,9 +273,34 @@ export function openLexicalIndex(bytes) {
230
273
  docCount,
231
274
  termCount,
232
275
  totalTokens,
276
+ // This index's df for each of the query's terms (absent terms omitted).
277
+ documentFrequencies(text) {
278
+ const out = new Map();
279
+ for (const term of new Set(tokenize(text))) {
280
+ const i = findTerm(fnv1a32(term, 0), fnv1a32(term, 0x9e3779b9));
281
+ if (i >= 0) out.set(term, view.getUint32(entryAt(i) + 20, true));
282
+ }
283
+ return out;
284
+ },
233
285
  // BM25 over the query's unique tokens; returns [{id, score}] sorted
234
286
  // by score descending (ties by id), at most n entries.
235
- search(text, n) {
287
+ // `options.exclude(id)` drops a row BEFORE the cap, and
288
+ // `options.stats` supplies chain-global {docCount, avgdl} for IDF and
289
+ // length normalization. Both exist for the layered profile
290
+ // (LAYERED_PROFILE.md 5.3, 5.4): a chain used to cap each tier at n
291
+ // and mask afterwards, so a tier whose top-n were all tombstoned
292
+ // contributed nothing while live rows below the cap were discarded;
293
+ // and each tier scored BM25 against its own docCount, making scores
294
+ // from different tiers incomparable before fusion. `options.df`
295
+ // (Map term -> df) is the chain-global df summed over tiers from each
296
+ // tier's documentFrequencies(); a term absent from it keeps this
297
+ // index's own df.
298
+ search(text, n, options = {}) {
299
+ const exclude = typeof options.exclude === 'function' ? options.exclude : null;
300
+ const statDocCount = Number.isFinite(options.stats?.docCount) && options.stats.docCount > 0
301
+ ? options.stats.docCount : docCount;
302
+ const statAvgdl = Number.isFinite(options.stats?.avgdl) && options.stats.avgdl > 0
303
+ ? options.stats.avgdl : avgdl;
236
304
  const terms = [...new Set(tokenize(text))];
237
305
  if (!terms.length || docCount === 0) return [];
238
306
  const scores = new Map();
@@ -243,15 +311,17 @@ export function openLexicalIndex(bytes) {
243
311
  const len = view.getUint32(entryAt(i) + 16, true);
244
312
  const df = view.getUint32(entryAt(i) + 20, true);
245
313
  if (rel + len > postingsBytes) throw new Error('.pikelet lexical postings out of bounds');
246
- const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
314
+ const scoringDf = globalDf(options, term, df);
315
+ const idf = Math.log(1 + (statDocCount - scoringDf + 0.5) / (scoringDf + 0.5));
247
316
  const cursor = { at: postingsOffset + rel, end: postingsOffset + rel + len };
248
317
  let docId = 0;
249
318
  for (let p = 0; p < df; p++) {
250
319
  docId = p === 0 ? readVarint(cursor) : docId + readVarint(cursor);
251
320
  const tf = readVarint(cursor);
252
321
  if (docId >= docCount) throw new Error('.pikelet lexical postings doc id out of range');
322
+ if (exclude && exclude(docId)) continue;
253
323
  const dl = view.getUint32(doclenOffset + 4 * docId, true);
254
- const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / avgdl);
324
+ const norm = tf + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / statAvgdl);
255
325
  scores.set(docId, (scores.get(docId) || 0) + (idf * tf * (BM25_K1 + 1)) / norm);
256
326
  }
257
327
  }
@@ -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;
@@ -71,14 +151,25 @@ export function createAbstentionScorer(asset, bloomBytes) {
71
151
  }
72
152
  const SEEDS = [0, 0x9e3779b9];
73
153
 
74
- function knownFrac(text) {
154
+ // `override` is the layered profile's union bloom (LAYERED_PROFILE.md
155
+ // 4.5): the bitwise OR of the base's bloom and every mounted layer's,
156
+ // which a chain MUST score against rather than the base's alone.
157
+ // Identical geometry is enforced where the union is built, and re-checked
158
+ // here because reading a different-sized bloom with these `bits` would
159
+ // silently probe the wrong positions rather than fail.
160
+ function knownFrac(text, override) {
161
+ const table = override || bloom;
162
+ if (table !== bloom && table.length !== bloom.length) {
163
+ throw new Error(`abstention: bloom override is ${table.length} bytes but the fit's is ${bloom.length}; `
164
+ + 'known_frac would probe the wrong bits');
165
+ }
75
166
  const words = text.toLowerCase().match(/[a-z0-9']+/g) || [];
76
167
  if (!words.length) return 0;
77
168
  let known = 0;
78
169
  for (const w of words) {
79
170
  const hit = SEEDS.every((seed) => {
80
171
  const bit = fnv1a(w, seed);
81
- return (bloom[bit >> 3] >> (bit & 7)) & 1;
172
+ return (table[bit >> 3] >> (bit & 7)) & 1;
82
173
  });
83
174
  if (hit) known++;
84
175
  }
@@ -87,11 +178,13 @@ export function createAbstentionScorer(asset, bloomBytes) {
87
178
 
88
179
  return {
89
180
  // 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).
181
+ // before scoring when usesPassage is true; the coverage/maxSim
182
+ // terms read them (older assets scored one passage; topK now
183
+ // defaults to it). maxSim never ships without coverage (calibrate.mjs
184
+ // fits them together), so coverageCfg.topK already covers both.
92
185
  usesPassage: !!coverageCfg,
93
186
  passagesNeeded: coverageCfg ? (coverageCfg.topK || 1) : 0,
94
- score(queryText, results, passageTexts) {
187
+ async score(queryText, results, passageTexts, options = {}) {
95
188
  // Fit at build time (pikelet/src/calibrate.mjs) always scores a
96
189
  // fixed top-10 window (K = min(10, candidates)), independent of
97
190
  // whatever k a caller later passes to query(). Slicing to the
@@ -105,17 +198,28 @@ export function createAbstentionScorer(asset, bloomBytes) {
105
198
  ? top[Math.min(4, top.length - 1)].distance - d0 : 0;
106
199
  const mean10 = top.length
107
200
  ? top.reduce((s, r) => s + r.distance, 0) / top.length : 1;
108
- const signals = { d0, margin, mean10, known_frac: knownFrac(queryText) };
201
+ const signals = { d0, margin, mean10, known_frac: knownFrac(queryText, options.vocabBloom) };
109
202
  let z = asset.bias;
110
203
  asset.features.forEach((f, j) => {
111
204
  z += ((signals[f] - asset.standardize.mean[f]) / asset.standardize.std[f]) * asset.weights[j];
112
205
  });
206
+ // Local to this call (not module/closure state — see
207
+ // coverageFrac's comment) so concurrent queries against the
208
+ // same open pack can never see each other's grounding detail.
209
+ let grounding = null;
113
210
  if (coverageCfg) {
114
211
  // passageTexts is hydrated by the caller for the fixed
115
212
  // top-COVERAGE_TOP_PASSAGES window (see passagesNeeded
116
213
  // below), independent of the caller's k, matching
117
214
  // calibrate.mjs's top.slice(0, COVERAGE_TOP_PASSAGES).
118
- signals.coverage1 = coverageFrac(queryText, Array.isArray(passageTexts) ? passageTexts : [passageTexts]);
215
+ const passages = Array.isArray(passageTexts) ? passageTexts : [passageTexts];
216
+ const coverage = coverageFrac(queryText, passages);
217
+ signals.coverage1 = coverage.value;
218
+ grounding = coverage.grounding;
219
+ // grounding1 is coverage1 alone — see this file's header
220
+ // comment and calibrate.mjs's GROUNDING_FEAT design note
221
+ // for why a semantic (maxSim1) blend was tried twice and
222
+ // removed both times.
119
223
  z += ((signals.coverage1 - coverageCfg.mean) / (coverageCfg.std || 1)) * coverageCfg.weight;
120
224
  }
121
225
  // A malformed asset (unknown feature name, non-numeric term) must
@@ -125,7 +229,7 @@ export function createAbstentionScorer(asset, bloomBytes) {
125
229
  const p = 1 / (1 + Math.exp(-z));
126
230
  const verdict = p < asset.thresholds.hard ? 'abstain'
127
231
  : p < asset.thresholds.weak ? 'weak' : 'answer';
128
- return { p, verdict, signals };
232
+ return { p, verdict, signals, grounding };
129
233
  },
130
234
  };
131
235
  }
@@ -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 };