pikelet-wasm 0.9.2 → 0.9.3

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,67 @@ 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.9.3 / pikelet 0.9.3 — 2026-10-06
12
+
13
+ Hardening for readers that mount packs from other publishers or hosts:
14
+ the index header commitment now covers the bytes actually parsed, and a
15
+ pack can no longer steer lazy reads, demand an exhaustive rerank, or turn
16
+ a malformed abstention asset into confident answers. Upgrade if you mount
17
+ packs you did not build, including over `pikelet mcp`. Both packages move
18
+ to 0.9.3 together, and `pikelet` requires `pikelet-wasm` ^0.9.3 so that
19
+ `pikelet mcp` serves through the hardened reader.
20
+
21
+ ### Compatibility
22
+
23
+ - **A pack that declares a rerank depth over 4096 is served at 4096 by
24
+ default**, unless the caller passes `rerank`. No published pack is
25
+ affected.
26
+ - **A pack whose abstention asset has malformed `thresholds` or
27
+ `coverage.topK` now reports `unscored`** instead of answering. Packs
28
+ built by `compile` are not affected.
29
+ - **`compile` declares at most 4096** for a pack over 4096 records whose
30
+ sweep does not meet its recall target, and warns. Other packs compile
31
+ as under 0.9.2.
32
+
33
+ ### Fixed
34
+
35
+ - **The index header commitment binds the bytes the reader parses.** The
36
+ complete reader checked `manifest.index.headerSha256` on its own read of
37
+ the sketch header, while the sketch parsed a separate read of the same
38
+ range. A host answering the two reads differently could pass the check
39
+ with the genuine header and have a forged one parsed, together with the
40
+ integrity anchors it carries, replacing the index under a pinned
41
+ identity. `PikeletSketchArtifact.open()` now takes
42
+ `expectedHeaderSha256` and checks the header it parses.
43
+ - **Lazily read keyword segments cannot steer reads or inflate them.**
44
+ Lexical segments over 8 MiB are read on demand without per-read
45
+ verification. Reads are now confined to the segment's window, and each
46
+ term entry is checked before its postings are fetched: inside the
47
+ postings region, `df` no larger than the corpus, and a length a postings
48
+ list of that `df` can occupy. `info().lexical.integrity` reports
49
+ `'unverified-lazy-reads'` for such segments and `'segment-sha256'` for
50
+ eagerly verified ones.
51
+ - **A pack's declared rerank depth is capped at 4096 by default.** The
52
+ sketch header's `recommendedRerank` is a producer hint; a value near the
53
+ row count made every query fetch, hash and rerank the whole vectors
54
+ region. Readers default to at most 4096, keep the declared value as
55
+ `declaredRecommendedRerank`, and still honor an explicit `rerank`. The
56
+ published wiki, astro-docs and rust-book packs declare 200, 40 and 30.
57
+ - **`compile` and `append` no longer declare a rerank depth readers will not
58
+ use.** `compile`'s rerank sweep fell back to the full row count when no
59
+ depth up to 640 met its recall target; it now stops at the same 4096
60
+ ceiling, records the recall measured there, and logs a warning when the
61
+ target was not met. `append` caps a layer's exhaustive hint the same way.
62
+ `measureRecommendedRerank` returns `targetReached` and `rerankCeiling`,
63
+ and `pikelet-wasm/artifact` exports `MAX_RECOMMENDED_RERANK`.
64
+ - **A malformed abstention asset serves `unscored`.** Missing or
65
+ non-numeric `thresholds` compared false against every score, so the pack
66
+ answered every query as if calibrated; out-of-order or out-of-range ones
67
+ gave verdicts the fit never defined. A `coverage.topK` outside 1–10 is
68
+ refused too, since each counted passage is a record read on every query.
69
+ The scorer now rejects such an asset and the pack reports `unscored`.
70
+ Assets written by `compile` are unaffected.
71
+
11
72
  ## pikelet-wasm 0.9.2 / pikelet 0.9.2 — 2026-10-01
