pikelet-wasm 0.8.0 → 0.9.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +399 -2
  2. package/README.md +81 -257
  3. package/complete/builder.mjs +35 -5
  4. package/complete/format.mjs +6 -1
  5. package/complete/fusion.mjs +72 -0
  6. package/complete/index.d.ts +23 -0
  7. package/complete/index.mjs +580 -76
  8. package/complete/inline-transformer.mjs +97 -1
  9. package/complete/layer-append.mjs +246 -0
  10. package/complete/layer-chain.mjs +284 -0
  11. package/complete/layer-compact.mjs +319 -0
  12. package/complete/layer-encoder.mjs +223 -0
  13. package/complete/layer-locator.mjs +228 -0
  14. package/complete/layer-manifest.mjs +271 -0
  15. package/complete/layer-reader.mjs +759 -0
  16. package/complete/layer-rebase.mjs +218 -0
  17. package/complete/lexical.mjs +78 -8
  18. package/complete/retrieval-abstention.mjs +132 -28
  19. package/complete/sources.mjs +1 -1
  20. package/complete/tombstones.mjs +281 -0
  21. package/complete/wordpiece.mjs +22 -0
  22. package/dist/engine.js +1 -1
  23. package/dist/engine.scalar.js +1 -1
  24. package/dist/engine.scalar.wasm +0 -0
  25. package/dist/engine.wasm +0 -0
  26. package/package.json +65 -77
  27. package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
  28. package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
  29. package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +97 -8
  30. package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
  31. package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
  32. package/{pikelet.js → src/pikelet.js} +7 -7
  33. package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
  34. package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
  35. package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
  36. /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
  37. /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
  38. /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
  39. /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
  40. /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
package/README.md CHANGED
@@ -8,12 +8,28 @@ A model can interrogate a 456,153-record knowledge base whose backend is a stati
8
8
 
9
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.
10
10
 
11
- Requires Node 20+.
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
+
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.
12
21
 
13
22
  ```bash
14
23
  npx pikelet compile --source ./docs --out docs.pikelet
15
24
  ```
16
25
 
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
+
17
33
  ```text
18
34
  Ingested 3 docs -> 3 chunks
19
35
  Embedded 3/3 chunks with inline transformer
@@ -47,7 +63,7 @@ console.log(out.matchQuality, out.results[0]?.title);
47
63
  // 'strong' 'Snapshot restore'
48
64
  ```
49
65
 
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 [`pikelet/README.md`](pikelet/README.md) for the full CLI reference and the tradeoffs between the two. `compile` never needs the scaffold path's `@xenova/transformers` dependency; skip its ~140 MB install with `npm install -g pikelet --omit=optional` if you only need `compile`/`mcp`.
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.
51
67
 
52
68
  Under the hood, the file is one container for everything a reader needs:
53
69
 
@@ -82,6 +98,22 @@ Search is the interface. **The file is the knowledge deployment unit.**
82
98
 
83
99
  A real Pikelet pack contains **456,153 Simple English Wikipedia records** in a **648.5 MiB** artifact.
84
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
+
85
117
  That file was served by a deliberately dumb HTTP server whose only relevant capability was:
86
118
 
