pikelet-wasm 0.9.2 → 0.10.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 CHANGED
@@ -8,6 +8,135 @@ 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.0 / pikelet 0.10.0 — 2026-10-09
12
+
13
+ `matchQuality` changes meaning for packs compiled with this release: it
14
+ now measures retrieval relevance — whether the pack covers the
15
+ question's topic — instead of trying to say whether the answer is there.
16
+ Packs already built keep their behavior, since the calibration ships
17
+ inside each file. The inline query encoder runs about 1.65× faster in
18
+ Node.js and Chromium and about 2.2× in WebKit with identical embeddings,
19
+ and readers can now run 12-layer and 4-bit encoders. Both packages move
20
+ to 0.10.0 together, and `pikelet` requires `pikelet-wasm` ^0.10.0.
21
+
22
+ ### Compatibility
23
+
24
+ - **Newly compiled packs score relevance.** `none` means the pack does
25
+ not cover the question's topic; a question about a covered topic whose
26
+ specific fact the pack lacks now comes back `strong` or `weak` instead
27
+ of `none`. Do not read `strong` as "answered": an asked-for value in a
28
+ result's `grounding.uncovered` is one no passage states. On the
29
+ repository's labeled questions, answerable questions called `none`
30
+ went from 12 of 44 to 0 (docs) and 26 of 50 to 0 (Veyra registry).
31
+ Rebuild a pack to pick this up; packs compiled by 0.9.x keep their
32
+ calibration.
33
+ - **The calibration summary changes shape.** The evaluation segment's
34
+ `calibration` block gains `method` (`self-templates-v6`) and `target`
35
+ (`relevance`); `cvAuc` is now measured against off-topic queries;
36
+ `cvAucHard`, answerable vs. in-domain-unanswerable, is reported but no
37
+ longer gates the fit; `stage1FloorAuc` is gone. Corpora that shipped
38
+ `unscored` for lack of hard negatives or a low hard-negative AUC now
39
+ calibrate.
40
+ - **MCP wording changes.** The `search` description, the note on a
41
+ `none` result, the server instructions and `verify_pack`'s calibration
42
+ note describe relevance; `verify_pack`'s calibration block gains
43
+ `method` and `target` (null on older packs). Tool names and result
44
+ shapes are unchanged.
45
+ - **Packs that declare a layer count other than 6, or 4-bit weights,
46
+ open only in 0.10.0 readers**; 0.9.x readers refuse them at parse. No
47
+ published pack declares either.
48
+
49
+ ### Added
50
+
51
+ - **Inline encoders with 1 to 12 layers and u4 weights.** A kind-3
52
+ declaration may give `layout.L` from 1 to 12 and `layout.Q: 4` with
53
+ `layout.B: 32` (u4 weights, two per byte, an f32 scale and offset per
54
+ block of 32); `Q` absent means u8 in blocks of 64, as before. Exported
55
+ as `KERNEL_MAX_LAYERS`. The parser and the embedder check the layer
56
+ count, the format and the blob length before the kernel runs. A u4
57
+ BGE-small-en-v1.5 blob is 23.9 MiB against MiniLM's 24.3 and matches
58
+ PyTorch at cosine 0.985 (1.000 against the same weights fake-quantized).
59
+ `compile` has no flag for these layouts yet.
60
+
61
+ ### Changed
62
+
63
+ - **The inline encoder runs its projections over tiles of four tokens**
64
+ instead of one token at a time, so each layer's weights stream through
65
+ memory once per tile. Median query encoding, before → after: about
66
+ 23 → 14 ms for a 10-word question and 213 → 125 ms for 100 words in
67
+ Chromium; 32 → 14 and 285 → 134 ms in WebKit. Embeddings are
68
+ bit-identical, so existing packs and their verification vectors are
69
+ unaffected.
70
+ - **Calibration** (`compile`, `compact` refits) fits verified on-topic
71
+ probes against off-topic ones — the built-in foreign bank, minus
72
+ entries the corpus turns out to cover, plus gibberish — and places
73
+ `none` at the 95th percentile of the off-topic scores. The
74
+ in-domain-unanswerable probe classes are still generated, for the
75
+ `cvAucHard` diagnostic only.
76
+ - **Documentation** (both READMEs, `docs/how-a-query-runs.md`,
77
+ `docs/veyra-ablation.md`) describes `matchQuality` as relevance.
78
+
79
+ ## pikelet-wasm 0.9.3 / pikelet 0.9.3 — 2026-10-06
80
+
81
+ Hardening for readers that mount packs from other publishers or hosts:
82
+ the index header commitment now covers the bytes actually parsed, and a
83
+ pack can no longer steer lazy reads, demand an exhaustive rerank, or turn
84
+ a malformed abstention asset into confident answers. Upgrade if you mount
85
+ packs you did not build, including over `pikelet mcp`. Both packages move
86
+ to 0.9.3 together, and `pikelet` requires `pikelet-wasm` ^0.9.3 so that
87
+ `pikelet mcp` serves through the hardened reader.
88
+
89
+ ### Compatibility
90
+
91
+ - **A pack that declares a rerank depth over 4096 is served at 4096 by
92
+ default**, unless the caller passes `rerank`. No published pack is
93
+ affected.
94
+ - **A pack whose abstention asset has malformed `thresholds` or
95
+ `coverage.topK` now reports `unscored`** instead of answering. Packs
96
+ built by `compile` are not affected.
97
+ - **`compile` declares at most 4096** for a pack over 4096 records whose
98
+ sweep does not meet its recall target, and warns. Other packs compile
99
+ as under 0.9.2.
100
+
101
+ ### Fixed
102
+
103
+ - **The index header commitment binds the bytes the reader parses.** The
104
+ complete reader checked `manifest.index.headerSha256` on its own read of
105
+ the sketch header, while the sketch parsed a separate read of the same
106
+ range. A host answering the two reads differently could pass the check
107
+ with the genuine header and have a forged one parsed, together with the
108
+ integrity anchors it carries, replacing the index under a pinned
109
+ identity. `PikeletSketchArtifact.open()` now takes
110
+ `expectedHeaderSha256` and checks the header it parses.
111
+ - **Lazily read keyword segments cannot steer reads or inflate them.**
112
+ Lexical segments over 8 MiB are read on demand without per-read
113
+ verification. Reads are now confined to the segment's window, and each
114
+ term entry is checked before its postings are fetched: inside the
115
+ postings region, `df` no larger than the corpus, and a length a postings
116
+ list of that `df` can occupy. `info().lexical.integrity` reports
117
+ `'unverified-lazy-reads'` for such segments and `'segment-sha256'` for
118
+ eagerly verified ones.
119
+ - **A pack's declared rerank depth is capped at 4096 by default.** The
120
+ sketch header's `recommendedRerank` is a producer hint; a value near the
121
+ row count made every query fetch, hash and rerank the whole vectors
122
+ region. Readers default to at most 4096, keep the declared value as
123
+ `declaredRecommendedRerank`, and still honor an explicit `rerank`. The
124
+ published wiki, astro-docs and rust-book packs declare 200, 40 and 30.
125
+ - **`compile` and `append` no longer declare a rerank depth readers will not
126
+ use.** `compile`'s rerank sweep fell back to the full row count when no
127
+ depth up to 640 met its recall target; it now stops at the same 4096
128
+ ceiling, records the recall measured there, and logs a warning when the
129
+ target was not met. `append` caps a layer's exhaustive hint the same way.
130
+ `measureRecommendedRerank` returns `targetReached` and `rerankCeiling`,
131
+ and `pikelet-wasm/artifact` exports `MAX_RECOMMENDED_RERANK`.
132
+ - **A malformed abstention asset serves `unscored`.** Missing or
133
+ non-numeric `thresholds` compared false against every score, so the pack
134
+ answered every query as if calibrated; out-of-order or out-of-range ones
135
+ gave verdicts the fit never defined. A `coverage.topK` outside 1–10 is
136
+ refused too, since each counted passage is a record read on every query.
137
+ The scorer now rejects such an asset and the pack reports `unscored`.
138
+ Assets written by `compile` are unaffected.
139
+
11
140
  ## pikelet-wasm 0.9.2 / pikelet 0.9.2 — 2026-10-01
