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.
@@ -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
  }
@@ -151,14 +151,25 @@ export function createAbstentionScorer(asset, bloomBytes) {
151
151
  }
152
152
  const SEEDS = [0, 0x9e3779b9];
153
153
 
154
- 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
+ }
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 (bloom[bit >> 3] >> (bit & 7)) & 1;
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];