87
119
  ```text
@@ -131,7 +163,7 @@ That is the product in one demonstration:
131
163
 
132
164
  > **A model is interrogating a 456,153-record knowledge base whose backend is a static file.**
133
165
 
134
- 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 below tests whether support changes when evidence is removed from the pack.
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.
135
167
 
136
168
  **Network cost.** In a single persistent session (one mount, five queries, one repeat):
137
169
 
@@ -154,97 +186,6 @@ The headless-Claude test above used a fresh process per question, so each invoca
154
186
 
155
187
  ---
156
188
 
157
- ## What happens if the answer is changed or removed from the file?
158
-
159
- This is different from asking whether search returns sensible documents. To test whether Pikelet's retrieval-quality signal could form a useful evidence boundary, a synthetic corpus called **Station Veyra Registry** was built. One version contained the fact:
160
-
161
- ```text
162
- The Tovash project is housed in Chamber 17.
163
- ```
164
-
165
- A second pack was byte-for-byte identical except that the record containing that fact was removed.
166
-
167
- ```text
168
- full pack: matchQuality: strong confidence: 0.915 → Chamber 17
169
- ablated pack: matchQuality: none confidence: 0.136 → unsupported
170
- ```
171
-
172
- After the model had already seen the answer, it was queried against the ablated pack with prompts like "Confirm Tovash is in Chamber 17" and "Tovash project Chamber 17 location." The retrieval result stayed unsupported and the model declined to confirm the location from the pack. A separate, informal session then probed a fresh isolated agent with neutral prompts, leading prompts, authority pressure, invitations to use general knowledge, cross-record distractors, and repeated pressure — six adversarial framings — and it continued distinguishing supported facts from the removed one in every case; that session wasn't captured as a script, so treat it as a described observation rather than a reproducible result.
173
-
174
- This does **not** mean Pikelet can prevent an LLM from hallucinating. It means the artifact can expose an explicit evidence boundary that a consuming model can choose to respect — and removing evidence from the artifact changed what that model was able to support from the mounted source.
175
-
176
- A second intervention changed only the Tovash location source record from **Chamber 17** to **Chamber 43**, rebuilt the pack, and repeated the same prompt in a fresh session:
177
-
178
- ```text
179
- chamber43 pack: matchQuality: strong confidence: 0.916 → Chamber 43
180
- ```
181
-
182
- The model answered **Chamber 43** and cited the same logical source record. With Veyra not mounted at all, the same prompt produced no chamber number and the model declined to guess. Change the evidence and the grounded answer changes with it; remove the evidence source and the answer disappears.
183
-
184
- The test is synthetic and intentionally narrow. All three packs and the `matchQuality`/`confidence` numbers above are reproducible: `node examples/05-one-file-search/web/public/reproduce-ablation.mjs`. The retrieval side is scripted; the paired LLM-session claims (declining to confirm the removed fact, answering Chamber 43, declining without the pack mounted) were run separately and aren't reproduced by that script.
185
-
186
- ---
187
-
188
- ## What does the compression cost?
189
-
190
- The artifact architecture is only useful if the compact representation does not quietly destroy retrieval. The retrieval path was decomposed on BEIR rather than evaluated only end-to-end, across four configurations that isolate each transformation:
191
-
192
- ```text
193
- A upstream all-MiniLM-L6-v2, float32 corpus, exhaustive search
194
- B Pikelet embedded encoder, float32 corpus, exhaustive search
195
- C Pikelet embedded encoder, affine-u8 corpus, exhaustive search
196
- D Pikelet embedded encoder, affine-u8 corpus, Pikelet HNSW
197
- ```
198
-
199
- Corpus mapping and evaluation config were frozen before the runs. Official BEIR qrels are used; no LLM judges relevance.
200
-
201
- | Dataset | Config | nDCG@10 | Recall@10 | Recall@100 | median ms/q | p95 ms/q |
202
- | -------- | ------------------------------------------ | ---------: | ---------: | ---------: | ----------: | -------: |
203
- | SciFact | A — upstream float exhaustive | 0.6451 | 0.7833 | 0.9250 | 3.6 | 4.0 |
204
- | SciFact | B — Pikelet encoder / float exhaustive | 0.6512 | 0.7942 | 0.9417 | 3.8 | 4.4 |
205
- | SciFact | C — Pikelet encoder / affine-u8 exhaustive | 0.6500 | 0.7942 | 0.9417 | 6.9 | 8.7 |
206
- | SciFact | D — Pikelet encoder / affine-u8 HNSW | **0.6500** | **0.7942** | **0.9417** | **0.15** | **0.24** |
207
- | NFCorpus | A — upstream float exhaustive | 0.3159 | 0.1550 | 0.3115 | 2.6 | 3.1 |
208
- | NFCorpus | B — Pikelet encoder / float exhaustive | 0.3154 | 0.1511 | 0.3044 | 2.8 | 3.3 |
209
- | NFCorpus | C — Pikelet encoder / affine-u8 exhaustive | 0.3154 | 0.1511 | 0.3036 | 2.9 | 3.6 |
210
- | NFCorpus | D — Pikelet encoder / affine-u8 HNSW | **0.3135** | **0.1482** | **0.3061** | **0.16** | **0.25** |
211
- | ArguAna | A — upstream float exhaustive | 0.3698 | 0.7653 | 0.9772 | 7.8 | 10.8 |
212
- | ArguAna | B — Pikelet encoder / float exhaustive | 0.3506 | 0.7397 | 0.9801 | 6.1 | 7.3 |
213
- | ArguAna | C — Pikelet encoder / affine-u8 exhaustive | 0.3496 | 0.7368 | 0.9801 | 6.4 | 7.5 |
214
- | ArguAna | D — Pikelet encoder / affine-u8 HNSW | **0.3496** | **0.7368** | **0.9808** | **0.12** | **0.20** |
215
-
216
- These results are more useful because they are not uniformly flattering.
217
-
218
- **Affine-u8 storage is not the main quality cost** (B→C nDCG@10: SciFact 0.6512→0.6500, NFCorpus 0.3154→0.3154, ArguAna 0.3506→0.3496). **HNSW is similarly close to exhaustive search** at these settings (C→D: SciFact and ArguAna unchanged at reported precision; NFCorpus exposes a small approximation loss, 0.3154→0.3135).
219
-
220
- **The largest observed loss is the embedded query encoder on ArguAna**: A→B moves nDCG@10 from 0.3698 to 0.3506, about a 5% relative reduction. Recall@100 actually improves slightly (0.9772→0.9801) — the relevant document is generally still in the candidate set; the degradation is in fine ordering near the top. That limitation isn't hidden: MiniLM is small *because* the goal is to fit the query encoder inside the artifact. It is not state of the art, and the compact implementation is not behaviorally identical to an upstream sentence-transformers runtime on every task.
221
-
222
- The benchmark harness, quantization-conformance test, frozen configuration, and raw runs live under `bench/beir/`.
223
-
224
- ---
225
-
226
- ## Why make knowledge a file?
227
-
228
- The conventional retrieval deployment looks something like:
229
-
230
- ```text
231
- documents → chunking → embedding service → vector database
232
- → keyword database
233
- → retrieval service → application API → agent
234
- ```
235
-
236
- That is the correct architecture for many workloads. But it is a lot of machinery when the corpus is fundamentally a release artifact: product documentation, a manual, a source tree, a legal code, a standards corpus, a research collection, a book, a knowledge snapshot, an offline reference set. These datasets often change daily, weekly, monthly, or with releases — not hundreds of times per second.
237
-
238
- For those workloads, Pikelet asks a different question:
239
-
240
- > **What if retrieval could be compiled ahead of time and distributed with the corpus?**
241
-
242
- Then deployment becomes: copy the file, cache it, pin it, put it behind a CDN, mount it by URL. The storage layer does not need to know that the file contains vectors.
243
-
244
- It is not a replacement for a mutable vector database. It is an attempt to make **static and slowly changing knowledge deploy like any other artifact**.
245
-
246
- ---
247
-
248
189
  ## Attach a pack to an LLM
249
190
 
250
191
  `pikelet mcp` exposes one or more packs through the Model Context Protocol:
@@ -261,55 +202,29 @@ A pack mounted with a content hash has a stable identity — `https://example.co
261
202
 