12
141
 
13
142
  Makes the layered update path work as documented: `append` on a freshly
package/README.md CHANGED
@@ -133,7 +133,7 @@ claude \
133
133
  --mcp-config examples/one-file-search/web/public/veyra.mcp.json
134
134
  ```
135
135
 
136
- All three packs derive from the same small synthetic Station Veyra corpus, with controlled differences in the Tovash evidence. Ask where the Tovash project is housed: the full pack supports Chamber 17, the modified pack supports Chamber 43, and the ablated pack should abstain.
136
+ All three packs derive from the same small synthetic Station Veyra corpus, with controlled differences in the Tovash evidence. Ask where the Tovash project is housed: the full pack supports Chamber 17, the modified pack supports Chamber 43, and the ablated pack should not support any chamber.
137
137
 
138
138
  ---
139
139
 
@@ -153,7 +153,7 @@ A layer records its parent's identity, and its location when the parent sits in
153
153
 
154
154
  ## Match quality and abstention
155
155
 
156
- A nearest neighbour is not automatically evidence that a corpus answers a question. At build time Pikelet can fit a classifier over retrieval signals — distances, and how much of the question the top passages actually contain ([details](docs/how-a-query-runs.md#how-abstention-is-decided)). When the corpus supports a reliable classifier, results carry `matchQuality: strong | weak | none`. When calibration can't separate supported from unsupported reliably — a single novel may be semantically homogeneous enough that the fit isn't trustworthy — Pikelet reports `matchQuality: unscored` and records why calibration was skipped, rather than manufacturing confidence. In the library, a `none` verdict withholds `results` by default; pass `query(text, { showAbstained: true })` to see the raw retrieval anyway — `matchQuality` and `confidence` are unaffected either way. MCP `search` does the opposite: it returns results under `none` with a note telling the model the support is indirect, because the calibrator can misjudge a paraphrase; `showAbstained: false` withholds them.
156
+ A nearest neighbour is not automatically evidence that a corpus covers a question. At build time Pikelet fits a classifier over retrieval signals — distances, and how much of the question the top passages actually contain — against queries the corpus does not cover ([details](docs/how-a-query-runs.md#how-abstention-is-decided)), and results carry `matchQuality: strong | weak | none`. It measures retrieval relevance: `none` means the pack does not cover the question's topic; `strong` means it does, not that a returned passage states the specific fact asked for. For that, each result lists the question's words no top passage contains (`grounding.uncovered`), and an asked-for value in that list is unsupported whatever `matchQuality` says. When calibration can't separate on-topic from off-topic queries reliably, Pikelet reports `matchQuality: unscored` and records why calibration was skipped, rather than manufacturing confidence. In the library, a `none` verdict withholds `results` by default; pass `query(text, { showAbstained: true })` to see the raw retrieval anyway — `matchQuality` and `confidence` are unaffected either way. MCP `search` does the opposite: it returns results under `none` with a note telling the model the support is indirect, because the calibrator can misjudge a paraphrase; `showAbstained: false` withholds them.
157
157
 
158
158
  `matchQuality` is evidence about retrieval support. It is **not** a guarantee that an LLM will never hallucinate.
159
159
 
@@ -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,
@@ -67,6 +67,14 @@ export declare function httpRangeSource(url: string, options?: {
67
67
  }): HttpRangeSource;
68
68
 
69
69
  export interface CompleteQueryResult {
70
+ /**
71
+ * The pack's calibrated verdict on this retrieval. On packs calibrated
72
+ * by pikelet 0.10 or later (evaluation calibration.target 'relevance')
73
+ * it measures retrieval relevance: 'none' means the pack does not appear
74
+ * to cover the query's topic, 'strong' that it does — not that a
75
+ * returned passage states the specific fact asked for. 'unscored': the
76
+ * pack ships no fitted calibrator.
77
+ */
70
78
  matchQuality: 'strong' | 'weak' | 'none' | 'unscored';
71
79
  confidence?: number;
72
80
  /**
@@ -134,7 +142,13 @@ export interface CompleteSearch {
134
142
  * order fuses distance and BM25 rankings by reciprocal rank). Null on
135
143
  * vector-only artifacts.
136
144
  */
137
- lexical: { terms: number; docCount: number; lazy?: true } | null;
145
+ lexical: {
146
+ terms: number;
147
+ docCount: number;
148
+ /** 'segment-sha256' when read eagerly and verified whole; 'unverified-lazy-reads' for lazily opened segments. */
149
+ integrity: 'segment-sha256' | 'unverified-lazy-reads';
150
+ lazy?: true;
151
+ } | null;
138
152
  /**
139
153
  * 'engine' once a WASM scan kernel serves the resident scan
140
154
  * (auto-staged or injected via options.sketchScanner); 'js' before
@@ -291,6 +305,8 @@ export declare function verifyHostEncoder(
291
305
 
292
306
  /** Kernel layout compiled into the kind-3 encoder (mirrors encoder.cpp). */
293
307
  export declare const KERNEL_LAYOUT: Record<'V' | 'P' | 'T' | 'D' | 'F' | 'L' | 'B' | 'H', number>;
308
+ /** Largest layer count (layout L) the kind-3 kernel runs; L may be 1 to this. */
309
+ export declare const KERNEL_MAX_LAYERS: number;
294
310
  export declare function expectedBlobBytes(layout?: Record<string, number>): number;
295
311
  export declare function parseInlineTransformerEncoder(encoderBytes: Uint8Array): {
296
312
  declaration: Record<string, unknown>;
@@ -29,7 +29,7 @@ import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
29
29
  import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
30
30
 
31
31
  import {
32
- KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
32
+ KERNEL_LAYOUT, KERNEL_MAX_LAYERS, expectedBlobBytes, parseInlineTransformerEncoder,
33
33
  createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
34
34
  buildInlineTestVectors, verifyInlineTestVectors,
35
35
  validateExpectedEmbedding, validateTestVectorTolerance,
@@ -44,7 +44,7 @@ export { openLexicalIndex } from './lexical.mjs';
44
44
  export { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
45
45
  import { fuseCandidates } from './fusion.mjs';
46
46
  export {
47
- KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
47
+ KERNEL_LAYOUT, KERNEL_MAX_LAYERS, expectedBlobBytes, parseInlineTransformerEncoder,
48
48
  createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
49
49
  buildInlineTestVectors, verifyInlineTestVectors,
50
50
  validateExpectedEmbedding, validateTestVectorTolerance,
@@ -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,
@@ -5,8 +5,13 @@ const decoder = new TextDecoder();
5
5
  // The wasm kernel compiles these in (encoder-spike/encoder.cpp `constexpr`
6
6
  // block) and walks the weight blob with a bare running cursor — it never
7
7
  // reads the declaration. Declaration/blob skew is therefore only catchable
8
- // here, host-side, before the first forward.
8
+ // here, host-side, before the first forward. Two values are exceptions,
9
+ // taken by the kernel per call: the layer count, any L from 1 to
10
+ // KERNEL_MAX_LAYERS (KERNEL_LAYOUT.L is MiniLM-L6's, the default), and the
11
+ // weight format, Q bits per weight in blocks of B — u8 in blocks of 64
12
+ // (Q absent or 8, every pack before Q existed) or u4 in blocks of 32.
9
13
  export const KERNEL_LAYOUT = { V: 30522, P: 512, T: 2, D: 384, F: 1536, L: 6, B: 64, H: 12 };
14
+ export const KERNEL_MAX_LAYERS = 12;
10
15
  const KERNEL_MAX_SEQ = 512;
11
16
  // Smallest window that still frames interior tokens between [CLS] and [SEP]
12
17
  // with room to make progress; see the maxTokens check in
@@ -14,11 +19,11 @@ const KERNEL_MAX_SEQ = 512;
14
19
  const MIN_DECLARED_MAX_TOKENS = 8;
15
20
 
16
21
  // Byte size the kernel's fill_layout() will consume for a given layout:
17
- // each quantized matrix is rows*cols u8 plus per-block f32 scales and
18
- // offsets; biases and layer norms are plain f32.
22
+ // each quantized matrix is rows*cols weights of Q bits plus per-block f32
23
+ // scales and offsets; biases and layer norms are plain f32.
19
24
  export function expectedBlobBytes(layout = KERNEL_LAYOUT) {
20
- const { V, P, T, D, F, L, B } = layout;
21
- const quant = (rows, cols) => rows * cols + rows * (cols / B) * 4 * 2;
25
+ const { V, P, T, D, F, L, B, Q = 8 } = layout;
26
+ const quant = (rows, cols) => (rows * cols * Q) / 8 + rows * (cols / B) * 4 * 2;
22
27
  const layer = 4 * quant(D, D) + 4 * (D * 4) // wq,wk,wv,wo + their biases
23
28
  + 2 * (D * 4) // ln1 gamma/beta
24
29
  + quant(F, D) + F * 4 // wu + bu
@@ -27,6 +32,33 @@ export function expectedBlobBytes(layout = KERNEL_LAYOUT) {
27
32
  return quant(V, D) + quant(P, D) + quant(T, D) + 2 * (D * 4) + L * layer;
28
33
  }
29
34
 
35
+ // The declared layer count, which the kernel takes per call. Its
36
+ // Layout struct holds KERNEL_MAX_LAYERS layers (encoder.cpp MAXL), and it
37
+ // returns -3 outside 1..MAXL rather than walking past them.
38
+ function kernelLayers(layout) {
39
+ const layers = layout.L;
40
+ if (!Number.isInteger(layers) || layers < 1 || layers > KERNEL_MAX_LAYERS) {
41
+ throw new Error(`.pikelet inline-encoder declares layout L=${layers}; `
42
+ + `the kernel runs 1 to ${KERNEL_MAX_LAYERS} layers`);
43
+ }
44
+ return layers;
45
+ }
46
+
47
+ // The declared weight format as the kernel's bits argument. The kernel's
48
+ // block size is fixed per format, so B must be the one Q implies.
49
+ const KERNEL_WEIGHT_FORMATS = { 8: 64, 4: 32 };
50
+ function kernelWeightBits(layout) {
51
+ // Only an absent Q means u8 (packs from before Q existed); null is not
52
+ // absent, and expectedBlobBytes() would size it as zero-bit weights.
53
+ const bits = layout.Q === undefined ? 8 : layout.Q;
54
+ if (!Object.hasOwn(KERNEL_WEIGHT_FORMATS, bits) || typeof bits !== 'number'
55
+ || layout.B !== KERNEL_WEIGHT_FORMATS[bits]) {
56
+ throw new Error(`.pikelet inline-encoder declares layout Q=${layout.Q} B=${layout.B}; `
57
+ + 'the kernel runs u8 weights in blocks of 64 (Q absent or 8) or u4 in blocks of 32 (Q=4)');
58
+ }
59
+ return bits;
60
+ }
61
+
30
62
  export function parseInlineTransformerEncoder(encoderBytes) {
31
63
  const view = new DataView(encoderBytes.buffer, encoderBytes.byteOffset, encoderBytes.byteLength);
32
64
  const declLen = view.getUint32(0, true);
@@ -38,7 +70,12 @@ export function parseInlineTransformerEncoder(encoderBytes) {
38
70
  const declaration = JSON.parse(decoder.decode(encoderBytes.subarray(12, 12 + declLen)));
39
71
  const blob = encoderBytes.subarray(12 + declLen + vocabLen);
40
72
  const layout = declaration.layout || {};
73
+ // L, Q and B first: expectedBlobBytes() below scales with them, so they
74
+ // must be bounded before they size anything.
75
+ kernelLayers(layout);
76
+ kernelWeightBits(layout);
41
77
  for (const key of Object.keys(KERNEL_LAYOUT)) {
78
+ if (key === 'L' || key === 'B') continue;
42
79
  if (layout[key] !== KERNEL_LAYOUT[key]) {
43
80
  throw new Error(`.pikelet inline-encoder declares layout ${key}=${layout[key]}; `
44
81
  + `the compiled kernel requires ${key}=${KERNEL_LAYOUT[key]}`);
@@ -80,6 +117,16 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
80
117
  if (vocabSize > KERNEL_LAYOUT.V) {
81
118
  throw new Error(`inline-encoder vocab has ${vocabSize} entries; the kernel's embedding table holds ${KERNEL_LAYOUT.V}`);
