pikelet-wasm 0.9.0 → 0.9.2
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 +113 -0
- package/README.md +37 -109
- package/complete/index.d.ts +9 -0
- package/complete/layer-chain.mjs +4 -2
- package/complete/layer-reader.mjs +55 -6
- package/complete/sources.mjs +27 -5
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,119 @@ 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.2 / pikelet 0.9.2 — 2026-10-01
|
|
12
|
+
|
|
13
|
+
Makes the layered update path work as documented: `append` on a freshly
|
|
14
|
+
compiled pack needs no flag, `pikelet mcp --pack` takes the newest layer,
|
|
15
|
+
and `verify_pack` reports honestly on a chain. Both packages move to 0.9.2
|
|
16
|
+
together, and `pikelet` requires `pikelet-wasm` ^0.9.2, whose chain reader
|
|
17
|
+
supplies the integrity fields `verify_pack` reports.
|
|
18
|
+
|
|
19
|
+
### Compatibility
|
|
20
|
+
|
|
21
|
+
- **A pack compiled from the same source gets a different identity than
|
|
22
|
+
under 0.9.1**, because its manifest gains `corpus.ingest`. Pins and
|
|
23
|
+
citations to packs already published are unaffected; only new compiles
|
|
24
|
+
change.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **`compile` records how a pack's records were made, so `append` works on
|
|
29
|
+
it with no flag.** New packs carry `corpus.ingest` (the chunker and its
|
|
30
|
+
parameters). Packs compiled before it still need `--assert-ingest`, and
|
|
31
|
+
`append`'s error now prints the exact JSON to assert. `append` refuses a
|
|
32
|
+
chain whose declaration names a chunker this `pikelet` does not
|
|
33
|
+
implement. The new manifest field means a pack compiled from the same
|
|
34
|
+
source gets a different identity than under 0.9.1.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **`verify_pack` on a chain reports what it actually checked.** It returned
|
|
39
|
+
"no tests" with no encoder, integrity or calibration figures, and a note
|
|
40
|
+
blaming an "older build", so an agent concluded no verdict could be
|
|
41
|
+
trusted. It now runs the base's golden queries and probes against the
|
|
42
|
+
chain, labelled as the base's, skips a golden whose expected record a
|
|
43
|
+
layer removed, and reports the base's calibration figures, the chain's
|
|
44
|
+
calibration status and drift, and each member's integrity. A chain's
|
|
45
|
+
`info()` gains `encoderVerified`, `corpusIntegrity` and
|
|
46
|
+
`indexRowIntegrity`, and per-member integrity under `members`.
|
|
47
|
+
- **`pikelet mcp --pack` accepts the newest layer of an updated pack.** A
|
|
48
|
+
layer passed to `--pack`, or named by a shelf entry without a `lineage`,
|
|
49
|
+
failed to mount with "unsupported profile pikelet-layer-v1". It now mounts
|
|
50
|
+
the chain by walking each layer's parent locator, as LAYERED_PROFILE.md 7
|
|
51
|
+
specifies, locally or over HTTP; `#<sha256>` pins the head. Shelf entries
|
|
52
|
+
with a `lineage` mount as before.
|
|
53
|
+
|
|
54
|
+
### Documentation
|
|
55
|
+
|
|
56
|
+
- **Both READMEs describe layered updates** (`append`, `compact`, `rebase`,
|
|
57
|
+
mounting the newest layer, pins, the drift limit) and citations that
|
|
58
|
+
survive updates. The root README's opening states that a pack fixes how
|
|
59
|
+
its corpus is queried; the long Wikipedia demonstration moved to
|
|
60
|
+
`docs/wikipedia-demonstration.md`, and the abstention signals to
|
|
61
|
+
`docs/how-a-query-runs.md`. Stale claims were corrected: a "retrieval
|
|
62
|
+
agreement" signal that does not exist, MCP returning zero results under
|
|
63
|
+
`none`, and the old `PIKELET_SEARCH_EMBED_WORKERS` name.
|
|
64
|
+
|
|
65
|
+
## pikelet-wasm 0.9.1 / pikelet 0.9.1 — 2026-09-30
|
|
66
|
+
|
|
67
|
+
Fixes to the layered profile shipped in 0.9.0, headlined by a chain that
|
|
68
|
+
lost its abstention verdicts as soon as a layer deleted anything. Both
|
|
69
|
+
packages move to 0.9.1 together, and `pikelet` requires `pikelet-wasm`
|
|
70
|
+
^0.9.1 so that `pikelet mcp` serves chains with the reader fixes.
|
|
71
|
+
|
|
72
|
+
### Compatibility
|
|
73
|
+
|
|
74
|
+
- **Layers from `append` and `rebase` now record a parent locator** when
|
|
75
|
+
the parent is a local file under the output's directory, so the same
|
|
76
|
+
inputs produce a layer with a different identity than under 0.9.0.
|
|
77
|
+
- **`--remove` and `--supersede` refuse ids they used to truncate**
|
|
78
|
+
(`1,2,3`, `12abc`, `1.5`, `1e3`); give one id per flag.
|
|
79
|
+
- **A chain-member location ending in `#` plus hex is read as a pin.** A
|
|
80
|
+
full 64-hex suffix is verified against the member's identity, and a
|
|
81
|
+
shorter one is refused; it is no longer treated as part of a filename.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **`pikelet-wasm` now exports `./package.json`.** `require.resolve('pikelet-wasm/package.json')`
|
|
86
|
+
and JSON imports of it threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which
|
|
87
|
+
tooling that reads the installed version relies on.
|
|
88
|
+
- **`pikelet append` parses `--remove` and `--supersede` ids strictly.**
|
|
89
|
+
`--remove 1,2,3` used to tombstone record 1 alone, and `--remove 12abc`
|
|
90
|
+
record 12, without an error; each flag now takes exactly one decimal id.
|
|
91
|
+
- **`#<sha256>` pins on chain members are enforced.** Every layered
|
|
92
|
+
producer's usage line advertised `--parent <file|url>[#identity]`, but
|
|
93
|
+
nothing parsed the pin: a local path failed as a missing file, and a URL
|
|
94
|
+
dropped the fragment and built on whatever the server returned. `--parent`,
|
|
95
|
+
`--head`, `--onto`, `--old-parent` and `--layer` now verify each pinned
|
|
96
|
+
member's manifest identity before opening anything, and refuse a
|
|
97
|
+
truncated pin.
|
|
98
|
+
- **`append` and `rebase` record a parent locator by default.** They wrote
|
|
99
|
+
one only when given `--parent-locator`, so a rebased layer could not be
|
|
100
|
+
mounted from its head alone and its next rebase needed `--old-parent`.
|
|
101
|
+
When the parent is a local file under the output's directory, its relative
|
|
102
|
+
path is now recorded. An explicit `--parent-locator` is validated up front
|
|
103
|
+
instead of being written as-is and refused by every reader.
|
|
104
|
+
- **A URL chain mounts from its head alone, and locator redirects are
|
|
105
|
+
confined.** Walking up from a URL head failed at the first hop ("a locator
|
|
106
|
+
needs the child's own location") because a range source carried no URL,
|
|
107
|
+
and a parent's URL would then have been read as a file path. The walk now
|
|
108
|
+
resolves each parent over HTTP. A parent named by a publisher's locator is
|
|
109
|
+
confined to the child's origin and directory at every redirect hop, as
|
|
110
|
+
5.1.1 requires; lineage and host-resolver locations are the operator's
|
|
111
|
+
choice and are not confined. `httpRangeSource` gains `url` and a
|
|
112
|
+
`redirectGuard` option.
|
|
113
|
+
- **A tombstone-only layer no longer unscores its chain.** A layer that only
|
|
114
|
+
deletes records carries no vocabulary bloom, and the reader counted that
|
|
115
|
+
as a layer hiding its vocabulary, marking the whole chain
|
|
116
|
+
`drift-exceeded`: on the astro pack, deleting 3 of 3,255 records (drift
|
|
117
|
+
0.0009 against a 0.20 limit) turned every query's verdict to `unscored`.
|
|
118
|
+
The rule now applies only to layers with records, and LAYERED_PROFILE.md
|
|
119
|
+
4.5 and 5.5 say so. Tombstone-only layers already written with 0.9.0
|
|
120
|
+
score again with no rebuild.
|
|
121
|
+
- **`pikelet --help` documents `append`, `compact` and `rebase`**, including
|
|
122
|
+
`--supersede`, `--allow-drift`, `--max-depth` and pins.
|
|
123
|
+
|
|
11
124
|
## pikelet-wasm 0.9.0 / pikelet 0.9.0 — 2026-09-29
|
|
12
125
|
|
|
13
126
|
Everything merged to `main` since 0.8.0 that the 0.8.1 and 0.8.2 point
|
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
|
|
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
|
-
|
|
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. `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- [
|
|
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
|
package/complete/index.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface HttpRangeSourceStats {
|
|
|
34
34
|
|
|
35
35
|
export interface HttpRangeSource extends CompleteRangeSource {
|
|
36
36
|
stats: HttpRangeSourceStats;
|
|
37
|
+
/** The URL as given, before any redirect; a layer's parent locator resolves against it. */
|
|
38
|
+
url: string;
|
|
37
39
|
init(): Promise<void>;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -55,6 +57,13 @@ export declare function httpRangeSource(url: string, options?: {
|
|
|
55
57
|
* disables. CDNs rate-limit the parallel range bursts a query issues.
|
|
56
58
|
*/
|
|
57
59
|
maxRetries?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Called with each redirect target before it is followed; throw to refuse
|
|
62
|
+
* the hop. When set, range reads never auto-follow redirects. A chain walk
|
|
63
|
+
* sets it for a parent named by a layer's locator, confining every hop to
|
|
64
|
+
* the child's origin and directory (LAYERED_PROFILE.md 5.1.1).
|
|
65
|
+
*/
|
|
66
|
+
redirectGuard?: (target: string) => void;
|
|
58
67
|
}): HttpRangeSource;
|
|
59
68
|
|
|
60
69
|
export interface CompleteQueryResult {
|
package/complete/layer-chain.mjs
CHANGED
|
@@ -211,8 +211,10 @@ export function calibrationStatus({ drift, producerEnvelope, readerLimit, baseHa
|
|
|
211
211
|
if (!baseHasFit) {
|
|
212
212
|
return { status: 'none', drift, effectiveLimit, producerEnvelope: envelope, readerLimit };
|
|
213
213
|
}
|
|
214
|
-
// A layer whose calibration region is `none` while the base
|
|
215
|
-
// degrades the chain to unscored (4.5, 5.5).
|
|
214
|
+
// A layer WITH RECORDS whose calibration region is `none` while the base
|
|
215
|
+
// carries a fit degrades the chain to unscored (4.5, 5.5). The caller
|
|
216
|
+
// leaves tombstone-only layers out of everyLayerShipsBloom: they have no
|
|
217
|
+
// vocabulary for a bloom to carry.
|
|
216
218
|
const status = (drift <= effectiveLimit && everyLayerShipsBloom) ? 'inherited' : 'drift-exceeded';
|
|
217
219
|
return { status, drift, effectiveLimit, producerEnvelope: envelope, readerLimit };
|
|
218
220
|
}
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
// named by the caller and a published chain could not be mounted from its head.
|
|
27
27
|
|
|
28
28
|
import { openPikeletFile, readChainMemberShell, readMemberManifest, base64Bytes, LEXICAL_CANDIDATES, LEXICAL_CUTOFF } from './index.mjs';
|
|
29
|
-
import { resolveParentLocation } from './layer-locator.mjs';
|
|
29
|
+
import { resolveParentLocation, assertConfined } from './layer-locator.mjs';
|
|
30
|
+
import { httpRangeSource } from './sources.mjs';
|
|
30
31
|
import { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
|
|
31
32
|
import { parseTombstoneSegment, firstSupersetViolation } from './tombstones.mjs';
|
|
32
33
|
import { validateAgainstParent, chainIngestDeclaration, validateIngestAgainstChain, LAYER_PROFILE, MAX_DEPTH } from './layer-manifest.mjs';
|
|
@@ -83,10 +84,15 @@ async function walkToBase(head, options) {
|
|
|
83
84
|
}
|
|
84
85
|
if (!peek.isLayer) return chain; // reached the base
|
|
85
86
|
if (!peek.parent) throw new Error('a layer manifest carries no layer.parent (3.2)');
|
|
87
|
+
// A child is a URL whether it arrived as a range source or, one hop
|
|
88
|
+
// up, as the URL string a locator resolved to. Classifying every
|
|
89
|
+
// string as a file path sent that second hop to fileSource.
|
|
90
|
+
const childLocation = typeof cursor === 'string' ? cursor : (cursor.url ?? cursor.location ?? null);
|
|
91
|
+
const childIsFile = typeof cursor === 'string' && !isHttpUrl(cursor);
|
|
86
92
|
const located = await resolveParentLocation(peek.parent, {
|
|
87
93
|
lineage,
|
|
88
|
-
childLocation
|
|
89
|
-
kind:
|
|
94
|
+
childLocation,
|
|
95
|
+
kind: childIsFile ? 'file' : 'url',
|
|
90
96
|
resolveParents: options.resolveParents !== false,
|
|
91
97
|
hostResolver: options.hostResolver || null,
|
|
92
98
|
// A file-path locator needs realpath/dirname/join to confine
|
|
@@ -95,15 +101,36 @@ async function walkToBase(head, options) {
|
|
|
95
101
|
// runtime-agnostic, so the Node implementation is supplied here
|
|
96
102
|
// and only when a file path is actually being resolved -- a
|
|
97
103
|
// browser or Worker mount is URL-only and never loads node:fs.
|
|
98
|
-
fsops: options.fsops || (
|
|
104
|
+
fsops: options.fsops || (childIsFile ? await nodeFsops() : null),
|
|
99
105
|
});
|
|
100
106
|
expectIdentity = peek.parent.identity;
|
|
101
107
|
cursor = located.location;
|
|
108
|
+
if (typeof cursor === 'string' && isHttpUrl(cursor)) {
|
|
109
|
+
// A publisher's locator is confined to the child's origin and
|
|
110
|
+
// directory, and so is every redirect on the way to it (5.1.1).
|
|
111
|
+
// A lineage listing or host resolver is the operator's choice and
|
|
112
|
+
// may name any origin, so its redirects are not confined.
|
|
113
|
+
const confineTo = located.via === 'locator' ? childLocation : null;
|
|
114
|
+
cursor = httpRangeSource(cursor, {
|
|
115
|
+
...(confineTo ? { redirectGuard: (target) => assertConfined(target, confineTo) } : {}),
|
|
116
|
+
});
|
|
117
|
+
await cursor.init();
|
|
118
|
+
}
|
|
102
119
|
chain.unshift(cursor);
|
|
103
120
|
}
|
|
104
121
|
throw new Error(`walking to the base exceeded the depth limit ${maxDepth} (2.7): a layer.parent cycle or an over-deep chain`);
|
|
105
122
|
}
|
|
106
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
|
+
|
|
133
|
+
const isHttpUrl = (loc) => /^https?:\/\//i.test(loc);
|
|
107
134
|
const describeLocation = (loc) => (typeof loc === 'string' ? loc : (loc?.url ?? loc?.location ?? '<source>'));
|
|
108
135
|
|
|
109
136
|
// Lazily imported so a URL-only mount in a runtime without node:fs never
|
|
@@ -224,8 +251,13 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
224
251
|
if (inherited.bloomBytes && !baseBloomGeometry) {
|
|
225
252
|
throw new Error(`layer at depth ${rel.depth} ships a vocabulary bloom but the base carries none whose geometry it could match (4.5)`);
|
|
226
253
|
}
|
|
254
|
+
// A missing bloom degrades the chain because it hides a layer's
|
|
255
|
+
// vocabulary from known_frac (4.5). A tombstone-only layer has no
|
|
256
|
+
// records and so no vocabulary to hide: its `none` region is the
|
|
257
|
+
// empty bloom, not a gap. Counting it took a chain that deleted 3
|
|
258
|
+
// of 3,255 records to drift-exceeded, unscoring every query.
|
|
227
259
|
if (inherited.bloomBytes) layerBlooms.push(inherited.bloomBytes);
|
|
228
|
-
else everyLayerShipsBloom = false;
|
|
260
|
+
else if (!rel.tombstoneOnly) everyLayerShipsBloom = false;
|
|
229
261
|
|
|
230
262
|
if (!rel.tombstoneOnly && tier.segments.has('lexical') !== baseHasLexical) {
|
|
231
263
|
throw new Error(baseHasLexical
|
|
@@ -347,7 +379,24 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
347
379
|
// to that member. A chain surfaces the base's, which are
|
|
348
380
|
// the ones fit against the corpus the encoder saw.
|
|
349
381
|
sampleQueries: Array.isArray(base.manifest.sampleQueries) ? base.manifest.sampleQueries : [],
|
|
350
|
-
|
|
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),
|
|
351
400
|
};
|
|
352
401
|
},
|
|
353
402
|
|
package/complete/sources.mjs
CHANGED
|
@@ -22,6 +22,13 @@ export function httpRangeSource(url, options = {}) {
|
|
|
22
22
|
: (options.cacheKeyParam === null || options.cacheKeyParam === false) ? null
|
|
23
23
|
: String(options.cacheKeyParam);
|
|
24
24
|
let full = null;
|
|
25
|
+
// A parent reached through a publisher-supplied locator is confined to the
|
|
26
|
+
// child's origin and directory, and 5.1.1 applies that rule to EVERY
|
|
27
|
+
// redirect hop before it is followed. The guard throws to refuse a hop.
|
|
28
|
+
// With a guard set, reads never auto-follow: only the HEAD walk below,
|
|
29
|
+
// which checks each hop, may move the target.
|
|
30
|
+
const redirectGuard = typeof options.redirectGuard === 'function' ? options.redirectGuard : null;
|
|
31
|
+
const readInit = (headers) => (redirectGuard ? { headers, redirect: 'error' } : { headers });
|
|
25
32
|
|
|
26
33
|
// Redirect memoization. A host that answers with a redirect (GitHub
|
|
27
34
|
// release assets: github.com 302s every hit to a signed CDN URL) would
|
|
@@ -46,7 +53,13 @@ export function httpRangeSource(url, options = {}) {
|
|
|
46
53
|
const probe = await fetch(target, { method: 'HEAD', redirect: 'manual' });
|
|
47
54
|
const location = probe.headers.get('location');
|
|
48
55
|
if (probe.status >= 300 && probe.status < 400 && location) {
|
|
49
|
-
|
|
56
|
+
const next = new URL(location, target).href;
|
|
57
|
+
if (redirectGuard) {
|
|
58
|
+
try { redirectGuard(next); } catch (err) {
|
|
59
|
+
throw Object.assign(new Error(`refused redirect to ${next}: ${err.message}`), { redirectRefused: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
target = next;
|
|
50
63
|
viaRedirect = true;
|
|
51
64
|
stats.redirects += 1;
|
|
52
65
|
continue;
|
|
@@ -64,7 +77,13 @@ export function httpRangeSource(url, options = {}) {
|
|
|
64
77
|
}
|
|
65
78
|
break;
|
|
66
79
|
}
|
|
67
|
-
} catch {
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// A refused hop is a verdict, not a probe failure: falling back
|
|
82
|
+
// to the original URL would retry the same redirect.
|
|
83
|
+
if (redirectGuard && err?.redirectRefused) {
|
|
84
|
+
resolving = null;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
68
87
|
// HEAD unsupported or blocked: fall back to auto-follow.
|
|
69
88
|
target = url;
|
|
70
89
|
viaRedirect = false;
|
|
@@ -78,6 +97,9 @@ export function httpRangeSource(url, options = {}) {
|
|
|
78
97
|
|
|
79
98
|
return {
|
|
80
99
|
stats,
|
|
100
|
+
// The location as given, before any redirect: what a child's parent
|
|
101
|
+
// locator resolves against (5.1.1).
|
|
102
|
+
url,
|
|
81
103
|
size: undefined,
|
|
82
104
|
preferredParallelism: options.preferredParallelism ?? 32,
|
|
83
105
|
preferredGapBytes: options.preferredGapBytes ?? 16 * 1024,
|
|
@@ -108,18 +130,18 @@ export function httpRangeSource(url, options = {}) {
|
|
|
108
130
|
const targetFor = ({ target, viaRedirect }) => (cacheKeyParam && !viaRedirect
|
|
109
131
|
? `${target}${target.includes('?') ? '&' : '?'}${cacheKeyParam}=${offset}-${end}`
|
|
110
132
|
: target);
|
|
111
|
-
let response = await fetch(targetFor(pinned),
|
|
133
|
+
let response = await fetch(targetFor(pinned), readInit(headers));
|
|
112
134
|
if ((response.status === 401 || response.status === 403) && pinned.viaRedirect) {
|
|
113
135
|
// The pinned signed URL expired; resolve a fresh one once
|
|
114
136
|
// and retry. Concurrent reads share the re-resolution.
|
|
115
137
|
if (resolvedTarget === pinned) resolvedTarget = null;
|
|
116
138
|
stats.retries += 1;
|
|
117
|
-
response = await fetch(targetFor(await resolveTarget()),
|
|
139
|
+
response = await fetch(targetFor(await resolveTarget()), readInit(headers));
|
|
118
140
|
}
|
|
119
141
|
for (let attempt = 0; RETRYABLE.has(response.status) && attempt < maxRetries; attempt++) {
|
|
120
142
|
stats.retries += 1;
|
|
121
143
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt, response)));
|
|
122
|
-
response = await fetch(targetFor(resolvedTarget || pinned),
|
|
144
|
+
response = await fetch(targetFor(resolvedTarget || pinned), readInit(headers));
|
|
123
145
|
}
|
|
124
146
|
|
|
125
147
|
if (response.status === 206) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pikelet-wasm",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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",
|
|
@@ -101,7 +101,8 @@
|
|
|
101
101
|
"./complete/layer-compact.mjs": {
|
|
102
102
|
"default": "./complete/layer-compact.mjs"
|
|
103
103
|
},
|
|
104
|
-
"./engine.wasm": "./dist/engine.wasm"
|
|
104
|
+
"./engine.wasm": "./dist/engine.wasm",
|
|
105
|
+
"./package.json": "./package.json"
|
|
105
106
|
},
|
|
106
107
|
"files": [
|
|
107
108
|
"src",
|