262
203
  **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.
263
204
 
264
- **Try it with your own questions.** The three Veyra packs from the ablation test above (`examples/05-one-file-search/web/public/veyra*.pikelet`) are committed to this repo, so there's a working MCP mount you can query with anything, not just the fixed prompts above:
205
+ **Try it with your own questions.** The three Veyra packs from the [ablation](docs/veyra-ablation.md) are published as pinned GitHub release assets. Fetch and verify them first:
206
+
207
+ ```bash
208
+ npm run demo:veyra
209
+ ```
210
+
211
+ Then mount all three through Pikelet's MCP server:
265
212
 
266
213
  ```bash
267
214
  claude -p "your question here" \
268
- --mcp-config examples/05-one-file-search/web/public/veyra.mcp.json \
215
+ --mcp-config examples/one-file-search/web/public/veyra.mcp.json \
269
216
  --strict-mcp-config \
270
217
  --allowedTools "mcp__veyra-demo__search,mcp__veyra-demo__list_packs,mcp__veyra-demo__get_record"
271
218
  ```
272
219
 
273
- or drop the flags and just run `claude` from the repo root — Claude Code picks up project-level `.mcp.json` configs automatically, so pointing one at this file (or copying its `veyra-demo` server into your own) gets the same three packs into an interactive session. All three packs share the same 94-ish-record synthetic corpus, so the interesting thing to ask about is Tovash's chamber — the full and Chamber 43 packs will disagree with each other, and the ablated pack won't answer at all.
220
+ For an interactive session, use the same config without `-p`:
274
221
 