82
119
  }
120
+ // Like maxTokens below, checked here too because this function is also
121
+ // called directly, without parseInlineTransformerEncoder(): the kernel
122
+ // walks the blob for exactly this many layers in this weight format.
123
+ const layers = kernelLayers({ L: declaration.layout?.L ?? KERNEL_LAYOUT.L });
124
+ const blockSize = declaration.layout?.B ?? KERNEL_LAYOUT.B;
125
+ const bits = kernelWeightBits({ Q: declaration.layout?.Q, B: blockSize });
126
+ const blobBytes = expectedBlobBytes({ ...KERNEL_LAYOUT, L: layers, Q: bits, B: blockSize });
127
+ if (blob.length !== blobBytes) {
128
+ throw new Error(`inline-encoder blob is ${blob.length} bytes but ${layers} layers of u${bits} weights imply ${blobBytes}`);
129
+ }
83
130
  const tokenizer = createWordPiece(vocabText);
84
131
  const EM = await createEncoder();
85
132
  const dim = declaration.dim;
@@ -121,7 +168,7 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
121
168
  throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
122
169
  }
123
170
  new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
124
- const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
171
+ const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0, layers, bits);
125
172
  if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
126
173
  const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
127
174
  if (usesClsPooling) {
@@ -181,7 +228,7 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
181
228
  throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
182
229
  }
