pikelet-wasm 0.10.0 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ Through 0.6.0 these were published as `pancake-wasm` and
8
8
  packages are pre-1.0: a minor bump may carry breaking changes, and each entry lists them
9
9
  first.
10
10
 
11
+ ## pikelet-wasm 0.10.1 / pikelet 0.10.1 — 2026-10-11
12
+
13
+ Layered packs now serve the same grounding and fetch tuning as single
14
+ files, HTTP readers make fewer, larger range requests, and lazily read
15
+ keyword indexes cache what they read. Upgrade if you serve layered packs
16
+ over `pikelet mcp`: under 0.10.0 they returned no `grounding`, the fact
17
+ check a relevance verdict leaves to the reader. Both packages move to
18
+ 0.10.1 together, and `pikelet` requires `pikelet-wasm` ^0.10.1.
19
+
20
+ ### Compatibility
21
+
22
+ - **HTTP range sources coalesce rerank rows within 256 KiB by default**
23
+ (was 16 KiB), and a pack's `recommendedGap` is a floor under the
24
+ source's own gap rather than a replacement for it. On the 456,153-record
25
+ Wikipedia pack a query makes about 38 range requests instead of 61 and
26
+ transfers about 2.3 MiB instead of 0.9 MiB; results are unchanged. Pass
27
+ `preferredGapBytes` to `httpRangeSource`, or `gap` to `query()`, to
28
+ choose otherwise.
29
+
30
+ ### Fixed
31
+
32
+ - **A layered pack returns `grounding`.** The chain reader kept only the
33
+ inherited scorer's verdict and confidence, so `grounding.covered` and
34
+ `grounding.uncovered` were missing from chain queries and from MCP
35
+ `search` sections as soon as a pack gained a layer. `recordId` is the
36
+ chain-global id of the passage the coverage was taken from.
37
+ - **A layered pack honors fetch tuning.** `gap`, `parallelism` and
38
+ `maxRangeBytes` (and the mount-level `rerankGap`, `rerankParallelism`
39
+ and `rerankMaxRangeBytes`) did not reach the tiers, and a tier's
40
+ manifest `recommendedGap` never applied. Each tier now fetches as its
41
+ own file would.
42
+ - **Lazily read keyword indexes cache postings and share reads.** A
43
+ repeated query re-read every postings list, and query terms whose
44
+ entries share an uncached term-table window each read it. Postings are
45
+ now cached (4 MiB, by bytes), pending reads are shared, and a failed
46
+ read is retried by the next query rather than cached.
47
+ - **`CompleteQueryResult` declares `grounding`** in the TypeScript
48
+ types; `query()` already returned it.
49
+
11
50
  ## pikelet-wasm 0.10.0 / pikelet 0.10.0 — 2026-10-09
12
51
 
13
52
  `matchQuality` changes meaning for packs compiled with this release: it