12
73
 
13
74
  Makes the layered update path work as documented: `append` on a freshly
@@ -71,6 +71,10 @@ export declare function measureRecommendedRerank(input: {
71
71
  }): Promise<{
72
72
  recommendedRerank: number;
73
73
  recall: number;
74
+ /** False when no rung up to rerankCeiling reached targetRecall; recommendedRerank is then the ceiling. */
75
+ targetReached: boolean;
76
+ /** min(row count, MAX_RECOMMENDED_RERANK): the deepest rerank the sweep measures. */
77
+ rerankCeiling: number;
74
78
  k: number;
75
79
  targetRecall: number;
76
80
  queries: number;
@@ -351,6 +351,12 @@ export function buildInlineTransformerEncoderSegment({ declaration, vocabBytes,
351
351
  // targetRecall — plus the whole curve for the evaluation segment. Queries
352
352
  // are the corpus's own float embeddings when the caller has them, else rows
353
353
  // dequantized out of the snapshot.
354
+ //
355
+ // The sweep stops at the reader's default ceiling (MAX_RECOMMENDED_RERANK):
356
+ // it used to fall back to the full row count, a depth readers no longer
357
+ // honor by default, so a pack could record a recall it would not be served
358
+ // at. The last rung is the ceiling, measured like any other, and
359
+ // targetReached says whether the target was met below it.
354
360
  export async function measureRecommendedRerank({
355
361
  artifactModule,
356
362
  sketchBytes,
@@ -387,9 +393,10 @@ export async function measureRecommendedRerank({
387
393
  for (const q of queries) {
388
394
  truth.push(new Set((await sketch.search(q, topK, { rerank: count })).results.map((r) => r.id)));
389
395
  }
396
+ const rerankCeiling = Math.min(count, artifactModule.MAX_RECOMMENDED_RERANK ?? count);
390
397
  const ladder = (sweep || [10, 15, 20, 30, 40, 60, 80, 120, 160, 240, 320, 480, 640])
391
- .filter((c) => c >= topK && c < count);
392
- ladder.push(count);
398
+ .filter((c) => c >= topK && c < rerankCeiling);
399
+ ladder.push(rerankCeiling);
393
400
  const curve = [];
394
401
  let recommendedRerank = null;
395
402
  let recommendedRecall = null;
@@ -409,13 +416,16 @@ export async function measureRecommendedRerank({
409
416
  recommendedRecall = recall;
410
417
  }
411
418
  }
412
- if (recommendedRerank === null) {
413
- recommendedRerank = count;
419
+ const targetReached = recommendedRerank !== null;
420
+ if (!targetReached) {
421
+ recommendedRerank = rerankCeiling;
414
422
  recommendedRecall = curve[curve.length - 1].recall;
415
423
  }
416
424
  return {
417
425
  recommendedRerank,
418
426
  recall: recommendedRecall,
427
+ targetReached,
428
+ rerankCeiling,
419
429
  k: topK,
420
430
  targetRecall,
421
431
  queries: queries.length,
@@ -134,7 +134,13 @@ export interface CompleteSearch {
134
134
  * order fuses distance and BM25 rankings by reciprocal rank). Null on
135
135
  * vector-only artifacts.
136
136
  */
137
- lexical: { terms: number; docCount: number; lazy?: true } | null;
137
+ lexical: {
138
+ terms: number;
139
+ docCount: number;
140
+ /** 'segment-sha256' when read eagerly and verified whole; 'unverified-lazy-reads' for lazily opened segments. */
141
+ integrity: 'segment-sha256' | 'unverified-lazy-reads';
142
+ lazy?: true;
143
+ } | null;
138
144
  /**
139
145
  * 'engine' once a WASM scan kernel serves the resident scan
140
146
  * (auto-staged or injected via options.sketchScanner); 'js' before
@@ -780,7 +780,12 @@ export async function openPikeletFile(input, options = {}) {
780
780
  }
781
781
  return bytes;
782
782
  })(),
783
- PikeletSketchArtifact.open(windowSource(source, idx.offset, idx.length), { maxReadBytes }),
783
+ // Format 2's header commitment is checked inside open(), against
784
+ // the same bytes the sketch parses (see expectedHeaderSha256).
785
+ PikeletSketchArtifact.open(windowSource(source, idx.offset, idx.length), {
786
+ maxReadBytes,
787
+ ...(perRecord ? { expectedHeaderSha256: manifest.index.headerSha256 } : {}),
788
+ }),
784
789
  readChecked(source, corpus.offset, tablesBytes, 'corpus tables', maxReadBytes, fileBytes),
785
790
  // Lexical index (kind 5, OPTIONAL). Small segments: eager
786
791
  // whole-segment read, digest-verified like the query-interp
@@ -791,7 +796,15 @@ export async function openPikeletFile(input, options = {}) {
791
796
  segments.has('lexical') ? (async () => {
792
797
  const seg = segments.get('lexical');
793
798
  if (seg.length > LAZY_LEXICAL_BYTES) {
794
- const lexRead = (off, len) => readChecked(source, seg.offset + off, len, 'lexical segment', maxReadBytes, fileBytes);
799
+ // Confined to the segment's own window: lazy reads are
800
+ // not individually verified, so no field they carry may
801
+ // steer a read into another segment.
802
+ const lexRead = (off, len) => {
803
+ if (!Number.isSafeInteger(off) || !Number.isSafeInteger(len) || off < 0 || len < 0 || off + len > seg.length) {
804
+ throw new Error('.pikelet lexical segment read is outside the segment window');
805
+ }
806
+ return readChecked(source, seg.offset + off, len, 'lexical segment', maxReadBytes, fileBytes);
807
+ };
795
808
  return { lazyIndex: await openLexicalIndexLazy(lexRead, seg.length) };
796
809
  }
797
810
  const bytes = await readChecked(source, seg.offset, seg.length, 'lexical segment', maxReadBytes, fileBytes);
@@ -800,12 +813,6 @@ export async function openPikeletFile(input, options = {}) {
800
813
  }
801
814
  return { bytes };
802
815
  })() : null,
803
- perRecord ? (async () => {
804
- const headerBytes = await readChecked(source, idx.offset, SKETCH_HEADER_BYTES, 'index header', maxReadBytes, fileBytes);
805
- if (await sha256hex(headerBytes) !== manifest.index.headerSha256) {
806
- throw new Error('.pikelet index header failed hash verification against the manifest');
807
- }
808
- })() : null,
809
816
  ]);
810
817
 
811
818
  const lexicalIndex = lexicalBytes
@@ -1289,6 +1296,9 @@ export async function openPikeletFile(input, options = {}) {
1289
1296
  ? {
1290
1297
  terms: lexicalIndex.termCount,
1291
1298
  docCount: lexicalIndex.docCount,
1299
+ // Lazy reads are bounded to the segment but not
1300
+ // individually verified (LAZY_LEXICAL_BYTES).
1301
+ integrity: lexicalIndex.lazy ? 'unverified-lazy-reads' : 'segment-sha256',
1292
1302
  ...(lexicalIndex.lazy ? { lazy: true } : {}),
1293
1303
  }
1294
1304
  : null,
@@ -181,6 +181,20 @@ export async function openLexicalIndexLazy(read, segLength) {
181
181
  return e && { ...e, term: t };
182
182
  }))).filter(Boolean);
183
183
  if (!found.length) return [];
184
+ // Term entries arrive by lazy read, unverified (see the stance
185
+ // note above), so each one is held to the segment before its
186
+ // postings are fetched: inside the postings region (spec 3.8, as
187
+ // the eager reader enforces), a df no larger than the corpus, and
188
+ // a length a df-entry list can actually occupy (a doc-gap and a
189
+ // tf varint, each at most 6 bytes). Without these, one forged
190
+ // entry could steer a read anywhere in the file or ask for
191
+ // hundreds of MiB per query term.
192
+ for (const e of found) {
193
+ if (!Number.isSafeInteger(e.postingsRel) || e.postingsRel + e.postingsLen > postingsBytes
194
+ || e.df > docCount || e.postingsLen > 12 * e.df) {
195
+ throw new Error('.pikelet lexical postings out of bounds');
196
+ }
197
+ }
184
198
  const postings = await Promise.all(found.map((e) => read(postingsOffset + e.postingsRel, e.postingsLen)));
185
199
  const scores = new Map();
186
200
  found.forEach((e, i) => {
@@ -34,11 +34,25 @@
34
34
  // one — there is no scenario where re-adding this via a config flag was
35
35
  // going to end differently a third time.
36
36
 
37
+ // Upper bound on asset.coverage.topK. calibrate.mjs ships 5; every passage
38
+ // counted here is a record hydrated on every query, so a pack must not be
39
+ // able to turn one query into a hydration of its whole rerank window.
40
+ export const MAX_COVERAGE_TOP_K = 10;
41
+
37
42
  export function createAbstentionScorer(asset, bloomBytes) {
38
43
  if (!asset || !Array.isArray(asset.weights) || !asset.thresholds) return null;
44
+ // Thresholds that are missing, non-numeric or out of order compare false
45
+ // against every p and would answer everything; a malformed asset must
46
+ // degrade to unscored instead (callers map a null scorer to it).
47
+ const { hard, weak } = asset.thresholds;
48
+ if (!Number.isFinite(hard) || !Number.isFinite(weak) || hard < 0 || hard > weak || weak > 1) return null;
39
49
  const coverageCfg = asset.coverage && typeof asset.coverage.weight === 'number'
40
50
  && Number.isFinite(asset.coverage.mean) && Number.isFinite(asset.coverage.std)
41
51
  ? asset.coverage : null;
52
+ if (coverageCfg && coverageCfg.topK !== undefined
53
+ && !(Number.isInteger(coverageCfg.topK) && coverageCfg.topK >= 1 && coverageCfg.topK <= MAX_COVERAGE_TOP_K)) {
54
+ return null;
55
+ }
42
56
  const coverageStopwords = coverageCfg ? new Set(coverageCfg.stopwords || []) : null;
43
57
  const coverageMinLen = coverageCfg ? (coverageCfg.minWordLen || 3) : 3;
44
58
  // Words the corpus uses everywhere ground any query that mentions them,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikelet-wasm",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
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",
@@ -37,6 +37,12 @@ const {
37
37
  const SKETCH_MAGIC = 0x31415350; // PSA1
38
38
  const SKETCH_HEADER_BYTES = 256;
39
39
  const SKETCH_KIND_U8 = 1;
40
+ // Ceiling on the header's recommendedRerank as a reader default. The field
41
+ // is a producer hint, and a hostile (or exhaustive-fallback) value up to the
42
+ // row count would make every query fetch, hash and rerank the whole vectors
43
+ // region — at wiki scale, minutes per query with every check passing. An
44
+ // explicit options.rerank is the caller's own choice and is not capped here.
45
+ const MAX_RECOMMENDED_RERANK = 4096;
40
46
 
41
47
  // The affine table feeds every rerank distance; a NaN/Infinity entry makes
42
48
  // that row's distance non-finite and lets it sit in the result set
@@ -331,6 +337,28 @@ class PikeletSketchArtifact {
331
337
  if (header.byteLength !== SKETCH_HEADER_BYTES) {
332
338
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact header is truncated');
333
339
  }
340
+ // options.expectedHeaderSha256 (hex): a container that commits to this
341
+ // header (the complete profile's manifest.index.headerSha256) checks
342
+ // it HERE, against the very bytes parsed below. Checking a separate
343
+ // read instead would let a host serve the genuine header to that read
344
+ // and a rewritten one (with a self-consistent residentSha256 and page
345
+ // table) to this one, replacing the index under a pinned identity.
346
+ if (options.expectedHeaderSha256 !== undefined) {
347
+ const expected = options.expectedHeaderSha256;
348
+ if (typeof expected !== 'string' || !/^[0-9a-f]{64}$/.test(expected)) {
349
+ throw pikeletError(PIKELET_ERROR_CODES.INVALID_ARGUMENT, 'SketchArtifact.open() expectedHeaderSha256 must be 64 lowercase hex characters');
350
+ }
351
+ const digest = await sha256BytesAsync(header);
352
+ if (!digest) {
353
+ throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID,
354
+ 'Sketch artifact header verification requested but no crypto backend is available');
355
+ }
356
+ let hex = '';
357
+ for (const b of digest) hex += b.toString(16).padStart(2, '0');
358
+ if (hex !== expected) {
359
+ throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, '.pikelet index header failed hash verification against the manifest');
360
+ }
361
+ }
334
362
  const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
335
363
  if (view.getUint32(0, true) !== SKETCH_MAGIC) {
336
364
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Not a Pikelet sketch artifact (bad magic)');
@@ -353,7 +381,8 @@ class PikeletSketchArtifact {
353
381
  const sketchesOffset = view.getUint32(40, true);
354
382
  artifact.vectorsOffset = view.getUint32(44, true);
355
383
  const fileBytes = Number(view.getBigUint64(48, true));
356
- artifact.recommendedRerank = view.getUint32(120, true);
384
+ artifact.declaredRecommendedRerank = view.getUint32(120, true);
385
+ artifact.recommendedRerank = Math.min(artifact.declaredRecommendedRerank, MAX_RECOMMENDED_RERANK);
357
386
  // Retained so the lazy tier stays verifiable after open — see
358
387
  // verifyVectors(). Copied out of the header read buffer.
359
388
  artifact.vectorsSha256 = new Uint8Array(header.subarray(88, 120));
@@ -1131,6 +1160,7 @@ async function createSketchScanner(loadEngine, artifact, options = {}) {
1131
1160
 
1132
1161
  module.exports = {
1133
1162
  PikeletSketchArtifact,
1163
+ MAX_RECOMMENDED_RERANK,
1134
1164
  createSketchScanner,
1135
1165
  buildSketchArtifact,
1136
1166
  buildSketchArtifactBytes,
@@ -105,5 +105,12 @@ export declare function exportSketchArtifact(
105
105
  options?: SketchArtifactBuildOptions
106
106
  ): SketchArtifactBuildManifest;
107
107
 
108
+ /**
109
+ * Ceiling on the header's recommendedRerank that readers use as their default
110
+ * rerank depth. Producers should not declare more: a reader will not honor it
111
+ * unless the caller passes an explicit rerank.
112
+ */
113
+ export declare const MAX_RECOMMENDED_RERANK: number;
114
+
108
115
  /** Parse and validate an untrusted uint8 engine snapshot (fails closed). */
109
116
  export declare function parseUint8Snapshot(bytes: Uint8Array | ArrayBufferLike): Uint8SnapshotGraph;
@@ -12,13 +12,14 @@
12
12
  const { NodeFileRangeSource, parseUint8Snapshot } = require('./pikelet-artifact-common.js');
13
13
  const { PikeletRangeArtifact, buildRangeArtifact, buildRangeArtifactFile } = require('./pikelet-artifact-range.js');
14
14
  const {
15
- PikeletSketchArtifact, createSketchScanner,
15
+ PikeletSketchArtifact, MAX_RECOMMENDED_RERANK, createSketchScanner,
16
16
  buildSketchArtifact, buildSketchArtifactBytes, buildSketchArtifactFile, exportSketchArtifact,
17
17
  } = require('./pikelet-artifact-sketch.js');
18
18
 
19
19
  module.exports = {
20
20
  PikeletRangeArtifact,
21
21
  PikeletSketchArtifact,
22
+ MAX_RECOMMENDED_RERANK,
22
23
  createSketchScanner,
23
24
  NodeFileRangeSource,
24
25
  buildRangeArtifact,