pikelet-wasm 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +321 -1
  2. package/README.md +65 -257
  3. package/complete/builder.mjs +18 -1
  4. package/complete/fusion.mjs +72 -0
  5. package/complete/index.d.ts +23 -0
  6. package/complete/index.mjs +100 -20
  7. package/complete/inline-transformer.mjs +97 -1
  8. package/complete/retrieval-abstention.mjs +118 -25
  9. package/complete/sources.mjs +1 -1
  10. package/complete/wordpiece.mjs +22 -0
  11. package/dist/engine.js +1 -1
  12. package/dist/engine.scalar.js +1 -1
  13. package/dist/engine.scalar.wasm +0 -0
  14. package/dist/engine.wasm +0 -0
  15. package/package.json +35 -77
  16. package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
  17. package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
  18. package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +30 -3
  19. package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
  20. package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
  21. package/{pikelet.js → src/pikelet.js} +7 -7
  22. package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
  23. package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
  24. package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
  25. /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
  26. /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
  27. /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
  28. /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
  29. /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@ 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
12
 
13
13
  ```bash
14
14
  npx pikelet compile --source ./docs --out docs.pikelet
@@ -47,7 +47,7 @@ console.log(out.matchQuality, out.results[0]?.title);
47
47
  // 'strong' 'Snapshot restore'
48
48
  ```
49
49
 
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`.
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. `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`.
51
51
 
52
52
  Under the hood, the file is one container for everything a reader needs:
53
53
 
@@ -82,6 +82,22 @@ Search is the interface. **The file is the knowledge deployment unit.**
82
82
 
83
83
  A real Pikelet pack contains **456,153 Simple English Wikipedia records** in a **648.5 MiB** artifact.
84
84
 
85
+ Try the live browser demo:
86
+
87
+ ```text
88
+ https://pikelet-wiki-playground.pages.dev/
89
+ ```
90
+
91
+ The page is static HTML/JS on Cloudflare Pages. It mounts the pack from R2:
92
+
93
+ ```text
94
+ https://pub-6da2384a3bca4a44b2b2fa29a94cc811.r2.dev/wikipedia.pikelet
95
+ ```
96
+
97
+ There is no `/search`, `/embed`, `/query`, or `/api` backend behind the demo.
98
+ The browser opens the `.pikelet` file over HTTP Range, verifies the artifact,
99
+ loads the embedded query encoder, and shows the byte ranges it reads.
100
+
85
101
  That file was served by a deliberately dumb HTTP server whose only relevant capability was:
86
102
 
87
103
  ```text
@@ -131,7 +147,7 @@ That is the product in one demonstration:
131
147
 
132
148
  > **A model is interrogating a 456,153-record knowledge base whose backend is a static file.**
133
149
 
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.
150
+ 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
151
 
136
152
  **Network cost.** In a single persistent session (one mount, five queries, one repeat):
137
153
 
@@ -154,97 +170,6 @@ The headless-Claude test above used a fresh process per question, so each invoca
154
170
 
155
171
  ---
156
172
 
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
173
  ## Attach a pack to an LLM
249
174
 
250
175
  `pikelet mcp` exposes one or more packs through the Model Context Protocol:
@@ -261,55 +186,29 @@ A pack mounted with a content hash has a stable identity — `https://example.co
261
186
 
262
187
  **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
188
 
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:
189
+ **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:
190
+
191
+ ```bash
192
+ npm run demo:veyra
193
+ ```
194
+
195
+ Then mount all three through Pikelet's MCP server:
265
196
 
266
197
  ```bash
267
198
  claude -p "your question here" \
268
- --mcp-config examples/05-one-file-search/web/public/veyra.mcp.json \
199
+ --mcp-config examples/one-file-search/web/public/veyra.mcp.json \
269
200
  --strict-mcp-config \
270
201
  --allowedTools "mcp__veyra-demo__search,mcp__veyra-demo__list_packs,mcp__veyra-demo__get_record"