275
- ---
276
-
277
- ## How a remote query runs
278
-
279
- Over a network, what makes search slow is not bytes; it's sequential round trips. Graph traversal is a chain of dependent reads — fetch node, inspect neighbors, fetch the next node, repeat — and across object storage those dependent round trips dominate. Measured against SIFT1M over real network storage, a resident scan plus one batched candidate-fetch phase beat graph traversal by about 5× at equal recall. So the remote artifact path carries no graph. The HNSW graph still exists — it's the in-memory engine, used when the whole index is local and round trips are free; that's the path BEIR Config D measures.
280
-
281
- For the remote path:
282
-
283
- 1. **Resident tier** — a pooled sketch of every semantic row loads once when the artifact opens and stays in memory. For the 456,153-record Wikipedia pack: 648.5 MiB artifact, 51.7 MiB mount fetch. A SIMD kernel scans all of it in milliseconds.
284
- 2. **Candidate selection** — the scan picks the top `C` candidates against the query vector; the lexical BM25 index can contribute additional candidates into the same set. `C` is the only real knob: the compiler measures recall against brute force on held-out queries at build time and writes the operating point into the file, so readers don't have to guess it.
285
- 3. **Batched byte-range fetch** — full affine-u8 rows for the candidate set are fetched in one parallel round, nearby ranges coalesced, rather than a graph-dependent network walk.
286
- 4. **Full rerank** — fetched rows are scored against the float query and verified against their digest.
287
- 5. **Corpus hydration** — only the records needed for returned results are fetched, each carrying record ID, title, section, source, and artifact identity. The reader returns evidence, not anonymous vector IDs.
288
-
289
- That architecture is why a 648.5 MiB pack can answer a fresh query while fetching about 0.5–1.1 MiB after warmup.
290
-
291
- ---
292
-
293
- ## Integrity is part of the read path
294
-
295
- A range-readable artifact cannot hash the entire file on every open without defeating the point of range reads, so integrity is layered: the resident structural portion is verified during open; lazily fetched index rows and corpus records carry independent commitments and are checked when read. Bytes a query never touches don't have to cross the network merely to prove the bytes it *did* use were correct.
296
-
297
- One exception: a lexical (BM25) segment above 8 MiB opens lazily and is covered only by the manifest's whole-segment digest, not per-read like index rows and corpus records — the same transitional stance format-1 sketch rows carry. A pack large enough for this to apply can have its lexical candidates altered between open and a full verification pass without failing a query.
298
-
299
- The failure-mode suite (`bench/range-proof/failure-modes.mjs`) exercises the important cases:
300
-
301
- ```text
302
- correct Range server → mounts, queries successfully
303
- server ignores Range → small files fall back to a bounded download;
304
- large files refuse ("host ignores Range and the
305
- file is 680029254 bytes; refusing full download" —
306
- the cap is currently 64 MiB)
307
- bytes change under a pinned identity → refused; tampered data is not silently served
308
- truncated Range response → fetch fails; no partial result is silently
309
- interpreted as valid data
222
+ ```bash
223
+ claude \
224
+ --mcp-config examples/one-file-search/web/public/veyra.mcp.json
310
225
  ```
311
226
 
312
- The point is not that static HTTP is magically reliable. The point is that a static artifact can fail in explicit, testable ways.
227
+ 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.
313
228
 
314
229
  ---
315
230
 
@@ -321,94 +236,12 @@ A nearest neighbour is not automatically evidence that a corpus answers a questi
321
236
 
322
237
  ---
323
238
 
324
- ## What's inside a `.pikelet`
325
-
326
- ```text
327
- manifest format versions, segment offsets, artifact identity
328
- semantic index resident sketch rows, full affine-u8 rows, row commitments
329
- corpus source records, offsets, per-record commitments
330
- query encoder WordPiece vocabulary, quantized MiniLM weights, encoder declaration
331
- lexical index BM25 postings
332
- calibration supported / unsupported retrieval model
333
- evaluation golden queries / expected behavior
334
- ```
335
-
336
- The artifact is immutable. Publish a new corpus by publishing a new artifact; old artifacts retain their identity.
337
-
338
- **Encoder profiles.** Self-containment has a cost — the default profile carries ~25 MiB of MiniLM data regardless of corpus size. Three arrangements: **inline** (tokenizer + weights + runtime in the artifact — largest file, no external dependency, used by the Wikipedia demo above), **distilled/compact** (smaller corpus-specific representation, lower footprint, potentially lower quality), **host-supplied** (the artifact declares expected encoder behavior and verifies the host implementation against embedded test vectors — smallest artifact, no longer fully self-contained). The format treats the encoder as a capability, not Pikelet's identity — MiniLM is the current choice, not a permanent requirement.
339
-
340
- ---
341
-
342
- ## One decision
343
-
344
- Most of the implementation follows from how a vector is stored. Each row is 8-bit integers plus two floats:
345
-
346
- ```text
347
- x[d] ≈ offset + scale · q[d] q[d] ∈ 0..255
348
- ```
349
-
350
- That's a per-row affine map. It costs 4× less memory than float32, which is the ordinary reason to do it. The reason it runs through the whole project is that every operation search needs — dot products, sums, averages — is linear, and affine terms factor out of linear operations.
351
-
352
- **Query against a stored row.** The query stays float32 and is never quantized.
353
-
354
- ```text
355
- y·x = offset·Σy + scale·(y·q)
356
- ```
357
-
358
- The inner loop multiplies floats by bytes. The two constants come in once at the end. No decompressed copy of the row ever exists.
359
-
360
- **Stored row against stored row.** This is what graph construction needs, thousands of times per insert.
361
-
362
- ```text
363
- x_i·x_j = D·o_i·o_j + o_i·s_j·Σq_j + o_j·s_i·Σq_i + s_i·s_j·(q_i·q_j)
364
- ```
365
-
366
- `D` is the vector's dimensionality (384 for the bundled encoder). `q_i·q_j` is an integer dot over two byte arrays, which maps efficiently onto SIMD. `Σq` is stored per row. The HNSW graph is built and repaired entirely on compressed data.
367
-
368
- **Pooling.** Average adjacent groups of `p` bytes and you get a shorter row. Because the mean of `offset + scale·q` over a group equals `offset + scale·mean(q)`, the shorter row keeps the *same two constants*. That's the resident sketch tier; a micro tier is the same thing done twice.
369
-
370
- **Weights.** The bundled query encoder is a 6-layer MiniLM whose matrices are stored the same way, one (scale, offset) per 64-column block. Activations stay float32, weights stay bytes, dequantization happens inside the matmul — the same widen-and-multiply trick, a separate kernel, that scores vectors.
371
-
372
- The consequence for the file format: a row decodes from its own bytes and its own two floats and nothing else. No codebook, no global statistics. Every row is a fixed-size byte range at a computable offset — locatable by arithmetic, fetchable on its own, hashable on its own, verifiable on the read that fetches it.
373
-
374
- **Why not product quantization.** Product quantization would compress harder. It would also need a codebook, a training pass, table lookups in the distance kernel, and rows that mean nothing without the codebook. Four-to-one with no shared state was the better trade for a file meant to be read in pieces.
375
-
376
- ---
377
-
378
- ## Build once, publish anywhere
239
+ ## Deeper dives
379
240
 
380
- ```bash
381
- npx pikelet compile --source ./docs --out docs.pikelet
382
- ```
383
-
384
- ```js
385
- import { openPikeletFile } from 'pikelet-wasm/complete';
386
-
387
- const pack = await openPikeletFile('https://example.com/docs.pikelet#<sha256>');
388
- const result = await pack.query('how do workers restore snapshots', { k: 5 });
389
-
390
- console.log(result.matchQuality);
391
- for (const hit of result.results) console.log(hit.title, hit.section);
392
-
393
- await pack.close();
394
- ```
395
-
396
- The same artifact can also be opened from a local path or any custom source implementing `{ size, read(offset, length) }`.
397
-
398
- **Or use the search engine directly**, if you already have vectors and don't need the artifact layer:
399
-
400
- ```js
401
- import Pikelet from 'pikelet-wasm';
402
-
403
- const index = await Pikelet.create({ dim: 384, maxElements: 100000, metric: 'cosine', quantized: true });
404
- index.add(vector);
405
- const results = index.search(query, 10);
406
- const snapshot = index.export();
407
- ```
408
-
409
- float32 HNSW, affine-u8 HNSW, insert/delete, compaction, import/export, deterministic snapshots, WASM with no native addon dependency. Pikelet is usable as a vector engine — that's just no longer the main reason the project exists.
410
-
411
- ---
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.
242
+ - [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
+ - [Why make knowledge a file](docs/why-a-file.md) — the reasoning behind the single-artifact design, and what it commits to.
244
+ - [Architecture](docs/architecture.md) — the engine, the C ABI and the JavaScript wrapper; the artifact formats are specified in [`spec/`](spec/).
412
245
 
413
246
  ## What this is not
414
247
 
@@ -422,21 +255,6 @@ float32 HNSW, affine-u8 HNSW, insert/delete, compaction, import/export, determin
422
255
 
423
256
  ---
424
257
 
425
- ## Why the file format matters
426
-
427
- A retrieval service is identified by an endpoint. A Pikelet is identified by its contents.
428
-
429
- - **Reproducibility** — pack hash + record ID identifies the exact evidence a model retrieved.
430
- - **Versioning** — different releases are different artifacts (`docs-v1.pikelet`, `docs-v2.pikelet`); no silent mutation.
431
- - **Distribution** — a pack can be mirrored by infrastructure that knows nothing about semantic search.
432
- - **Offline use** — once local, no network or embedding service needed.
433
- - **Customer-controlled knowledge** — hand someone the artifact instead of granting access to internal retrieval infrastructure.
434
- - **Agent knowledge environments** — run a task against a frozen snapshot, reproduce it later against the same bytes.
435
-
436
- > **Can knowledge become a first-class software artifact rather than something that always has to live behind a service?**
437
-
438
- ---
439
-
440
258
  ## When Pikelet is a good fit — and when it isn't
441
259
 
442
260
  **Use it when:**
@@ -468,23 +286,37 @@ The resident remote scan is linear in row count. The current architecture target
468
286
  ## Repository map
469
287
 
470
288
  ```text