@@ -41,6 +41,11 @@ export interface HttpRangeSource extends CompleteRangeSource {
41
41
 
42
42
  export declare function httpRangeSource(url: string, options?: {
43
43
  preferredParallelism?: number;
44
+ /**
45
+ * Rows of the vector rerank closer than this many bytes are fetched in
46
+ * one request, reading the bytes between them. Default 256 KiB. A pack's
47
+ * manifest recommendedGap applies only when it is wider.
48
+ */
44
49
  preferredGapBytes?: number;
45
50
  /** Cap on the one-time full download when a host ignores Range, enforced on received bytes. Default 64 MiB. */
46
51
  maxFullFallbackBytes?: number;
@@ -84,6 +89,22 @@ export interface CompleteQueryResult {
84
89
  * other way round.
85
90
  */
86
91
  results: Array<{ id: number; distance: number } & Record<string, unknown>>;
92
+ /**
93
+ * Which passage grounded the verdict and which of the query's content
94
+ * words it contains. Present when a fitted calibrator scored the query
95
+ * (absent on 'unscored'). An asked-for value in `uncovered` is one no top
96
+ * passage states, whatever matchQuality says.
97
+ */
98
+ grounding?: {
99
+ /** Id of the passage the coverage score was taken from. */
100
+ recordId: number | null;
101
+ /** Index of that passage in the fused (or distance-ordered) window. */
102
+ passageIndex: number;
103
+ /** Weighted share of the query's content words the passage contains, 0-1. */
104
+ coverage: number;
105
+ covered: string[];
106
+ uncovered: string[];
107
+ };
87
108
  }
88
109
 
89
110
  export interface CompleteSearch {
@@ -1243,6 +1243,19 @@ export async function openPikeletFile(input, options = {}) {
1243
1243
  if (closed) throw new Error('.pikelet reader is closed');
1244
1244
  };
1245
1245
 
1246
+ // The rerank's default coalescing gap. The identity-verified manifest
1247
+ // may carry a fetch hint: cluster-ordered layouts only coalesce at a
1248
+ // gap at least matched to their cluster geometry (the wiki pack's
1249
+ // k-means layout needs ~16 KiB; a 2 KiB gap squanders it — measured
1250
+ // 440 vs 200 requests/query at C=600). The hint is a floor: a wider
1251
+ // gap merges a superset of the same runs, so a source that prefers
1252
+ // more (HTTP's 256 KiB) keeps its own. undefined leaves the sketch
1253
+ // reader on the source's preference. A chain reads it per tier
1254
+ // through __chainTier.
1255
+ const rerankGapHint = Number.isFinite(manifest.recommendedGap)
1256
+ ? Math.max(manifest.recommendedGap, Number(source.preferredGapBytes) || 0)
1257
+ : undefined;
1258
+
1246
1259
  return {
1247
1260
  info() {
1248
1261
  return {
@@ -1368,14 +1381,7 @@ export async function openPikeletFile(input, options = {}) {
1368
1381
  searched = (await sketch.search(context.vector, k, {
1369
1382
  rerank: queryOptions.rerank,
1370
1383
  parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
1371
- // The identity-verified manifest may carry a fetch
1372
- // hint: cluster-ordered layouts only coalesce at a
1373
- // gap matched to their cluster geometry (the wiki
1374
- // pack's k-means layout needs ~16 KiB; the default
1375
- // 2 KiB squanders it — measured 440 vs 200
1376
- // requests/query at C=600).
1377
- gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap
1378
- ?? (Number.isFinite(manifest.recommendedGap) ? manifest.recommendedGap : undefined),
1384
+ gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? rerankGapHint,
1379
1385
  maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
1380
1386
  // Present once background staging finished; the
1381
1387
  // sketch reader ignores it for any query whose C
@@ -1588,6 +1594,10 @@ export async function openPikeletFile(input, options = {}) {
1588
1594
  // the kernel. A getter, not a captured value, because
1589
1595
  // staging completes after open() returns.
1590
1596
  get scanner() { return scanner; },
1597
+ // The rerank's default coalescing gap for this member
1598
+ // (manifest hint as a floor under the source's own), so
1599
+ // a chain fetches each tier's rows as this member would.
1600
+ rerankGap: rerankGapHint,
1591
1601
  // The base's OWN encoder, for `append` (6.1): "a kind-3
1592
1602
  // base carries the teacher, and append loads it from the
1593
1603
  // base's own query-interp segment — no model download, no
@@ -465,6 +465,13 @@ export async function openPikeletChain(members, options = {}) {
465
465
  // tier has no live deletion (maskCounts, computed at mount).
466
466
  const out = await t.reader.sketch.search(vector, Math.min(k, t.records), {
467
467
  rerank: queryOptions.rerank,
468
+ // The single-file reader's fetch tuning, with the same
469
+ // precedence: the query's own value, then the mount's,
470
+ // then this tier's default gap (its manifest hint as a
471
+ // floor under its source's preference).
472
+ parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
473
+ gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? t.reader.rerankGap,
474
+ maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
468
475
  ...(maskCounts[j] > 0 ? { exclude: masks[j] } : {}),
469
476
  // The tier's own staged scan kernel. Read per query, not
470
477
  // captured at mount: staging finishes in the background
@@ -528,6 +535,7 @@ export async function openPikeletChain(members, options = {}) {
528
535
  // to prevent.
529
536
  let matchQuality = 'unscored';
530
537
  let confidence;
538
+ let grounding = null;
531
539
  if (calibration.status === 'inherited' && base.scoreQuality) {
532
540
  // Coverage reads the fit's passagesNeeded from the FULL
533
541
  // fused window; top.slice(0, 3) gave it one passage at k=1.
@@ -553,6 +561,13 @@ export async function openPikeletChain(members, options = {}) {
553
561
  if (scored && scored.match_quality) {
554
562
  matchQuality = scored.match_quality;
555
563
  if (Number.isFinite(scored.confidence)) confidence = scored.confidence;
564
+ // Which passage grounded the verdict and which query
565
+ // words no passage contains, as the single-file
566
+ // reader returns it; recordId is the chain-global id
567
+ // of the passage coverage scored.
568
+ if (scored.grounding) {
569
+ grounding = { ...scored.grounding, recordId: passageOrder[scored.grounding.passageIndex]?.id ?? null };
570
+ }
556
571
  }
557
572
  } else if (calibration.status === 'none') {
558
573
  matchQuality = 'unscored';
@@ -567,6 +582,7 @@ export async function openPikeletChain(members, options = {}) {
567
582
  matchQuality,
568
583
  ...(confidence === undefined ? {} : { confidence }),
569
584
  results: withheld ? [] : results,
585
+ ...(grounding ? { grounding } : {}),
570
586
  identity: chainMembers[chainMembers.length - 1].identity,
571
587
  calibration: {
572
588
  status: calibration.status,
@@ -80,15 +80,58 @@ export async function openLexicalIndexLazy(read, segLength) {
80
80
  const doclenOf = (id) => doclenView.getUint32(4 * id, true);
81
81
 
82
82
  const WINDOW = 512;
83
- const windowCache = new Map(); // startIdx -> entry bytes (small LRU)
84
- async function readWindow(startIdx, count) {
83
+ // Both caches hold the pending read, not its result, so concurrent
84
+ // lookups of one window (two query terms whose hashes land in it) or one
85
+ // postings list share a single read; a failed read is evicted so the
86
+ // next query retries rather than re-throwing a cached rejection.
87
+ const windowCache = new Map(); // `${startIdx}:${count}` -> Promise<entry bytes> (small LRU)
88
+ function readWindow(startIdx, count) {
85
89
  const key = `${startIdx}:${count}`;
86
90
  const hit = windowCache.get(key);
87
- if (hit) return hit;
88
- const bytes = await read(termTableOffset + 24 * startIdx, 24 * count);
91
+ if (hit) {
92
+ windowCache.delete(key);
93
+ windowCache.set(key, hit);
94
+ return hit;
95
+ }
96
+ const pending = read(termTableOffset + 24 * startIdx, 24 * count);
89
97
  if (windowCache.size >= 64) windowCache.delete(windowCache.keys().next().value);
90
- windowCache.set(key, bytes);
91
- return bytes;
98
+ windowCache.set(key, pending);
99
+ pending.catch(() => { if (windowCache.get(key) === pending) windowCache.delete(key); });
100
+ return pending;
101
+ }
102
+ // Postings, LRU by bytes: a repeated or overlapping query re-reads only
103
+ // the terms it has not seen. A list larger than POSTINGS_CACHE_MAX_ENTRY
104
+ // (a very common term) is read every time rather than evicting
105
+ // everything else.
106
+ const POSTINGS_CACHE_BYTES = 4 * 1024 * 1024;
107
+ const POSTINGS_CACHE_MAX_ENTRY = 1024 * 1024;
108
+ const postingsCache = new Map(); // postingsRel -> { pending, bytes }
109
+ let postingsCachedBytes = 0;
110
+ function readPostings(rel, len) {
111
+ const hit = postingsCache.get(rel);
112
+ if (hit && hit.bytes === len) {
113
+ postingsCache.delete(rel);
114
+ postingsCache.set(rel, hit);
115
+ return hit.pending;
116
+ }
117
+ const pending = read(postingsOffset + rel, len);
118
+ if (len > POSTINGS_CACHE_MAX_ENTRY) return pending;
119
+ const entry = { pending, bytes: len };
120
+ if (hit) postingsCachedBytes -= hit.bytes;
121
+ postingsCache.set(rel, entry);
122
+ postingsCachedBytes += len;
123
+ while (postingsCachedBytes > POSTINGS_CACHE_BYTES && postingsCache.size > 1) {
124
+ const [oldRel, old] = postingsCache.entries().next().value;
125
+ postingsCache.delete(oldRel);
126
+ postingsCachedBytes -= old.bytes;
127
+ }
128
+ pending.catch(() => {
129
+ if (postingsCache.get(rel) === entry) {
130
+ postingsCache.delete(rel);
131
+ postingsCachedBytes -= len;
132
+ }
133
+ });
134
+ return pending;
92
135
  }
93
136
  const entryAt = (bytes, i) => {
94
137
  const view = new DataView(bytes.buffer, bytes.byteOffset + 24 * i, 24);
@@ -195,7 +238,7 @@ export async function openLexicalIndexLazy(read, segLength) {
195
238
  throw new Error('.pikelet lexical postings out of bounds');
196
239
  }
197
240
  }
198
- const postings = await Promise.all(found.map((e) => read(postingsOffset + e.postingsRel, e.postingsLen)));
241
+ const postings = await Promise.all(found.map((e) => readPostings(e.postingsRel, e.postingsLen)));
199
242
  const scores = new Map();
200
243
  found.forEach((e, i) => {
201
244
  const bytes = postings[i];
@@ -102,7 +102,12 @@ export function httpRangeSource(url, options = {}) {
102
102
  url,
103
103
  size: undefined,
104
104
  preferredParallelism: options.preferredParallelism ?? 32,
105
- preferredGapBytes: options.preferredGapBytes ?? 16 * 1024,
105
+ // Rows closer than this are fetched in one request, reading the
106
+ // bytes between them. Over HTTP a round trip costs far more than
107
+ // the bytes: on the 648.5 MB wiki pack in Chromium, 256 KiB took
108
+ // 61 -> 38 requests and about 2.8 -> 2.2 s per query against 16 KiB,
109
+ // at 0.9 -> 2.3 MiB transferred.
110
+ preferredGapBytes: options.preferredGapBytes ?? 256 * 1024,
106
111
 
107
112
  async init() {
108
113
  await resolveTarget();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikelet-wasm",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Vector search engine and .pikelet artifact readers/builders, in WebAssembly. Compile a corpus into one self-contained, hash-verified search file, or use the underlying HNSW engine directly. Runs in Node.js, browsers, and Cloudflare Workers with no native dependencies.",
5
5
  "main": "src/pikelet.js",
6
6
  "types": "src/pikelet.d.ts",