pikelet-wasm 0.8.1 → 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 +110 -33
- package/README.md +17 -1
- package/complete/builder.mjs +17 -4
- package/complete/format.mjs +6 -1
- package/complete/index.mjs +482 -58
- 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 +15 -4
- package/complete/tombstones.mjs +281 -0
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +31 -1
- package/src/artifact/pikelet-artifact-sketch.js +67 -5
|
@@ -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
|
}
|
|
@@ -151,14 +151,25 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
151
151
|
}
|
|
152
152
|
const SEEDS = [0, 0x9e3779b9];
|
|
153
153
|
|
|
154
|
-
|
|
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
|
+
}
|
|
155
166
|
const words = text.toLowerCase().match(/[a-z0-9']+/g) || [];
|
|
156
167
|
if (!words.length) return 0;
|
|
157
168
|
let known = 0;
|
|
158
169
|
for (const w of words) {
|
|
159
170
|
const hit = SEEDS.every((seed) => {
|
|
160
171
|
const bit = fnv1a(w, seed);
|
|
161
|
-
return (
|
|
172
|
+
return (table[bit >> 3] >> (bit & 7)) & 1;
|
|
162
173
|
});
|
|
163
174
|
if (hit) known++;
|
|
164
175
|
}
|
|
@@ -173,7 +184,7 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
173
184
|
// fits them together), so coverageCfg.topK already covers both.
|
|
174
185
|
usesPassage: !!coverageCfg,
|
|
175
186
|
passagesNeeded: coverageCfg ? (coverageCfg.topK || 1) : 0,
|
|
176
|
-
async score(queryText, results, passageTexts) {
|
|
187
|
+
async score(queryText, results, passageTexts, options = {}) {
|
|
177
188
|
// Fit at build time (pikelet/src/calibrate.mjs) always scores a
|
|
178
189
|
// fixed top-10 window (K = min(10, candidates)), independent of
|
|
179
190
|
// whatever k a caller later passes to query(). Slicing to the
|
|
@@ -187,7 +198,7 @@ export function createAbstentionScorer(asset, bloomBytes) {
|
|
|
187
198
|
? top[Math.min(4, top.length - 1)].distance - d0 : 0;
|
|
188
199
|
const mean10 = top.length
|
|
189
200
|
? top.reduce((s, r) => s + r.distance, 0) / top.length : 1;
|
|
190
|
-
const signals = { d0, margin, mean10, known_frac: knownFrac(queryText) };
|
|
201
|
+
const signals = { d0, margin, mean10, known_frac: knownFrac(queryText, options.vocabBloom) };
|
|
191
202
|
let z = asset.bias;
|
|
192
203
|
asset.features.forEach((f, j) => {
|
|
193
204
|
z += ((signals[f] - asset.standardize.mean[f]) / asset.standardize.std[f]) * asset.weights[j];
|