pikelet-wasm 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +340 -4
  2. package/NOTICE +5 -0
  3. package/README.md +226 -475
  4. package/complete/builder.d.ts +4 -4
  5. package/complete/builder.mjs +26 -9
  6. package/complete/encoder-kernels/encoder.node.wasm +0 -0
  7. package/complete/encoder-kernels/encoder.wasm +0 -0
  8. package/complete/fusion.mjs +72 -0
  9. package/complete/index.d.ts +34 -7
  10. package/complete/index.mjs +258 -129
  11. package/complete/inline-transformer.mjs +119 -10
  12. package/complete/lexical.mjs +11 -11
  13. package/complete/retrieval-abstention.mjs +136 -31
  14. package/complete/sources.mjs +22 -1
  15. package/complete/wordpiece.mjs +22 -0
  16. package/dist/engine.js +1 -1
  17. package/dist/engine.scalar.js +1 -1
  18. package/dist/engine.scalar.wasm +0 -0
  19. package/dist/engine.wasm +0 -0
  20. package/package.json +47 -82
  21. package/{pancake-artifact-common.js → src/artifact/pikelet-artifact-common.js} +42 -42
  22. package/{pancake-artifact-range.js → src/artifact/pikelet-artifact-range.js} +43 -36
  23. package/{pancake-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +105 -68
  24. package/{pancake-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +11 -11
  25. package/{pancake-artifact.js → src/artifact/pikelet-artifact.js} +10 -10
  26. package/{pancake-core.js → src/core/pikelet-core.js} +141 -140
  27. package/{pancake-errors.js → src/errors/pikelet-errors.js} +9 -8
  28. package/{pancake.d.ts → src/pikelet.d.ts} +55 -55
  29. package/{pancake.js → src/pikelet.js} +47 -47
  30. package/{pancake.node.mjs → src/pikelet.node.mjs} +49 -49
  31. package/{pancake.web.d.ts → src/pikelet.web.d.ts} +13 -13
  32. package/{pancake.web.mjs → src/pikelet.web.mjs} +24 -24
  33. package/{pancake.workerd.mjs → src/pikelet.workerd.mjs} +22 -22
  34. /package/{pancake-loader.js → src/loader/pikelet-loader.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * pikelet-wasm/complete/builder — Node-only assembly of complete .pancake
2
+ * pikelet-wasm/complete/builder — Node-only assembly of complete .pikelet
3
3
  * artifacts (spec/COMPLETE_PROFILE.md). The wire-format constants here are
4
4
  * the same objects the reader uses (complete/format.mjs).
5
5
  */
@@ -15,8 +15,8 @@ export declare function canonicalJson(value: unknown): string;
15
15
  export declare const align16: (n: number) => number;
16
16
 
17
17
  /** Manifest profile strings and the header formatVersion each one implies. */
18
- export declare const PROFILE_V1: 'pancake-complete-v1';
19
- export declare const PROFILE_V2: 'pancake-complete-v2';
18
+ export declare const PROFILE_V1: 'pikelet-complete-v1';
19
+ export declare const PROFILE_V2: 'pikelet-complete-v2';
20
20
  export declare const FORMAT_VERSIONS: Record<string, number>;
21
21
  /** Corpus layout name for format 2 (per-record digests behind a page table). */
22
22
  export declare const CORPUS_LAYOUT_V2: 'records-v2';
@@ -52,7 +52,7 @@ export declare function buildInlineTransformerEncoderSegment(input: {
52
52
  * buildCorpusSegment() corpus); mismatches throw. Segments with a kind this
53
53
  * spec does not name must carry an explicit kindNumber (readers skip them).
54
54
  */
55
- export declare function assemblePancakeFile(
55
+ export declare function assemblePikeletFile(
56
56
  manifestFields: Record<string, unknown> & { profile: string; corpus: { records: number } & Record<string, unknown> },
57
57
  segments: Array<{ kind: string; bytes: Buffer | Uint8Array; kindNumber?: number }>,
58
58
  outPath: string
@@ -81,8 +81,25 @@ export function buildQueryInterpSegment(kind, encoderBytes, calibrationBytes) {
81
81
  // and skips a small function-word stopword list — readers need no
82
82
  // coordination with the list, because an unindexed query token simply finds
83
83
  // no postings.
84
+ //
85
+ // Interrogative words (how/does/what/who/why/where/when/which/do/did) are
86
+ // deliberately in this list, not just the base one: without them, a
87
+ // natural-language question's own scaffolding becomes real, indexable BM25
88
+ // content — a passage containing literal dialogue like "How does Georgiana
89
+ // get on, Darcy?" then wins a high lexical score against the query "How
90
+ // does Elizabeth initially react to Mr. Darcy?" purely from "how does" and
91
+ // "darcy" co-occurring, with zero topical relevance. Confirmed live: this
92
+ // pushed a genuinely correct chunk (ranked #2 by the vector search alone)
93
+ // completely out of the fused top-10, replaced by RRF-boosted noise. Not
94
+ // borrowed wholesale from calibrate.mjs's COVERAGE_STOPWORDS, which also
95
+ // excludes domain words ("tell", "explain", "overview", "history",
96
+ // "definition") that are legitimate lexical CONTENT on many real corpora
97
+ // (a docs site literally titled "History") — this list only adds the
98
+ // unambiguous interrogative set, since the lexical index (unlike coverage)
99
+ // also indexes passage text, not just the query.
84
100
  const LEXICAL_LAYOUT = 'bm25-v1';
85
- const LEXICAL_STOPWORDS = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'into', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'as', 'their', 'they', 'them', 'his', 'her', 'has', 'have', 'had', 'not', 'can', 'will', 'if', 'we', 'you', 'your', 'i']);
101
+ const LEXICAL_STOPWORDS = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'into', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'as', 'their', 'they', 'them', 'his', 'her', 'has', 'have', 'had', 'not', 'can', 'will', 'if', 'we', 'you', 'your', 'i',
102
+ 'how', 'does', 'did', 'do', 'what', 'who', 'why', 'where', 'when', 'which']);
86
103
  const lexTokenize = (text) => (String(text).toLowerCase().match(/[a-z0-9']+/g) || [])
87
104
  .filter((w) => w.length >= 2 && w.length <= 32 && !LEXICAL_STOPWORDS.has(w));
88
105
 
@@ -228,7 +245,7 @@ export function buildLexicalSegment(texts) {
228
245
  };
229
246
  }
230
247
 
231
- // Corpus layout v1 (format 1, profile pancake-complete-v1): count + offsets +
248
+ // Corpus layout v1 (format 1, profile pikelet-complete-v1): count + offsets +
232
249
  // records, integrity by whole-segment digest only. Kept for producing
233
250
  // format-1 files (compatibility fixtures, readers that predate format 2);
234
251
  // new artifacts use buildCorpusSegment() below.
@@ -248,7 +265,7 @@ export function buildCorpusSegmentFromBuffers(records) {
248
265
  return out;
249
266
  }
250
267
 
251
- // Corpus layout v2 (format 2, profile pancake-complete-v2): per-record
268
+ // Corpus layout v2 (format 2, profile pikelet-complete-v2): per-record
252
269
  // integrity that a reader can check on the single range read that hydrates
253
270
  // a record, without fetching the rest of the segment (contract 4.1 / 7):
254
271
  //
@@ -352,7 +369,7 @@ export async function measureRecommendedRerank({
352
369
  async read(offset, length) { return bytes.subarray(offset, offset + length); },
353
370
  async close() {},
354
371
  };
355
- const sketch = await artifactModule.PancakeSketchArtifact.open(source);
372
+ const sketch = await artifactModule.PikeletSketchArtifact.open(source);
356
373
  try {
357
374
  const count = sketch.count;
358
375
  if (count === 0) throw new Error('cannot measure rerank on an empty sketch');
@@ -432,16 +449,16 @@ function selfQueriesFromSnapshot(graph, maxQueries) {
432
449
  return queries;
433
450
  }
434
451
 
435
- // Container format version follows the profile string: pancake-complete-v1
436
- // files carry corpus layout v1 and header version 1; pancake-complete-v2
452
+ // Container format version follows the profile string: pikelet-complete-v1
453
+ // files carry corpus layout v1 and header version 1; pikelet-complete-v2
437
454
  // files carry corpus layout records-v2 (buildCorpusSegment) and header
438
455
  // version 2, so readers that predate per-record integrity reject them
439
456
  // explicitly instead of misreading the corpus tables.
440
- export const PROFILE_V1 = 'pancake-complete-v1';
441
- export const PROFILE_V2 = 'pancake-complete-v2';
457
+ export const PROFILE_V1 = 'pikelet-complete-v1';
458
+ export const PROFILE_V2 = 'pikelet-complete-v2';
442
459
  export const FORMAT_VERSIONS = { [PROFILE_V1]: 1, [PROFILE_V2]: 2 };
443
460
 
444
- export function assemblePancakeFile(manifestFields, segments, outPath) {
461
+ export function assemblePikeletFile(manifestFields, segments, outPath) {
445
462
  const formatVersion = FORMAT_VERSIONS[manifestFields.profile];
446
463
  if (!formatVersion) {
447
464
  throw new Error(`manifest.profile must be ${PROFILE_V1} or ${PROFILE_V2}, got ${manifestFields.profile}`);
@@ -0,0 +1,72 @@
1
+ // Vector/lexical rank fusion for hybrid retrieval — the one definition the
2
+ // reader (index.mjs), the calibrator (pikelet/src/calibrate.mjs, which fits
3
+ // coverage on the passage fusion ranks first) and the BEIR ladder
4
+ // (benchmarks/beir/query-E.mjs) all call, so a fit-time or benchmark
5
+ // ranking is the ranking the reader serves.
6
+ //
7
+ // Reciprocal-rank fusion over the exact-reranked vector order and the BM25
8
+ // order: score = 1/(K + vRank) + lexicalWeight/(K + lexRank), lexical term
9
+ // present only for records BM25 returned. Every lexical candidate joins the
10
+ // vector pool (the reader reranks them exactly), so it always has a vRank.
11
+ //
12
+ // The vector-margin guard: when the vector top-1 leads the top-2 by at
13
+ // least guardMargin of its own distance, it keeps rank 1 regardless of
14
+ // fusion. Without it, with K=60 any record that appears in both lists
15
+ // outranks a vector-only rank 1 (1/(60+v) + w/(60+l) > 1/60 for any finite
16
+ // v when w=1), so a query whose terms match many chunks — a character name
17
+ // in a novel — fills the top ranks with term matches and buries the
18
+ // passage the encoder placed first. Measured on three sets with
19
+ // scripts/fusion-sim.mjs before any default changed.
20
+
21
+ export const FUSION_DEFAULTS = Object.freeze({
22
+ rrfK: 60,
23
+ lexicalWeight: 0.5,
24
+ guardMargin: 0.05,
25
+ });
26
+
27
+ /**
28
+ * @param {Array<{id: number, distance: number}>} vectorOrder hits sorted by
29
+ * exact distance ascending (the rerank output), including every lexical
30
+ * candidate.
31
+ * @param {number[]} lexicalIds record ids in BM25 order (after the cutoff).
32
+ * @param {{rrfK?: number, lexicalWeight?: number, guardMargin?: number}} [options]
33
+ * @returns the same hit objects in fused order.
34
+ */
35
+ export function fuseCandidates(vectorOrder, lexicalIds, options = {}) {
36
+ const rrfK = options.rrfK ?? FUSION_DEFAULTS.rrfK;
37
+ const lexicalWeight = options.lexicalWeight ?? FUSION_DEFAULTS.lexicalWeight;
38
+ const guardMargin = options.guardMargin ?? FUSION_DEFAULTS.guardMargin;
39
+ if (!(rrfK > 0) || !(lexicalWeight >= 0) || !(guardMargin >= 0)) {
40
+ throw new Error('fuseCandidates: rrfK must be > 0, lexicalWeight and guardMargin >= 0');
41
+ }
42
+ if (!lexicalIds.length) return vectorOrder.slice();
43
+ const lexRank = new Map();
44
+ lexicalIds.forEach((id, i) => { if (!lexRank.has(id)) lexRank.set(id, i); });
45
+ let fused = vectorOrder
46
+ .map((hit, vRank) => ({
47
+ hit,
48
+ score: 1 / (rrfK + vRank) + (lexRank.has(hit.id) ? lexicalWeight / (rrfK + lexRank.get(hit.id)) : 0),
49
+ }))
50
+ .sort((a, b) => (b.score - a.score) || (a.hit.distance - b.hit.distance))
51
+ .map((entry) => entry.hit);
52
+ if (guardMargin > 0 && vectorOrder.length > 1) {
53
+ const [first, second] = vectorOrder;
54
+ // The margin is relative to the top-1's own distance, which needs a
55
+ // positive denominator. first.distance === 0 is not a degenerate case
56
+ // to skip but the STRONGEST possible top-1 -- cosine distance is
57
+ // 1 - dot, so a query matching passage text verbatim scores exactly 0
58
+ // -- and gating on `first.distance > 0` disabled the guard for exactly
59
+ // the hit it exists to protect (a worse top-1 at 0.001 was guarded, a
60
+ // perfect one at 0 was not). Any second distance strictly greater than
61
+ // a zero first is an infinite relative lead, so the guard fires.
62
+ // Distances are non-negative here (cosine/L2 as returned by the
63
+ // rerank); a metric admitting negatives would need an absolute margin.
64
+ const lead = first.distance > 0
65
+ ? (second.distance - first.distance) / first.distance
66
+ : (second.distance > 0 ? Infinity : 0);
67
+ if (lead >= guardMargin && fused[0] !== first) {
68
+ fused = [first, ...fused.filter((hit) => hit !== first)];
69
+ }
70
+ }
71
+ return fused;
72
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * pikelet-wasm/complete — reader for complete .pancake artifacts
3
- * (spec/COMPLETE_PROFILE.md, profile pancake-complete-v1). Opens any kind
2
+ * pikelet-wasm/complete — reader for complete .pikelet artifacts
3
+ * (spec/COMPLETE_PROFILE.md, profile pikelet-complete-v1). Opens any kind
4
4
  * (1 student-inline, 2 declared-external, 3 inline-transformer) from a file
5
5
  * path in Node or a range source anywhere.
6
6
  */
@@ -74,7 +74,7 @@ export interface CompleteSearch {
74
74
  identity: string;
75
75
  /** Container header formatVersion (1 or 2). */
76
76
  formatVersion: number;
77
- /** Manifest profile: 'pancake-complete-v1' | 'pancake-complete-v2'. */
77
+ /** Manifest profile: 'pikelet-complete-v1' | 'pikelet-complete-v2'. */
78
78
  profile: string;
79
79
  /**
80
80
  * Human-readable pack name from the identity-verified manifest
@@ -158,6 +158,13 @@ export interface CompleteSearch {
158
158
  parallelism?: number;
159
159
  gap?: number;
160
160
  maxRangeBytes?: number;
161
+ /**
162
+ * A matchQuality: 'none' verdict withholds results by default. Set
163
+ * true to see the raw retrieval anyway (e.g. to inspect why the
164
+ * classifier abstained) — matchQuality and confidence are unaffected,
165
+ * this only controls whether results is populated. Default false.
166
+ */
167
+ showAbstained?: boolean;
161
168
  }): Promise<CompleteQueryResult>;
162
169
  /** Hydrate one corpus record by id (verified per record on format 2). */
163
170
  record(id: number): Promise<Record<string, unknown>>;
@@ -166,7 +173,7 @@ export interface CompleteSearch {
166
173
  close(): Promise<void>;
167
174
  }
168
175
 
169
- export declare function openPancakeFile(
176
+ export declare function openPikeletFile(
170
177
  input: string | CompleteRangeSource,
171
178
  options?: {
172
179
  /**
@@ -238,6 +245,29 @@ export declare function openPancakeFile(
238
245
 
239
246
  /** Header formatVersion -> manifest profile accepted by this reader. */
240
247
  export declare const SUPPORTED_PROFILES: Readonly<Record<number, string>>;
248
+
249
+ /** Rank-fusion parameters shared by the reader, the calibrator and the BEIR ladder. */
250
+ export interface FusionOptions {
251
+ /** Reciprocal-rank-fusion constant (default 60). */
252
+ rrfK?: number;
253
+ /** Weight of the lexical (BM25) rank term relative to the vector term (default 0.5). */
254
+ lexicalWeight?: number;
255
+ /**
256
+ * Relative distance margin (top-2 minus top-1, over top-1) at or above which
257
+ * the vector top-1 keeps rank 1 (default 0.05; 0 disables the guard).
258
+ */
259
+ guardMargin?: number;
260
+ }
261
+ export declare const FUSION_DEFAULTS: Readonly<Required<FusionOptions>>;
262
+ /**
263
+ * Fuse the exact-reranked vector order (every lexical candidate included) with the BM25 order.
264
+ * Returns the same hit objects, reordered.
265
+ */
266
+ export declare function fuseCandidates<T extends { id: number; distance: number }>(
267
+ vectorOrder: T[],
268
+ lexicalIds: number[],
269
+ options?: FusionOptions,
270
+ ): T[];
241
271
  export declare const CORPUS_LAYOUT_V2: 'records-v2';
242
272
  /**
243
273
  * Check a host encoder against a kind-2 declaration's verification vectors
@@ -284,6 +314,3 @@ export declare function buildInlineTestVectors(embedder: unknown, texts?: string
284
314
  text: string; windows: number; embedding: number[]; tolerance: number;
285
315
  }>>;
286
316
  export declare function verifyInlineTestVectors(embedder: unknown): Promise<{ checked: number }>;
287
-
288
- /** Documented alias for openPancakeFile — the project renamed to Pikelet; the reader's original export stays for compatibility. */
289
- export declare const openPikeletFile: typeof openPancakeFile;