pikelet-wasm 0.9.1 → 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,121 @@ 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
+
72
+ ## pikelet-wasm 0.9.2 / pikelet 0.9.2 — 2026-10-01
73
+
74
+ Makes the layered update path work as documented: `append` on a freshly
75
+ compiled pack needs no flag, `pikelet mcp --pack` takes the newest layer,
76
+ and `verify_pack` reports honestly on a chain. Both packages move to 0.9.2
77
+ together, and `pikelet` requires `pikelet-wasm` ^0.9.2, whose chain reader
78
+ supplies the integrity fields `verify_pack` reports.
79
+
80
+ ### Compatibility
81
+
82
+ - **A pack compiled from the same source gets a different identity than
83
+ under 0.9.1**, because its manifest gains `corpus.ingest`. Pins and
84
+ citations to packs already published are unaffected; only new compiles
85
+ change.
86
+
87
+ ### Changed
88
+
89
+ - **`compile` records how a pack's records were made, so `append` works on
90
+ it with no flag.** New packs carry `corpus.ingest` (the chunker and its
91
+ parameters). Packs compiled before it still need `--assert-ingest`, and
92
+ `append`'s error now prints the exact JSON to assert. `append` refuses a
93
+ chain whose declaration names a chunker this `pikelet` does not
94
+ implement. The new manifest field means a pack compiled from the same
95
+ source gets a different identity than under 0.9.1.
96
+
97
+ ### Fixed
98
+
99
+ - **`verify_pack` on a chain reports what it actually checked.** It returned
100
+ "no tests" with no encoder, integrity or calibration figures, and a note
101
+ blaming an "older build", so an agent concluded no verdict could be
102
+ trusted. It now runs the base's golden queries and probes against the
103
+ chain, labelled as the base's, skips a golden whose expected record a
104
+ layer removed, and reports the base's calibration figures, the chain's
105
+ calibration status and drift, and each member's integrity. A chain's
106
+ `info()` gains `encoderVerified`, `corpusIntegrity` and
107
+ `indexRowIntegrity`, and per-member integrity under `members`.
108
+ - **`pikelet mcp --pack` accepts the newest layer of an updated pack.** A
109
+ layer passed to `--pack`, or named by a shelf entry without a `lineage`,
110
+ failed to mount with "unsupported profile pikelet-layer-v1". It now mounts
111
+ the chain by walking each layer's parent locator, as LAYERED_PROFILE.md 7
112
+ specifies, locally or over HTTP; `#<sha256>` pins the head. Shelf entries
113
+ with a `lineage` mount as before.
114
+
115
+ ### Documentation
116
+
117
+ - **Both READMEs describe layered updates** (`append`, `compact`, `rebase`,
118
+ mounting the newest layer, pins, the drift limit) and citations that
119
+ survive updates. The root README's opening states that a pack fixes how
120
+ its corpus is queried; the long Wikipedia demonstration moved to
121
+ `docs/wikipedia-demonstration.md`, and the abstention signals to
122
+ `docs/how-a-query-runs.md`. Stale claims were corrected: a "retrieval
123
+ agreement" signal that does not exist, MCP returning zero results under
124
+ `none`, and the old `PIKELET_SEARCH_EMBED_WORKERS` name.
125
+
11
126
  ## pikelet-wasm 0.9.1 / pikelet 0.9.1 — 2026-09-30
12
127
 
13
128
  Fixes to the layered profile shipped in 0.9.0, headlined by a chain that
package/README.md CHANGED
@@ -2,34 +2,18 @@
2
2
 
3
3
  **Knowledge that ships as a file.**
4
4
 
5
- Pikelet compiles a corpus into one self-contained, queryable artifact.
5
+ Pikelet compiles a corpus into one self-contained, queryable artifact. A model can interrogate a 456,153-record knowledge base whose backend is a static file.
6
6
 