271
202
  ```
272
203
 
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.
274
-
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
- ---
204
+ For an interactive session, use the same config without `-p`:
292
205
 
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
206
+ ```bash
207
+ claude \
208
+ --mcp-config examples/one-file-search/web/public/veyra.mcp.json
310
209
  ```
311
210
 
312
- The point is not that static HTTP is magically reliable. The point is that a static artifact can fail in explicit, testable ways.
211
+ 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
212
 
314
213
  ---
315
214
 
@@ -321,94 +220,12 @@ A nearest neighbour is not automatically evidence that a corpus answers a questi
321
220
 
322
221
  ---
323
222
 
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
- ```
223
+ ## Deeper dives
335
224
 
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
379
-
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
- ---
225
+ - [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.
226
+ - [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.
227
+ - [Why make knowledge a file](docs/why-a-file.md) — the reasoning behind the single-artifact design, and what it commits to.
228
+ - [Architecture](docs/architecture.md) — the engine, the C ABI and the JavaScript wrapper; the artifact formats are specified in [`spec/`](spec/).
412
229
 
413
230
  ## What this is not
414
231
 
@@ -422,21 +239,6 @@ float32 HNSW, affine-u8 HNSW, insert/delete, compaction, import/export, determin
422
239
 
423
240
  ---
424
241
 
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
242
  ## When Pikelet is a good fit — and when it isn't
441
243
 
442
244
  **Use it when:**
@@ -468,23 +270,37 @@ The resident remote scan is linear in row count. The current architecture target
468
270
  ## Repository map
469
271
 
470
272
  ```text
471
- src/ C++/WASM vector engine: HNSW, float32 and
273
+ packages/pikelet-wasm/ The published pikelet-wasm package:
274
+ engine/ C++ vector engine (HNSW, float32 and
472
275
  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
276
+ snapshot import/export), compiled to WASM
277
+ in dist/. Used at compile time to build and
278
+ quantize the index; query time does not load
279
+ it (see complete/index.mjs's header comment)
280
+ — .pikelet reads run a pure-JS sketch scan,
281
+ optionally SIMD-accelerated.
282
+ src/ Engine wrapper and artifact readers/builders
283
+ (the entrypoints, core/, artifact/, errors/)
284
+ complete/ Reader and builder for the complete
285
+ range-readable .pikelet artifact
286
+ native/ N-API build of the same engine (benchmarks)
287
+ packages/pikelet/ The published pikelet CLI: compiler, MCP
288
+ server, encoder integration, Docusaurus plugin
289
+ docs/ Deeper dives (how a query runs, the Veyra
290
+ ablation, why a file), architecture notes,
291
+ rename history, measurement reports
478
292
  spec/ Byte-level artifact contracts
479
- bench/beir/ Frozen BEIR ablation harness (encoder,
293
+ benchmarks/beir/ Frozen BEIR ablation harness (encoder,
480
294
  quantization, HNSW quality)
481
- bench/range-proof/ The deliberately boring static-HTTP proof:
295
+ benchmarks/range-proof/ The deliberately boring static-HTTP proof:
482
296
  dumb-server.mjs, proof.mjs, mcp-proof.mjs,
483
297
  llm-proof.mjs, failure-modes.mjs
484
- examples/05-one-file-search/ Large single-artifact search and embedded
298
+ examples/one-file-search/ Large single-artifact search and embedded
485
299
  encoder work
486
- examples/06-mcp-knowledge-pack/ Compile, mount, search, and hydrate
300
+ examples/mcp-knowledge-pack/ Compile, mount, search, and hydrate
487
301
  records through MCP
302
+ local-packs/ Prebuilt example .pikelet artifacts used
303
+ by the demo commands above
488
304
  packs/ Pack hosting and distribution examples
489
305
  ```
490
306
 
@@ -492,9 +308,9 @@ packs/ Pack hosting and distribution examples
492
308
 
493
309
  ## Reproduce the proofs
494
310
 
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.
311
+ **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
312
 
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.
313
+ **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
314
 
499
315
  ---
500
316
 
@@ -504,7 +320,7 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
504
320
 
505
321
  - prebuilt WASM included
506
322
  - local and HTTP readers
507
- - affine-u8 and float HNSW backends
323
+ - affine-u8 and float HNSW backends for compile-time indexing/quantization
508
324
  - embedded query encoder
509
325
  - BM25 hybrid retrieval
510
326
  - content identity and lazy-read integrity verification
@@ -516,18 +332,10 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
516
332
 
517
333
  `npm test` exercises the engine, artifact profiles, MCP, ingestion, format hardening, and related conformance suites.
518
334
 
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.
335
+ 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
336
 
521
337
  ---
522
338
 
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
339
  ## License
532
340
 
533
341
  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
 
@@ -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