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.
- package/CHANGELOG.md +399 -2
- package/README.md +81 -257
- package/complete/builder.mjs +35 -5
- package/complete/format.mjs +6 -1
- package/complete/fusion.mjs +72 -0
- package/complete/index.d.ts +23 -0
- package/complete/index.mjs +580 -76
- package/complete/inline-transformer.mjs +97 -1
- package/complete/layer-append.mjs +246 -0
- package/complete/layer-chain.mjs +284 -0
- package/complete/layer-compact.mjs +319 -0
- package/complete/layer-encoder.mjs +223 -0
- package/complete/layer-locator.mjs +228 -0
- package/complete/layer-manifest.mjs +271 -0
- package/complete/layer-reader.mjs +759 -0
- package/complete/layer-rebase.mjs +218 -0
- package/complete/lexical.mjs +78 -8
- package/complete/retrieval-abstention.mjs +132 -28
- package/complete/sources.mjs +1 -1
- package/complete/tombstones.mjs +281 -0
- 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 +65 -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} +97 -8
- 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
|
@@ -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 };
|
package/complete/lexical.mjs
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
141
|
-
|
|
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
|
|
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) /
|
|
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
|
-
|
|
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
|
|
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) /
|
|
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
|
-
//
|
|
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;
|
|
@@ -71,14 +151,25 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
71
151
|
}
|
|
72
152
|
const SEEDS = [0, 0x9e3779b9];
|
|
73
153
|
|
|
74
|
-
|
|
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 (
|
|
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
|
|
91
|
-
// them (older assets scored one passage; topK now
|
|
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
|
-
|
|
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
|
}
|
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 };
|