7
- A model can interrogate a 456,153-record knowledge base whose backend is a static file.
7
+ A `.pikelet` carries the source text, semantic and keyword indexes, the query encoder, retrieval calibration, evaluation fixtures, and integrity commitments over all of them. So it is more than stored search data: the file fixes how its corpus is queried — the model that turns a question into a vector, how candidates are ranked, and when the honest answer is "this corpus doesn't say" — and its identity is a hash that commits to all of it. **Pikelet makes retrieval semantics part of the versioned knowledge artifact.**
8
8
 
9
- A `.pikelet` can carry the source text, semantic index, keyword index, query encoder, integrity commitments, retrieval calibration, and evaluation fixtures needed to interrogate that corpus. Put the file on disk, S3, R2, a CDN, or any static HTTP host. A reader can mount it locally or over HTTP Range and search it without a vector database, embedding API, or retrieval server.
9
+ Put the file on disk, S3, R2, a CDN, or any static HTTP host. A reader can mount it locally or over HTTP Range and search it without a vector database, embedding API, or retrieval server. Updates ship as small immutable layers, and an answer can name the exact version of the knowledge it came from.
10
10
 
11
11
  The `pikelet` CLI requires Node 20+; the `pikelet-wasm` library runs on Node 18+ (CI tests 18, 20, 22), browsers, and Cloudflare Workers.
12
12
 
13
- > **Upgrade to `pikelet-wasm@0.8.1` if you mount packs you did not compile.**
14
- > 0.8.0 and earlier sized the inline encoder's WASM buffers from the
15
- > artifact's own `maxTokens` with an upper bound only, so a pack declaring an
16
- > out-of-range value could overflow them — at mount, before any query, and
17
- > with every integrity check passing, because the pack's author is the one who
18
- > signed it. 0.8.1 validates the bound. Packs the toolchain produces were
19
- > never affected. `pikelet@0.8.1` picks the fix up through its dependency
20
- > range; see the CHANGELOG for the detail.
21
-
22
13
  ```bash
23
14
  npx pikelet compile --source ./docs --out docs.pikelet
24
15
  ```
25
16
 
26
- Installing rather than using `npx`? Use `npm install -g pikelet
27
- --omit=optional`. The optional dependency is `@xenova/transformers`, which
28
- only the `create` scaffold path uses. Measured on a clean project, omitting it
29
- takes the install from **82 packages / 257 MB / "6 vulnerabilities (5 high, 1
30
- critical)"** to **2 packages / 1.8 MB / "found 0 vulnerabilities"** — the
31
- advisories are all inside a dependency `compile` and `mcp` never load.
32
-
33
17
  ```text
34
18
  Ingested 3 docs -> 3 chunks
35
19
  Embedded 3/3 chunks with inline transformer
@@ -63,7 +47,7 @@ console.log(out.matchQuality, out.results[0]?.title);
63
47
  // 'strong' 'Snapshot restore'
64
48
  ```
65
49
 
66
- That's the whole loop. `compile` also takes a live URL (`--source https://docs.example.com`) instead of a directory. If you want a deployed search app — a Worker + UI, not a file — use `npx pikelet create` instead; see [`packages/pikelet/README.md`](packages/pikelet/README.md) for the full CLI reference and the tradeoffs between the two. `create` is the one path that needs the optional `@xenova/transformers` dependency, which is why the install above omits it by default.
50
+ That's the whole loop. `compile` also takes a live URL (`--source https://docs.example.com`) instead of a directory. If you want a deployed search app — a Worker + UI, not a file — use `npx pikelet create` instead; see [`packages/pikelet/README.md`](packages/pikelet/README.md) for the full CLI reference and the tradeoffs between the two. To install rather than use `npx`, run `npm install -g pikelet --omit=optional`: the optional dependency is only for `create` ([why it matters](packages/pikelet/README.md#installing)).
67
51
 
68
52
  Under the hood, the file is one container for everything a reader needs:
69
53
 
@@ -96,93 +80,18 @@ Search is the interface. **The file is the knowledge deployment unit.**
96
80
 
97
81
  ## The shortest demonstration
98
82
 