471
- src/ C++/WASM vector engine: HNSW, float32 and
289
+ packages/pikelet-wasm/ The published pikelet-wasm package:
290
+ engine/ C++ vector engine (HNSW, float32 and
472
291
  affine-u8 backends, mutation, compaction,
473
- snapshot import/export
474
- complete/, pikelet-artifact.js Readers and builders for the complete
475
- range-readable artifact
476
- pikelet/ CLI, compiler, MCP server, encoder
477
- integration, higher-level tooling
292
+ snapshot import/export), compiled to WASM
293
+ in dist/. Used at compile time to build and
294
+ quantize the index; query time does not load
295
+ it (see complete/index.mjs's header comment)
296
+ — .pikelet reads run a pure-JS sketch scan,
297
+ optionally SIMD-accelerated.
298
+ src/ Engine wrapper and artifact readers/builders
299
+ (the entrypoints, core/, artifact/, errors/)
300
+ complete/ Reader and builder for the complete
301
+ range-readable .pikelet artifact
302
+ native/ N-API build of the same engine (benchmarks)
303
+ packages/pikelet/ The published pikelet CLI: compiler, MCP
304
+ server, encoder integration, Docusaurus plugin
305
+ docs/ Deeper dives (how a query runs, the Veyra
306
+ ablation, why a file), architecture notes,
307
+ rename history, measurement reports
478
308
  spec/ Byte-level artifact contracts
479
- bench/beir/ Frozen BEIR ablation harness (encoder,
309
+ benchmarks/beir/ Frozen BEIR ablation harness (encoder,
480
310
  quantization, HNSW quality)
481
- bench/range-proof/ The deliberately boring static-HTTP proof:
311
+ benchmarks/range-proof/ The deliberately boring static-HTTP proof:
482
312
  dumb-server.mjs, proof.mjs, mcp-proof.mjs,
483
313
  llm-proof.mjs, failure-modes.mjs
484
- examples/05-one-file-search/ Large single-artifact search and embedded
314
+ examples/one-file-search/ Large single-artifact search and embedded
485
315
  encoder work
486
- examples/06-mcp-knowledge-pack/ Compile, mount, search, and hydrate
316
+ examples/mcp-knowledge-pack/ Compile, mount, search, and hydrate
487
317
  records through MCP
318
+ local-packs/ Prebuilt example .pikelet artifacts used
319
+ by the demo commands above
488
320
  packs/ Pack hosting and distribution examples
489
321
  ```
490
322
 
@@ -492,9 +324,9 @@ packs/ Pack hosting and distribution examples
492
324
 
493
325
  ## Reproduce the proofs
494
326
 
495
- **Range proof** (`bench/range-proof/`) is built around a server that does not understand Pikelet. It checks: static HTTP + Range + Pikelet client = remote semantic retrieval, without a full download. The main script reports artifact size, records, mount bytes/requests, per-query bytes/requests, cache behavior. `failure-modes.mjs` independently exercises Range supported / ignored / tampered bytes / truncated response. `mcp-proof.mjs` and `llm-proof.mjs` use a real MCP client and a real headless Claude process rather than a mocked model response.
327
+ **Range proof** (`benchmarks/range-proof/`) is built around a server that does not understand Pikelet. It checks: static HTTP + Range + Pikelet client = remote semantic retrieval, without a full download. The main script reports artifact size, records, mount bytes/requests, per-query bytes/requests, cache behavior. `failure-modes.mjs` independently exercises Range supported / ignored / tampered bytes / truncated response. `mcp-proof.mjs` and `llm-proof.mjs` use a real MCP client and a real headless Claude process rather than a mocked model response.
496
328
 
497
- **BEIR evaluation** (`bench/beir/`) keeps stages separate so a quality change can be attributed to the component that caused it, plus a standalone conformance fixture checking the JS benchmark path against the actual C++ affine-u8 representation. Don't read the latency columns as a universal native-performance comparison — they measure the benchmark paths under the stated harness. The quality deltas are the important part.
329
+ **BEIR evaluation** (`benchmarks/beir/`) keeps stages separate so a quality change can be attributed to the component that caused it, plus a standalone conformance fixture checking the JS benchmark path against the actual C++ affine-u8 representation. Don't read the latency columns as a universal native-performance comparison — they measure the benchmark paths under the stated harness. The quality deltas are the important part.
498
330
 
499
331
  ---
500
332
 
@@ -504,7 +336,7 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
504
336
 
505
337
  - prebuilt WASM included
506
338
  - local and HTTP readers
507
- - affine-u8 and float HNSW backends
339
+ - affine-u8 and float HNSW backends for compile-time indexing/quantization
508
340
  - embedded query encoder
509
341
  - BM25 hybrid retrieval
510
342
  - content identity and lazy-read integrity verification
@@ -516,18 +348,10 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
516
348
 
517
349
  `npm test` exercises the engine, artifact profiles, MCP, ingestion, format hardening, and related conformance suites.
518
350
 
519
- The project was renamed from **Pancake** to **Pikelet** in September 2026; some old fixture filenames and compatibility identifiers still use the previous name. `pancake-wasm` and `create-pancake-search` are deprecated compatibility pointers to the Pikelet packages. Pikelet is unrelated to the pre-existing Pikelet programming language.
351
+ Previously known as **Pancake** (renamed September 2026); artifacts, profile strings and files from before the rename remain readable — see [`docs/history.md`](docs/history.md) for what kept the old name and why. Pikelet is unrelated to the pre-existing Pikelet programming language.
520
352
 
521
353
  ---
522
354
 
523
- ## The experiment
524
-
525
- > **For static and slowly changing corpora, useful semantic retrieval can be compiled into the artifact being distributed instead of operated as a separate service.**
526
-
527
- The implementation currently demonstrates: one file, 456,153 records, 648.5 MiB, embedded query encoder, semantic + lexical retrieval, content identity, per-record integrity, MCP mounting, static HTTP hosting, ~0.5–1.1 MiB fresh-query range traffic after warmup, real multi-record LLM synthesis, no retrieval backend.
528
-
529
- There are many reasons a database remains the right answer. Pikelet exists for the cases where the knowledge itself should be something you can **build, hash, copy, cache, publish, mount, query, and keep.**
530
-
531
355
  ## License
532
356
 
533
357
  Apache-2.0. See [LICENSE](LICENSE).
@@ -81,8 +81,25 @@ export function buildQueryInterpSegment(kind, encoderBytes, calibrationBytes) {
81
81
  // and skips a small function-word stopword list — readers need no
82
82
  // coordination with the list, because an unindexed query token simply finds
83
83
  // no postings.
84
+ //
85
+ // Interrogative words (how/does/what/who/why/where/when/which/do/did) are
86
+ // deliberately in this list, not just the base one: without them, a
87
+ // natural-language question's own scaffolding becomes real, indexable BM25
88
+ // content — a passage containing literal dialogue like "How does Georgiana
89
+ // get on, Darcy?" then wins a high lexical score against the query "How
90
+ // does Elizabeth initially react to Mr. Darcy?" purely from "how does" and
91
+ // "darcy" co-occurring, with zero topical relevance. Confirmed live: this
92
+ // pushed a genuinely correct chunk (ranked #2 by the vector search alone)
93
+ // completely out of the fused top-10, replaced by RRF-boosted noise. Not
94
+ // borrowed wholesale from calibrate.mjs's COVERAGE_STOPWORDS, which also
95
+ // excludes domain words ("tell", "explain", "overview", "history",
96
+ // "definition") that are legitimate lexical CONTENT on many real corpora
97
+ // (a docs site literally titled "History") — this list only adds the
98
+ // unambiguous interrogative set, since the lexical index (unlike coverage)
99
+ // also indexes passage text, not just the query.
84
100
  const LEXICAL_LAYOUT = 'bm25-v1';
85
- const LEXICAL_STOPWORDS = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'into', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'as', 'their', 'they', 'them', 'his', 'her', 'has', 'have', 'had', 'not', 'can', 'will', 'if', 'we', 'you', 'your', 'i']);
101
+ const LEXICAL_STOPWORDS = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'into', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'as', 'their', 'they', 'them', 'his', 'her', 'has', 'have', 'had', 'not', 'can', 'will', 'if', 'we', 'you', 'your', 'i',
102
+ 'how', 'does', 'did', 'do', 'what', 'who', 'why', 'where', 'when', 'which']);
86
103
  const lexTokenize = (text) => (String(text).toLowerCase().match(/[a-z0-9']+/g) || [])
87
104
  .filter((w) => w.length >= 2 && w.length <= 32 && !LEXICAL_STOPWORDS.has(w));
88
105
 
@@ -439,16 +456,29 @@ function selfQueriesFromSnapshot(graph, maxQueries) {
439
456
  // explicitly instead of misreading the corpus tables.
440
457
  export const PROFILE_V1 = 'pikelet-complete-v1';
441
458
  export const PROFILE_V2 = 'pikelet-complete-v2';
442
- export const FORMAT_VERSIONS = { [PROFILE_V1]: 1, [PROFILE_V2]: 2 };
459
+ // A layer is a complete-profile container under formatVersion 2 with its own
460
+ // manifest profile string (LAYERED_PROFILE.md 4.1). A reader that predates the
461
+ // layered profile sees the string under formatVersion 2 and refuses with its
462
+ // existing "unsupported profile" error, which is the intended behavior: a layer
463
+ // opened alone would silently serve a fraction of a corpus.
464
+ export const PROFILE_LAYER = 'pikelet-layer-v1';
465
+ export const FORMAT_VERSIONS = { [PROFILE_V1]: 1, [PROFILE_V2]: 2, [PROFILE_LAYER]: 2 };
443
466
 
444
467
  export function assemblePikeletFile(manifestFields, segments, outPath) {
445
468
  const formatVersion = FORMAT_VERSIONS[manifestFields.profile];
446
469
  if (!formatVersion) {
447
470
  throw new Error(`manifest.profile must be ${PROFILE_V1} or ${PROFILE_V2}, got ${manifestFields.profile}`);
448
471
  }
472
+ const isLayer = manifestFields.profile === PROFILE_LAYER;
449
473
  const layout = manifestFields.corpus?.layout;
450
- if (formatVersion === 2 && layout !== CORPUS_LAYOUT_V2) {
451
- throw new Error(`${PROFILE_V2} requires manifest.corpus from buildCorpusSegment() (layout ${CORPUS_LAYOUT_V2})`);
474
+ // A tombstone-only layer (4.1) carries a corpus object with records 0 and
475
+ // MUST omit the layout fields, because it has no corpus segment to describe.
476
+ const tombstoneOnlyLayer = isLayer && manifestFields.corpus?.records === 0;
477
+ if (formatVersion === 2 && !tombstoneOnlyLayer && layout !== CORPUS_LAYOUT_V2) {
478
+ throw new Error(`${manifestFields.profile} requires manifest.corpus from buildCorpusSegment() (layout ${CORPUS_LAYOUT_V2})`);
479
+ }
480
+ if (tombstoneOnlyLayer && layout !== undefined) {
481
+ throw new Error('a tombstone-only layer must omit corpus.layout');
452
482
  }
453
483
  if (formatVersion === 1 && layout !== undefined) {
454
484
  throw new Error(`${PROFILE_V1} carries corpus layout v1 (buildCorpusSegmentFromBuffers); got layout ${layout}`);
@@ -462,7 +492,7 @@ export function assemblePikeletFile(manifestFields, segments, outPath) {
462
492
  // sketch's own integrity chain to the complete identity without forcing a
463
493
  // whole-segment read at open (the index segment stays lazy).
464
494
  let indexCommitment;
465
- if (formatVersion >= 2) {
495
+ if (formatVersion >= 2 && !tombstoneOnlyLayer) {
466
496
  const index = segments.find((s) => s.kind === 'index');
467
497
  if (!index || index.bytes.length < 256) {
468
498
  throw new Error('index segment must carry a 256-byte sketch header');
@@ -4,5 +4,10 @@
4
4
  export const MAGIC = 0x31465350; // "PSF1"
5
5
  export const HEADER_BYTES = 64;
6
6
  export const TABLE_ENTRY_BYTES = 48;
7
- export const KINDS = { index: 1, corpus: 2, 'query-interp': 3, evaluation: 4, lexical: 5 };
7
+ // Kinds 6 and 7 belong to the layered profile (LAYERED_PROFILE.md 4.2): a
8
+ // layer carries exactly one tombstones segment, and a base compacted from a
9
+ // chain carries at most one lineage segment. They are registered here so the
10
+ // shared table has one definition; a complete-profile artifact never has
11
+ // either, and the required-segment tables stay per-profile.
12
+ export const KINDS = { index: 1, corpus: 2, 'query-interp': 3, evaluation: 4, lexical: 5, tombstones: 6, lineage: 7 };
8
13
  export const KIND_NAMES = Object.fromEntries(Object.entries(KINDS).map(([name, kind]) => [kind, name]));
@@ -0,0 +1,72 @@
1
+ // Vector/lexical rank fusion for hybrid retrieval — the one definition the
2
+ // reader (index.mjs), the calibrator (pikelet/src/calibrate.mjs, which fits
3
+ // coverage on the passage fusion ranks first) and the BEIR ladder
4
+ // (benchmarks/beir/query-E.mjs) all call, so a fit-time or benchmark
5
+ // ranking is the ranking the reader serves.
6
+ //
7
+ // Reciprocal-rank fusion over the exact-reranked vector order and the BM25
8
+ // order: score = 1/(K + vRank) + lexicalWeight/(K + lexRank), lexical term
9
+ // present only for records BM25 returned. Every lexical candidate joins the
10
+ // vector pool (the reader reranks them exactly), so it always has a vRank.
11
+ //
12
+ // The vector-margin guard: when the vector top-1 leads the top-2 by at
13
+ // least guardMargin of its own distance, it keeps rank 1 regardless of
14
+ // fusion. Without it, with K=60 any record that appears in both lists
15
+ // outranks a vector-only rank 1 (1/(60+v) + w/(60+l) > 1/60 for any finite
16
+ // v when w=1), so a query whose terms match many chunks — a character name
17
+ // in a novel — fills the top ranks with term matches and buries the
18
+ // passage the encoder placed first. Measured on three sets with
19
+ // scripts/fusion-sim.mjs before any default changed.
20
+
21
+ export const FUSION_DEFAULTS = Object.freeze({
22
+ rrfK: 60,
23
+ lexicalWeight: 0.5,
24
+ guardMargin: 0.05,
25
+ });
26
+
27
+ /**
28
+ * @param {Array<{id: number, distance: number}>} vectorOrder hits sorted by
29
+ * exact distance ascending (the rerank output), including every lexical
30
+ * candidate.
31
+ * @param {number[]} lexicalIds record ids in BM25 order (after the cutoff).
32
+ * @param {{rrfK?: number, lexicalWeight?: number, guardMargin?: number}} [options]
33
+ * @returns the same hit objects in fused order.
34
+ */
35
+ export function fuseCandidates(vectorOrder, lexicalIds, options = {}) {
36
+ const rrfK = options.rrfK ?? FUSION_DEFAULTS.rrfK;
37
+ const lexicalWeight = options.lexicalWeight ?? FUSION_DEFAULTS.lexicalWeight;
38
+ const guardMargin = options.guardMargin ?? FUSION_DEFAULTS.guardMargin;
39
+ if (!(rrfK > 0) || !(lexicalWeight >= 0) || !(guardMargin >= 0)) {
40
+ throw new Error('fuseCandidates: rrfK must be > 0, lexicalWeight and guardMargin >= 0');
41
+ }
42
+ if (!lexicalIds.length) return vectorOrder.slice();
43
+ const lexRank = new Map();
44
+ lexicalIds.forEach((id, i) => { if (!lexRank.has(id)) lexRank.set(id, i); });
45
+ let fused = vectorOrder
46
+ .map((hit, vRank) => ({
47
+ hit,
48
+ score: 1 / (rrfK + vRank) + (lexRank.has(hit.id) ? lexicalWeight / (rrfK + lexRank.get(hit.id)) : 0),
49
+ }))
50
+ .sort((a, b) => (b.score - a.score) || (a.hit.distance - b.hit.distance))
51
+ .map((entry) => entry.hit);
52
+ if (guardMargin > 0 && vectorOrder.length > 1) {
53
+ const [first, second] = vectorOrder;
54
+ // The margin is relative to the top-1's own distance, which needs a
55
+ // positive denominator. first.distance === 0 is not a degenerate case
56
+ // to skip but the STRONGEST possible top-1 -- cosine distance is
57
+ // 1 - dot, so a query matching passage text verbatim scores exactly 0
58
+ // -- and gating on `first.distance > 0` disabled the guard for exactly
59
+ // the hit it exists to protect (a worse top-1 at 0.001 was guarded, a
60
+ // perfect one at 0 was not). Any second distance strictly greater than
61
+ // a zero first is an infinite relative lead, so the guard fires.
62
+ // Distances are non-negative here (cosine/L2 as returned by the
63
+ // rerank); a metric admitting negatives would need an absolute margin.
64
+ const lead = first.distance > 0
65
+ ? (second.distance - first.distance) / first.distance
66
+ : (second.distance > 0 ? Infinity : 0);
67
+ if (lead >= guardMargin && fused[0] !== first) {
68
+ fused = [first, ...fused.filter((hit) => hit !== first)];
69
+ }
70
+ }
71
+ return fused;
72
+ }
@@ -245,6 +245,29 @@ export declare function openPikeletFile(
245
245
 
246
246
  /** Header formatVersion -> manifest profile accepted by this reader. */
247
247
  export declare const SUPPORTED_PROFILES: Readonly<Record<number, string>>;
248
+
249
+ /** Rank-fusion parameters shared by the reader, the calibrator and the BEIR ladder. */
250
+ export interface FusionOptions {
251
+ /** Reciprocal-rank-fusion constant (default 60). */
252
+ rrfK?: number;
253
+ /** Weight of the lexical (BM25) rank term relative to the vector term (default 0.5). */
254
+ lexicalWeight?: number;
255
+ /**
256
+ * Relative distance margin (top-2 minus top-1, over top-1) at or above which
257
+ * the vector top-1 keeps rank 1 (default 0.05; 0 disables the guard).
258
+ */
259
+ guardMargin?: number;
260
+ }
261
+ export declare const FUSION_DEFAULTS: Readonly<Required<FusionOptions>>;
262
+ /**
263
+ * Fuse the exact-reranked vector order (every lexical candidate included) with the BM25 order.
264
+ * Returns the same hit objects, reordered.
265
+ */
266
+ export declare function fuseCandidates<T extends { id: number; distance: number }>(
267
+ vectorOrder: T[],
268
+ lexicalIds: number[],
269
+ options?: FusionOptions,
270
+ ): T[];
248
271
  export declare const CORPUS_LAYOUT_V2: 'records-v2';
249
272
  /**
250
273
  * Check a host encoder against a kind-2 declaration's verification vectors