183
230
  new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
184
- const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
231
+ const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0, layers, bits);
185
232
  if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
186
233
  const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
187
234
  // Offset by 1: tokenIds[0] is [CLS], so idSlice's tokens start at
@@ -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) => {
@@ -4,6 +4,12 @@
4
4
  // bloom filter, standardized and passed through the fitted logistic model.
5
5
  // Verdict semantics: 'answer' (strong match), 'weak' (closest match is
6
6
  // distant — shown with a caveat), 'abstain' (nothing useful in the pack).
7
+ // What "match" means is the asset's: pikelet's self-templates-v6 assets
8
+ // (calibration.target 'relevance') are fit against off-topic queries, so
9
+ // 'abstain' means the pack does not cover the query's topic and 'answer'
10
+ // means it does — not that a passage states the asked-for fact. Earlier
11
+ // assets were fit against in-domain-unanswerable queries instead. The
12
+ // scoring math here is the same for both.
7
13
  //
8
14
  // Assets calibrated by pikelet's self-templates-v2+ may carry an additional
9
15
  // grounding term (asset.coverage): the fraction of the query's content
@@ -34,11 +40,25 @@
34
40
  // one — there is no scenario where re-adding this via a config flag was
35
41
  // going to end differently a third time.
36
42
 
43
+ // Upper bound on asset.coverage.topK. calibrate.mjs ships 5; every passage
44
+ // counted here is a record hydrated on every query, so a pack must not be
45
+ // able to turn one query into a hydration of its whole rerank window.
46
+ export const MAX_COVERAGE_TOP_K = 10;
47
+
37
48
  export function createAbstentionScorer(asset, bloomBytes) {
38
49
  if (!asset || !Array.isArray(asset.weights) || !asset.thresholds) return null;
50
+ // Thresholds that are missing, non-numeric or out of order compare false
51
+ // against every p and would answer everything; a malformed asset must
52
+ // degrade to unscored instead (callers map a null scorer to it).
53
+ const { hard, weak } = asset.thresholds;
54
+ if (!Number.isFinite(hard) || !Number.isFinite(weak) || hard < 0 || hard > weak || weak > 1) return null;
39
55
  const coverageCfg = asset.coverage && typeof asset.coverage.weight === 'number'
40
56
  && Number.isFinite(asset.coverage.mean) && Number.isFinite(asset.coverage.std)
41
57
  ? asset.coverage : null;
58
+ if (coverageCfg && coverageCfg.topK !== undefined
59
+ && !(Number.isInteger(coverageCfg.topK) && coverageCfg.topK >= 1 && coverageCfg.topK <= MAX_COVERAGE_TOP_K)) {
60
+ return null;
61
+ }
42
62
  const coverageStopwords = coverageCfg ? new Set(coverageCfg.stopwords || []) : null;
43
63
  const coverageMinLen = coverageCfg ? (coverageCfg.minWordLen || 3) : 3;
44
64
  // 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.10.0",
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,