99
- A real Pikelet pack contains **456,153 Simple English Wikipedia records** in a **648.5 MiB** artifact.
100
-
101
- Try the live browser demo:
102
-
103
- ```text
104
- https://pikelet-wiki-playground.pages.dev/
105
- ```
106
-
107
- The page is static HTML/JS on Cloudflare Pages. It mounts the pack from R2:
108
-
109
- ```text
110
- https://pub-6da2384a3bca4a44b2b2fa29a94cc811.r2.dev/wikipedia.pikelet
111
- ```
112
-
113
- There is no `/search`, `/embed`, `/query`, or `/api` backend behind the demo.
114
- The browser opens the `.pikelet` file over HTTP Range, verifies the artifact,
115
- loads the embedded query encoder, and shows the byte ranges it reads.
116
-
117
- That file was served by a deliberately dumb HTTP server whose only relevant capability was:
118
-
119
- ```text
120
- fs.createReadStream + HTTP Range
121
- ```
122
-
123
- There was no vector database, no search service, no embedding service, no application server that understood the pack, no index process on the server.
124
-
125
- The pack was mounted by URL and exposed to a real headless Claude process through MCP. Each invocation was restricted with `--strict-mcp-config` to only:
126
-
127
- ```text
128
- mcp__wiki-pack__search
129
- mcp__wiki-pack__list_packs
130
- mcp__wiki-pack__get_record
131
- ```
132
-
133
- No web tool. No filesystem tool. No alternate retrieval tool.
134
-
135
- One of the actual responses:
136
-
137
- > **Q: What causes earthquakes?**
138
- >
139
- > Earthquakes are caused by the movement of tectonic plates in the Earth's crust. Stress builds up along fault boundaries where plates grind against or ride over one another; when that stress exceeds the friction holding the locked fault in place, it suddenly releases as shock waves (seismic waves) that shake the ground [record 429637].
140
- >
141
- > There are three main fault types that can produce quakes — normal, reverse (thrust), and strike-slip — and some quakes have less clear-cut causes, like deep shifts in ancient rock or mine-collapse "tremors" [record 429638].
142
- >
143
- > Two related mechanisms compound this:
144
- >
145
- > * Subduction, where one plate is forced under another into the mantle, produces the most powerful earthquakes and can trigger tsunamis [record 132833].
146
- > * More broadly, the slow convection-driven movement of crustal plates over the hot mantle is the underlying driver of quakes, volcanoes, and mountain formation [record 132267].
147
- >
148
- > Volcanic eruptions can also cause earthquakes, though these are localized to areas near active volcanoes [record 429637].
149
-
150
- The model did not just repeat the first search hit. It retrieved multiple records and synthesized them into a sourced answer.
151
-
152
- Other runs produced:
153
-
154
- ```text
155
- Who was the first person on the moon? → Neil Armstrong (records 408686, 408684)
156
- How do volcanoes form? → plates + subduction + hotspots
157
- (records 132770, 132765, 132852, 132853)
158
- How does photosynthesis work? → two-phase reaction, cited (5 records: 114219, 114220, 114222, 114223, 269279)
159
- What is the capital of France? → Paris (records 454641, 302684)
160
- ```
161
-
162
- That is the product in one demonstration:
163
-
164
- > **A model is interrogating a 456,153-record knowledge base whose backend is a static file.**
83
+ A real pack holds **456,153 Simple English Wikipedia records** in a **648.5 MiB** file. The [live browser demo](https://pikelet-wiki-playground.pages.dev/) is static HTML on Cloudflare Pages that mounts it straight from an R2 bucket — there is no `/search`, `/embed`, `/query` or `/api` behind it. The browser range-reads the file, verifies it, loads the query encoder from inside it, and shows every byte range it fetches.
165
84
 
166
- The model may already contain some of these facts in its pretrained parameters. This test demonstrates the retrieval, synthesis, citation, and deployment path; the [Veyra ablation](docs/veyra-ablation.md) tests whether support changes when evidence is removed from the pack.
85
+ For the agent test, the same file was served by a server whose only relevant capability was `fs.createReadStream` + HTTP Range and mounted by URL into a headless Claude restricted to the pack's three MCP tools (no web, no filesystem). The model answered questions such as "What causes earthquakes?" by searching, reading several records, and citing them.
167
86
 
168
- **Network cost.** In a single persistent session (one mount, five queries, one repeat):
87
+ | In one persistent session | Transferred |
88
+ | ---------------------------------------- | --------------------------: |
89
+ | Mount (once) | 51.7 MiB in 11 requests |
90
+ | Query encoder (once, with the first query) | ~25 MiB |
91
+ | Each fresh query after that | ~0.5–1.1 MiB |
92
+ | Whole session: 5 queries and a repeat | 80.8 MiB, 12.5% of the file |
169
93
 
170
- | Operation | Bytes transferred | Requests |
171
- | ------------------------------- | ----------------: | -------: |
172
- | Initial mount | 51.7 MiB | 11 |
173
- | Query 1 + one-time encoder load | 25.9 MiB | 77 |
174
- | Query 2 | 504.4 KiB | 37 |
175
- | Query 3 | 499.4 KiB | 46 |
176
- | Query 4 | 968.9 KiB | 75 |
177
- | Query 5 | 1.1 MiB | 116 |
178
- | Repeated query 1 | 238.4 KiB | 4 |
179
- | **Total** | **80.8 MiB** | **366** |
180
-
181
- Roughly **12.5% of the 648.5 MiB artifact** crossed the wire across that whole session. Excluding the one-time ~25 MiB encoder load, fresh-query traffic ran **~0.5–1.1 MiB per query**. The complete artifact was never downloaded.
182
-
183
- The headless-Claude test above used a fresh process per question, so each invocation repaid the ~52 MiB mount and ~25 MiB encoder cost — about 78 MiB per cold query. That's a real operational distinction: **persistent sessions amortize mount and encoder cost; independent cold processes do not.**
184
-
185
- *(The large benchmark fixture still carries its historical `.pancake` filename from before the Pikelet rename; current artifacts use `.pikelet`.)*
94
+ The complete artifact is never downloaded. The full transcript, the per-query table, and what a cold process per question costs instead are in [the Wikipedia demonstration](docs/wikipedia-demonstration.md); [`benchmarks/range-proof/`](benchmarks/range-proof/) reproduces it.
186
95
 
187
96
  ---
188
97
 
@@ -198,7 +107,7 @@ npx pikelet mcp install \
198
107
 
199
108
  The agent gets `search`, `get_record`, `list_packs`, `verify_pack`. A search result carries the identity of the pack and the location of the source record, so an agent can work against `product-docs.pikelet`, `rust-reference.pikelet`, `policy-2026-09.pikelet`, `customer-manual-v4.pikelet` without each publisher operating a retrieval API. The artifact can be local, private, public, behind authenticated object storage, or distributed like any other static asset.
200
109
 
201
- A pack mounted with a content hash has a stable identity — `https://example.com/docs.pikelet#8d731...` — so "what body of knowledge did this agent query?" has a reproducible answer.
110
+ A pack mounted with a content hash has a stable identity — `https://example.com/docs.pikelet#8d731...` — so "what body of knowledge did this agent query?" has a reproducible answer. A citation is `(pack identity, record id)`, and it stays meaningful as the pack changes: a chain of layers resolves citations made against any of its earlier versions, and a pack compacted from a chain translates them forward, reporting what an old record became or that it was deleted (the old record's bytes stay in the old files). Citation translation is a library call today, `citation(identity, id)`; the MCP tools do not expose it yet.
202
111
 
203
112
  **A mounted pack's content reaches the model as tool output.** `verify_pack` proves the bytes are intact and match their pinned identity; it does not prove the corpus itself is trustworthy. Mounting a pack from a source you don't control is the same trust decision as giving an agent any other untrusted-content tool — treat pack text the way you'd treat search results or fetched web pages, not as instructions.
204
113
 
@@ -228,9 +137,23 @@ All three packs derive from the same small synthetic Station Veyra corpus, with
228
137
 
229
138
  ---
230
139
 
140
+ ## Update a pack without rebuilding it
141
+
142
+ A pack doesn't have to be recompiled when a few documents change. `append` never modifies its parent: it writes a new, small `.pikelet` — a **layer** — holding new records, deletions, or replacements, and the base plus its layers (a chain) is searched as one pack. Every published file stays byte-for-byte immutable; the newest layer defines the current state, and its identity pins the whole history.
143
+
144
+ ```bash
145
+ npx pikelet append --parent docs.pikelet --source ./changed-docs --out docs.0001.pikelet
146
+ npx pikelet append --parent docs.0001.pikelet --remove 412 --out docs.0002.pikelet
147
+ npx pikelet mcp --pack docs.0002.pikelet
148
+ ```
149
+
150
+ Layers are small. On a 20-record test pack with a 24.6 MiB base, deleting a record took a 1.2 KiB layer and replacing one took 6.9 KiB; a layer that adds records also carries a vocabulary filter sized to its base's (about 43 KiB on a 3,255-record docs pack).
151
+
152
+ A layer records its parent's identity, and its location when the parent sits in the same directory, so mounting the newest layer finds the rest of the chain — locally or over HTTP — and verifies every link. `#<sha256>` on the newest layer pins the whole chain. Layers always inherit the base's encoder. They inherit its calibration only while the chain stays within the drift limit (by default, records added plus deleted up to 20% of the base's); past it, the chain serves `unscored` until `compact` folds it into one fresh base. `rebase` moves a layer onto a different parent. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md); `npx pikelet --help` lists every flag.
153
+
231
154
  ## Match quality and abstention
232
155
 
233
- A nearest neighbour is not automatically evidence that a corpus answers a question. Pikelet can calibrate retrieval signals at build time (best semantic distance, distance margin, lexical coverage, retrieval agreement). 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. 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.
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.
234
157
 
235
158
  `matchQuality` is evidence about retrieval support. It is **not** a guarantee that an LLM will never hallucinate.
236
159
 
@@ -238,14 +161,15 @@ A nearest neighbour is not automatically evidence that a corpus answers a questi
238
161
 
239
162
  ## Deeper dives
240
163
 
241
- - [How a `.pikelet` query runs](docs/how-a-query-runs.md) — what is inside the file, what a query costs on the wire, remote execution, and the integrity checks on the read path.
164
+ - [The Wikipedia demonstration](docs/wikipedia-demonstration.md) — the full agent transcript, the per-query network table, and what cold processes cost.
165
+ - [How a `.pikelet` query runs](docs/how-a-query-runs.md) — what is inside the file, what a query costs on the wire, remote execution, the integrity checks on the read path, and how abstention is decided.
242
166
  - [The Veyra ablation](docs/veyra-ablation.md) — what happens to answers when the supporting record is edited or removed from the pack, with the reproduction commands.
243
167
  - [Why make knowledge a file](docs/why-a-file.md) — the reasoning behind the single-artifact design, and what it commits to.
244
168
  - [Architecture](docs/architecture.md) — the engine, the C ABI and the JavaScript wrapper; the artifact formats are specified in [`spec/`](spec/).
245
169
 
246
170
  ## What this is not
247
171
 
248
- **Not a claim that vector databases are obsolete.** If your corpus changes continuously, needs transactional updates, serves many tenants, or already lives comfortably in a database, use a database. Pikelet targets `build → publish → query many times → replace on release`.
172
+ **Not a claim that vector databases are obsolete.** If your corpus changes continuously, needs transactional updates, serves many tenants, or already lives comfortably in a database, use a database. Pikelet targets `build → publish → query many times → replace on release`, with small layered updates in between.
249
173
 
250
174
  **Not state-of-the-art embedding research.** The bundled model is MiniLM-L6, chosen for being small enough to live inside the artifact. ArguAna demonstrates a real quality cost from the compact encoder.
251
175
 
@@ -298,7 +222,8 @@ packages/pikelet-wasm/ The published pikelet-wasm package:
298
222
  src/ Engine wrapper and artifact readers/builders
299
223
  (the entrypoints, core/, artifact/, errors/)
300
224
  complete/ Reader and builder for the complete
301
- range-readable .pikelet artifact
225
+ range-readable .pikelet artifact, and the
226
+ layered-chain reader and producers
302
227
  native/ N-API build of the same engine (benchmarks)
303
228
  packages/pikelet/ The published pikelet CLI: compiler, MCP
304
229
  server, encoder integration, Docusaurus plugin
@@ -306,6 +231,8 @@ docs/ Deeper dives (how a query runs, the Veyra
306
231
  ablation, why a file), architecture notes,
307
232
  rename history, measurement reports
308
233
  spec/ Byte-level artifact contracts
234
+ LAYERED_PROFILE.md The layered profile: append, compact,
235
+ rebase, and how chains are read
309
236
  benchmarks/beir/ Frozen BEIR ablation harness (encoder,
310
237
  quantization, HNSW quality)
311
238
  benchmarks/range-proof/ The deliberately boring static-HTTP proof:
@@ -342,6 +269,7 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
342
269
  - content identity and lazy-read integrity verification
343
270
  - calibration/abstention support
344
271
  - MCP mounting
272
+ - layered updates: append, compact, rebase
345
273
  - BEIR retrieval evaluation
346
274
  - range-read and failure-mode tests
347
275
  - a large 456k-record example artifact
@@ -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,
@@ -121,6 +121,15 @@ async function walkToBase(head, options) {
121
121
  throw new Error(`walking to the base exceeded the depth limit ${maxDepth} (2.7): a layer.parent cycle or an over-deep chain`);
122
122
  }
123
123
 
124
+ function memberIntegritySummary(opened) {
125
+ const infos = opened.filter((r) => typeof r?.info === 'function').map((r) => r.info());
126
+ const one = (key) => {
127
+ const values = [...new Set(infos.map((i) => i[key]))];
128
+ return values.length === 1 ? values[0] : 'mixed';
129
+ };
130
+ return { corpusIntegrity: one('corpusIntegrity'), indexRowIntegrity: one('indexRowIntegrity') };
131
+ }
132
+
124
133
  const isHttpUrl = (loc) => /^https?:\/\//i.test(loc);
125
134
  const describeLocation = (loc) => (typeof loc === 'string' ? loc : (loc?.url ?? loc?.location ?? '<source>'));
126
135
 
@@ -370,7 +379,24 @@ export async function openPikeletChain(members, options = {}) {
370
379
  // to that member. A chain surfaces the base's, which are
371
380
  // the ones fit against the corpus the encoder saw.
372
381
  sampleQueries: Array.isArray(base.manifest.sampleQueries) ? base.manifest.sampleQueries : [],
373
- members: chainMembers.map((m) => ({ depth: m.depth, identity: m.identity, records: m.records })),
382
+ // Each member's own integrity, from its reader. A
383
+ // tombstone-only layer is a verified shell (manifest plus
384
+ // tombstones), with no corpus or index to report on.
385
+ members: chainMembers.map((m, i) => {
386
+ const ri = typeof opened[i]?.info === 'function' ? opened[i].info() : null;
387
+ return {
388
+ depth: m.depth, identity: m.identity, records: m.records,
389
+ ...(ri ? { corpusIntegrity: ri.corpusIntegrity, indexRowIntegrity: ri.indexRowIntegrity }
390
+ : { tombstoneOnly: true }),
391
+ };
392
+ }),
393
+ // The chain encodes with the base's encoder (4.5), so the
394
+ // base's verification is the chain's. Chain-level integrity
395
+ // is the members' when they agree, else 'mixed'. Without
396
+ // these, verify_pack on a chain reported no encoder or
397
+ // integrity state at all.
398
+ encoderVerified: opened[0].info().encoderVerified,
399
+ ...memberIntegritySummary(opened),
374
400
  };
375
401
  },
376
402
 
@@ -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.1",
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,