pikelet-wasm 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,82 @@ 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.14.0 / pikelet 0.14.0 — 2026-11-04
12
+
13
+ Asking a URL pack a question an earlier process already asked is now
14
+ mostly local too: `pikelet mcp` keeps the verified rows, records and digest
15
+ pages a query reads in its disk cache, so on the Wikipedia pack over R2 a
16
+ repeated question transferred 178 KiB instead of 1.3 MiB and answered in
17
+ 0.65 s instead of 1.45 s. The cache is now private to its owner (0.13.0
18
+ left it readable by other local users under the usual umask), and two
19
+ processes sharing it no longer mis-index each other's writes. Upgrade if
20
+ you mount packs by URL, and especially if other people have accounts on
21
+ the machine. Packs compiled before this release still verify and serve,
22
+ and compiling the same inputs gives the same identities as 0.13.0. Both
23
+ packages move to 0.14.0 together, and `pikelet` requires `pikelet-wasm`
24
+ ^0.14.0.
25
+
26
+ ### Compatibility
27
+
28
+ - **The cache also holds query reads.** On top of the mount entries (up
29
+ to 2 GiB), URL packs now store what queries read in append-only logs
30
+ under `~/.cache/pikelet/blobs/small`, up to 64 MiB. `--no-cache` still
31
+ turns all of it off, and local packs still never use it.
32
+ - **The cache root becomes 0700.** The first write tightens the store's
33
+ directory to owner-only, including a `$PIKELET_CACHE_DIR/blobs` or a
34
+ directory passed to `diskBlobCache(dir)` that was opened up on purpose.
35
+ Nothing above that directory is touched.
36
+ - **Library callers: close a `diskBlobCache()` you are done with.** A store
37
+ that has served a query entry holds read handles on its log until
38
+ `close()`. Node 26 treats a file handle left to garbage collection as a
39
+ fatal error, so a store dropped without `close()` can end the process
40
+ there. The CLI keeps its store for the life of the process and is not
41
+ affected.
42
+
43
+ ### Added
44
+
45
+ - **Queries use the disk cache too.** With `blobCache`, the verified pieces
46
+ a query reads are stored and served by digest: rerank vector rows (by
47
+ their row digests; format 2), corpus records (by their record digests),
48
+ and the digest pages that verify both (by their page-table hashes). Every
49
+ hit is hash-checked against its key, a truncated row digest of at least
50
+ 128 bits included, and only reads verified on their own use the cache:
51
+ the lazy lexical index never does. `diskBlobCache()` keeps entries under
52
+ 64 KiB in append-only log generations (64 MiB in total by default,
53
+ `smallMaxBytes`; the oldest generation dropped whole, hits rewritten to
54
+ the newest) instead of one file each, and gains `close()`. On the
55
+ Wikipedia pack over R2, asking a question an earlier process already
56
+ asked transferred 178 KiB in 11 requests instead of 1.3 MiB in 29, and
57
+ answered in 0.65 s instead of 1.45 s; a new question costs what it did.
58
+
59
+ ### Fixed
60
+
61
+ - **Generated configs point at a schema that exists.** The configs the
62
+ CLI generates carried a `$schema` URL from before the repository moved
63
+ to `packages/`, which returned 404; it and the schema's own `$id` now name
64
+ `packages/pikelet/schemas/v1/pikelet.config.schema.json`. Configs
65
+ generated earlier keep the old URL until it is edited.
66
+ - **Two processes sharing a disk cache no longer mis-index each other's
67
+ appends.** A store computed a log batch's offset as the file size after
68
+ its append, so another process appending in between left its index
69
+ pointing at the wrong record: about a third of each writer's own
70
+ lookups across three processes. Every hit is hash-checked, so this cost
71
+ refetches, never a wrong byte, and a fresh process re-indexed the log
72
+ correctly. A batch is now read back at its offset before it is indexed,
73
+ and if the end moved, the generation is rescanned to find where it
74
+ landed.
75
+
76
+ ### Security
77
+
78
+ - **The disk cache is private to its owner.** `diskBlobCache()` stored
79
+ entries with the process umask, so under the usual 022 its directories
80
+ were 0755 and its files 0644: readable by other local users wherever
81
+ the parent directories allowed it. Entries hold record text, which for a
82
+ pack of a private repository, a journal, or conversation history is the
83
+ source itself. Directories are now created 0700 and files 0600, and the
84
+ first write tightens the store's root to 0700, which also closes off
85
+ entries a 0.13.0 cache already wrote.
86
+
11
87
  ## pikelet-wasm 0.13.0 / pikelet 0.13.0 — 2026-11-01
12
88
 
13
89
  Mounting a pack over HTTP is much cheaper the second time: `pikelet mcp`
@@ -1,23 +1,46 @@
1
- // A disk store for openPikeletFile's options.blobCache: whole regions a pack
2
- // commits to by sha256 (the sketch's resident prefix, the query-interp
3
- // segment holding the encoder), filed under that digest. Those regions are
4
- // most of what a URL mount downloads (about 71 of 77 MiB on the 649 MiB
5
- // Simple English Wikipedia pack), so a second process mounting the same pack
6
- // reads them from disk instead of the network.
1
+ // A disk store for openPikeletFile's options.blobCache: bytes a pack commits
2
+ // to by sha256, filed under that digest. Two kinds of entry reach it:
3
+ //
4
+ // - whole regions read at mount (the sketch's resident prefix, the
5
+ // query-interp segment holding the encoder): most of what a URL mount
6
+ // downloads, about 71 of 77 MiB on the 649 MiB Simple English Wikipedia
7
+ // pack. One file each.
8
+ // - pieces read per query (rerank vector rows, corpus records, and the
9
+ // digest pages that verify them): a few hundred bytes to a few KiB, and
10
+ // hundreds per query. One file each would be a poor fit, so entries under
11
+ // SMALL_ENTRY_BYTES go into append-only log files (generations) with an
12
+ // in-memory index, and whole generations rotate out under their own
13
+ // budget.
7
14
  //
8
15
  // The store is untrusted by design: the reader hashes every hit against the
9
16
  // digest it asked for and treats a mismatch as a miss (blobCacheGet in
10
- // pikelet-artifact-common.js), so a torn write or a corrupted file costs one
11
- // refetch, never a wrong byte. Entries are immutable and never go stale; the
12
- // only policy is a size budget, enforced by evicting least recently used
13
- // entries (by mtime, touched on every hit) after each write.
17
+ // pikelet-artifact-common.js), so a torn write, a corrupted file, or two
18
+ // processes appending to one log at once costs a refetch, never a wrong byte.
19
+ // Entries are immutable and never go stale; the only policy is size.
14
20
 
15
21
  import fs from 'node:fs/promises';
16
22
  import os from 'node:os';
17
23
  import path from 'node:path';
18
24
 
19
- const KEY = /^[0-9a-f]{64}$/;
25
+ // A full sha256, or a truncated one of at least 128 bits (sketch row digests
26
+ // may be 16-32 bytes).
27
+ const KEY = /^(?:[0-9a-f]{2}){16,32}$/;
20
28
  export const DEFAULT_BLOB_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
29
+ export const DEFAULT_SMALL_CACHE_BYTES = 64 * 1024 * 1024;
30
+ // Entries below this go to the log; at or above it, a file of their own.
31
+ export const SMALL_ENTRY_BYTES = 64 * 1024;
32
+ // The log keeps this many generations; the oldest is deleted whole when the
33
+ // newest fills (smallMaxBytes / GENERATIONS each).
34
+ const GENERATIONS = 4;
35
+ // Log record: magic 'PKB1' (u32 LE), key length (u8), key bytes, data length
36
+ // (u32 LE), data. A scan stops at the first record that does not parse.
37
+ const RECORD_MAGIC = 0x3142_4b50;
38
+ const GEN_FILE = /^gen-(\d{8})\.log$/;
39
+ // Entries are pack content, record text included, and a pack can be a
40
+ // private repository or a conversation history: the store is private to its
41
+ // owner whatever the umask.
42
+ const DIR_MODE = 0o700;
43
+ const FILE_MODE = 0o600;
21
44
 
22
45
  // $PIKELET_CACHE_DIR, else $XDG_CACHE_HOME/pikelet/blobs, else
23
46
  // ~/.cache/pikelet/blobs: the same root the CLI already caches encoder
@@ -28,15 +51,28 @@ export function defaultBlobCacheDir(env = process.env) {
28
51
  return path.join(base, 'pikelet', 'blobs');
29
52
  }
30
53
 
31
- export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_BLOB_CACHE_BYTES } = {}) {
32
- if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
33
- throw new Error('diskBlobCache maxBytes must be a non-negative integer');
54
+ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_BLOB_CACHE_BYTES, smallMaxBytes = DEFAULT_SMALL_CACHE_BYTES } = {}) {
55
+ for (const [label, value] of [['maxBytes', maxBytes], ['smallMaxBytes', smallMaxBytes]]) {
56
+ if (!Number.isSafeInteger(value) || value < 0) {
57
+ throw new Error(`diskBlobCache ${label} must be a non-negative integer`);
58
+ }
34
59
  }
35
60
  const root = path.resolve(dir);
36
- const fileOf = (key) => {
37
- if (!KEY.test(key)) throw new Error(`blob cache key must be a lowercase sha256 hex digest, got ${JSON.stringify(key)}`);
38
- return path.join(root, key.slice(0, 2), key);
61
+ const checkKey = (key) => {
62
+ if (typeof key !== 'string' || !KEY.test(key)) {
63
+ throw new Error(`blob cache key must be a lowercase sha256 hex digest (32-64 hex digits), got ${JSON.stringify(key)}`);
64
+ }
65
+ return key;
39
66
  };
67
+ const fileOf = (key) => path.join(root, key.slice(0, 2), checkKey(key));
68
+ // Once per instance, before the first write: a root made by an older
69
+ // store (or by mkdir -p under umask 022) is tightened, which also closes
70
+ // off whatever it already holds. Best effort; on Windows modes are moot.
71
+ let privateRoot = null;
72
+ const makeRootPrivate = () => (privateRoot ??= (async () => {
73
+ await fs.mkdir(root, { recursive: true, mode: DIR_MODE });
74
+ await fs.chmod(root, DIR_MODE).catch(() => {});
75
+ })());
40
76
  const pending = new Set();
41
77
  const track = (promise) => {
42
78
  pending.add(promise);
@@ -48,6 +84,7 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
48
84
  const entries = [];
49
85
  let total = 0;
50
86
  for (const shard of await fs.readdir(root).catch(() => [])) {
87
+ if (shard === 'small') continue;
51
88
  const shardDir = path.join(root, shard);
52
89
  for (const name of await fs.readdir(shardDir).catch(() => [])) {
53
90
  if (!KEY.test(name)) continue;
@@ -67,9 +104,178 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
67
104
  }
68
105
  };
69
106
 
107
+ // --- the small-entry log -------------------------------------------------
108
+ const smallDir = path.join(root, 'small');
109
+ const genBytes = Math.max(1, Math.floor(smallMaxBytes / GENERATIONS));
110
+ const genPath = (gen) => path.join(smallDir, `gen-${String(gen).padStart(8, '0')}.log`);
111
+ const index = new Map(); // key -> { gen, offset, length }
112
+ const handles = new Map(); // gen -> Promise<FileHandle> (read)
113
+ let current = 0;
114
+ // Set when the newest generation ends in bytes that do not parse (a torn
115
+ // write): a scan stops there, so anything appended after it would never
116
+ // be indexed again. Writing resumes in a fresh generation instead.
117
+ let sealed = false;
118
+ let queue = [];
119
+ let flushing = null;
120
+
121
+ const listGens = async () => (await fs.readdir(smallDir).catch(() => []))
122
+ .map((name) => GEN_FILE.exec(name)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => a - b);
123
+
124
+ const forgetGen = async (gen) => {
125
+ for (const [key, at] of index) if (at.gen === gen) index.delete(key);
126
+ const handle = handles.get(gen);
127
+ handles.delete(gen);
128
+ if (handle) await handle.then((h) => h.close(), () => {});
129
+ };
130
+
131
+ const scan = (gen, bytes) => {
132
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
133
+ let at = 0;
134
+ while (at + 9 <= bytes.length && view.getUint32(at, true) === RECORD_MAGIC) {
135
+ const keyLen = bytes[at + 4];
136
+ if (keyLen < 16 || keyLen > 32 || at + 5 + keyLen + 4 > bytes.length) break;
137
+ const key = Buffer.from(bytes.subarray(at + 5, at + 5 + keyLen)).toString('hex');
138
+ const length = view.getUint32(at + 5 + keyLen, true);
139
+ const offset = at + 9 + keyLen;
140
+ if (offset + length > bytes.length) break;
141
+ index.set(key, { gen, offset, length });
142
+ at = offset + length;
143
+ }
144
+ return at === bytes.length;
145
+ };
146
+
147
+ // Built once, on first use: every generation still on disk is read and
148
+ // indexed (at most smallMaxBytes), later generations overriding earlier.
149
+ let ready = null;
150
+ const smallReady = () => (ready ??= (async () => {
151
+ if (smallMaxBytes === 0) return;
152
+ const gens = await listGens();
153
+ for (const gen of gens.slice(0, Math.max(0, gens.length - GENERATIONS))) {
154
+ await fs.rm(genPath(gen), { force: true });
155
+ }
156
+ for (const gen of gens.slice(-GENERATIONS)) {
157
+ const bytes = await fs.readFile(genPath(gen)).catch(() => null);
158
+ const whole = bytes ? scan(gen, bytes) : true;
159
+ if (gen === gens[gens.length - 1]) sealed = !whole;
160
+ }
161
+ current = gens.length ? gens[gens.length - 1] : 0;
162
+ })());
163
+
164
+ const readSmall = async (key) => {
165
+ const at = index.get(key);
166
+ if (!at) return null;
167
+ let handle = handles.get(at.gen);
168
+ if (!handle) {
169
+ handle = fs.open(genPath(at.gen), 'r');
170
+ handles.set(at.gen, handle);
171
+ handle.catch(() => { if (handles.get(at.gen) === handle) handles.delete(at.gen); });
172
+ }
173
+ let fh;
174
+ try { fh = await handle; } catch (err) {
175
+ // Another process rotated this generation away.
176
+ if (err?.code === 'ENOENT') { await forgetGen(at.gen); return null; }
177
+ throw err;
178
+ }
179
+ const out = new Uint8Array(at.length);
180
+ const { bytesRead } = await fh.read(out, 0, at.length, at.offset);
181
+ if (bytesRead !== at.length) { index.delete(key); return null; }
182
+ // A hit from an older generation is written again to the newest, so
183
+ // rotation drops what has not been read lately: LRU by generation.
184
+ if (at.gen !== current) enqueue(key, out);
185
+ return out;
186
+ };
187
+
188
+ const rotate = async () => {
189
+ await makeRootPrivate();
190
+ await fs.mkdir(smallDir, { recursive: true, mode: DIR_MODE });
191
+ const gens = await listGens();
192
+ current = Math.max(current, gens.length ? gens[gens.length - 1] : 0) + 1;
193
+ // Exclusive create; another process that rotated first owns the
194
+ // name, and appending after its records is fine.
195
+ await fs.writeFile(genPath(current), new Uint8Array(0), { flag: 'wx', mode: FILE_MODE }).catch((err) => {
196
+ if (err?.code !== 'EEXIST') throw err;
197
+ });
198
+ for (const gen of (await listGens()).filter((g) => g <= current - GENERATIONS)) {
199
+ await forgetGen(gen);
200
+ await fs.rm(genPath(gen), { force: true });
201
+ }
202
+ };
203
+
204
+ const flushSmall = async () => {
205
+ while (queue.length) {
206
+ const batch = queue;
207
+ queue = [];
208
+ const total = batch.reduce((n, r) => n + r.record.length, 0);
209
+ if (!current || sealed) {
210
+ await rotate();
211
+ sealed = false;
212
+ } else {
213
+ const st = await fs.stat(genPath(current)).catch(() => null);
214
+ if (!st || st.size + total > genBytes) await rotate();
215
+ }
216
+ const buf = Buffer.concat(batch.map((r) => r.record));
217
+ const fh = await fs.open(genPath(current), 'a+', FILE_MODE);
218
+ let base;
219
+ try {
220
+ await fh.write(buf);
221
+ // O_APPEND puts the batch at the end; its offset is the size
222
+ // now minus its length, unless another process appended
223
+ // between the write and the stat. So the batch is read back
224
+ // there before it is indexed: records are content-addressed,
225
+ // so wherever these exact bytes sit is a right offset.
226
+ base = (await fh.stat()).size - buf.length;
227
+ if (base >= 0) {
228
+ const back = Buffer.alloc(buf.length);
229
+ const { bytesRead } = await fh.read(back, 0, buf.length, base);
230
+ if (bytesRead !== buf.length || !back.equals(buf)) base = -1;
231
+ }
232
+ } finally {
233
+ await fh.close();
234
+ }
235
+ if (base < 0) {
236
+ // The end moved: find where the batch landed by scanning the
237
+ // generation, which indexes the other writer's records too.
238
+ // A record still being written stops the scan; whatever lies
239
+ // past it is a miss, not a wrong offset.
240
+ const bytes = await fs.readFile(genPath(current)).catch(() => null);
241
+ if (bytes) scan(current, bytes);
242
+ continue;
243
+ }
244
+ let at = base;
245
+ for (const r of batch) {
246
+ index.set(r.key, { gen: current, offset: at + r.headerBytes, length: r.length });
247
+ at += r.record.length;
248
+ }
249
+ }
250
+ };
251
+
252
+ const enqueue = (key, bytes) => {
253
+ if (index.has(key) && index.get(key).gen === current) return;
254
+ const raw = Buffer.from(key, 'hex');
255
+ const headerBytes = 9 + raw.length;
256
+ const record = Buffer.alloc(headerBytes + bytes.byteLength);
257
+ record.writeUInt32LE(RECORD_MAGIC, 0);
258
+ record[4] = raw.length;
259
+ raw.copy(record, 5);
260
+ record.writeUInt32LE(bytes.byteLength, 5 + raw.length);
261
+ record.set(bytes, headerBytes);
262
+ queue.push({ key, record, headerBytes, length: bytes.byteLength });
263
+ // One append per burst: a query stores hundreds of rows at once.
264
+ flushing ??= track(new Promise((resolve) => setImmediate(resolve))
265
+ .then(() => flushSmall())
266
+ .finally(() => { flushing = null; }));
267
+ return flushing;
268
+ };
269
+
70
270
  return {
71
271
  dir: root,
72
272
  async get(key) {
273
+ checkKey(key);
274
+ if (smallMaxBytes > 0) {
275
+ await smallReady();
276
+ const small = await readSmall(key);
277
+ if (small) return small;
278
+ }
73
279
  const file = fileOf(key);
74
280
  let bytes;
75
281
  try { bytes = await fs.readFile(file); } catch (err) {
@@ -82,15 +288,21 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
82
288
  return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
83
289
  },
84
290
  put(key, bytes) {
291
+ checkKey(key);
292
+ if (bytes.byteLength < SMALL_ENTRY_BYTES) {
293
+ if (smallMaxBytes === 0 || bytes.byteLength > genBytes) return Promise.resolve();
294
+ return track(smallReady().then(() => enqueue(key, bytes)));
295
+ }
85
296
  const file = fileOf(key);
86
297
  return track((async () => {
87
298
  if (bytes.byteLength > maxBytes) return;
88
- await fs.mkdir(path.dirname(file), { recursive: true });
299
+ await makeRootPrivate();
300
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: DIR_MODE });
89
301
  // Write-then-rename, so a reader in another process sees
90
302
  // either no entry or a whole one.
91
303
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
92
304
  try {
93
- await fs.writeFile(tmp, bytes);
305
+ await fs.writeFile(tmp, bytes, { mode: FILE_MODE });
94
306
  await fs.rename(tmp, file);
95
307
  } catch (err) {
96
308
  await fs.rm(tmp, { force: true });
@@ -100,6 +312,10 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
100
312
  })());
101
313
  },
102
314
  async delete(key) {
315
+ checkKey(key);
316
+ // A log entry cannot be removed in place; forgetting it is enough,
317
+ // and rotation reclaims the bytes.
318
+ index.delete(key);
103
319
  await fs.rm(fileOf(key), { force: true });
104
320
  },
105
321
  // Settles every write started so far: for tests, and for a process
@@ -107,5 +323,15 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
107
323
  async flush() {
108
324
  while (pending.size) await Promise.allSettled([...pending]);
109
325
  },
326
+ // Closes the log's read handles (they otherwise live as long as the
327
+ // process). The store stays usable; handles reopen on demand.
328
+ async close() {
329
+ await this.flush();
330
+ for (const gen of [...handles.keys()]) {
331
+ const handle = handles.get(gen);
332
+ handles.delete(gen);
333
+ await handle.then((h) => h.close(), () => {});
334
+ }
335
+ },
110
336
  };
111
337
  }
@@ -96,7 +96,7 @@ export declare function nodeFetch(input: string, init?: {
96
96
  inflateGzip?: boolean;
97
97
  }): Promise<any>;
98
98
 
99
- /** openPikeletFile's options.blobCache: bytes keyed by their sha256 (lowercase hex). */
99
+ /** openPikeletFile's options.blobCache: bytes keyed by their sha256 (lowercase hex, 32-64 digits). */
100
100
  export interface BlobCache {
101
101
  get(key: string): Promise<Uint8Array | null> | Uint8Array | null;
102
102
  put(key: string, bytes: Uint8Array): Promise<void> | void;
@@ -104,17 +104,23 @@ export interface BlobCache {
104
104
  }
105
105
 
106
106
  /**
107
- * Node only (the 'node' export condition): a BlobCache on disk, one file per
108
- * key under `dir` (default defaultBlobCacheDir()), evicting least recently
109
- * used entries past maxBytes (default 2 GiB). flush() settles pending writes.
107
+ * Node only (the 'node' export condition): a BlobCache on disk under `dir`
108
+ * (default defaultBlobCacheDir()). Entries of 64 KiB or more get a file each,
109
+ * evicting least recently used past maxBytes (default 2 GiB). Smaller ones
110
+ * (per-query rows, records, digest pages) go to append-only log generations
111
+ * capped at smallMaxBytes in total (default 64 MiB; 0 disables them), the
112
+ * oldest generation dropped whole. flush() settles pending writes; close()
113
+ * releases the log's file handles.
110
114
  */
111
- export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number }): BlobCache & {
115
+ export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number; smallMaxBytes?: number }): BlobCache & {
112
116
  dir: string;
113
117
  flush(): Promise<void>;
118
+ close(): Promise<void>;
114
119
  };
115
120
  /** $PIKELET_CACHE_DIR/blobs, else $XDG_CACHE_HOME/pikelet/blobs, else ~/.cache/pikelet/blobs. */
116
121
  export declare function defaultBlobCacheDir(env?: Record<string, string | undefined>): string;
117
122
  export declare const DEFAULT_BLOB_CACHE_BYTES: number;
123
+ export declare const DEFAULT_SMALL_CACHE_BYTES: number;
118
124
 
119
125
  export interface CompleteQueryResult {
120
126
  /**
@@ -308,14 +314,18 @@ export declare function openPikeletFile(
308
314
  */
309
315
  prefetchEncoder?: boolean;
310
316
  /**
311
- * A store for the regions a pack commits to by sha256 that dominate a
312
- * cold URL mount: the sketch's resident prefix and the query-interp
313
- * segment holding the inline encoder. Keys are those digests (lowercase
314
- * hex), so entries never go stale. Every hit is hashed against its key
315
- * before use and a mismatch is treated as a miss (and deleted), so the
316
- * store need not be trusted; get/put/delete faults never fail an open.
317
- * diskBlobCache() (Node) is a ready implementation. Ignored by an open
318
- * whose resident verification is off.
317
+ * A store for bytes a pack commits to by sha256. At mount: the sketch's
318
+ * resident prefix and the query-interp segment holding the inline
319
+ * encoder, which dominate a cold URL mount. Per query: rerank vector
320
+ * rows (format 2), corpus records, and the digest pages that verify
321
+ * both. Keys are those digests in lowercase hex (a row's digest may be
322
+ * truncated to 16-31 bytes), so entries never go stale. Every hit is
323
+ * hashed and must match its key (a prefix, for a truncated key); a
324
+ * mismatch is treated as a miss and deleted, so the store need not be
325
+ * trusted, and get/put/delete faults never fail a read. Only reads that
326
+ * are verified on their own use it: nothing when verification is off,
327
+ * and never the lazy lexical index. diskBlobCache() (Node) is a ready
328
+ * implementation.
319
329
  */
320
330
  blobCache?: BlobCache;
321
331
  /**
@@ -97,9 +97,9 @@ const COVERAGE_BATCH_COUNT = 5;
97
97
  // Only BM25 hits within this fraction of the top lexical score join fusion.
98
98
  // Common query terms give every matching document a near-tied score (idf
99
99
  // collapses them flat); a wide cutoff let that tied mass ride into fusion on
100
- // a technicality. Kept identical to pikelet/src/calibrate.mjs's
101
- // LEXICAL_CUTOFF so calibration is fit against the same fusion the reader
102
- // actually serves.
100
+ // a technicality. Calibration (pikelet/src/calibrate.mjs) gathers its
101
+ // evidence through this reader's own retrieval, so it is fit against the
102
+ // same cutoff and fusion the reader serves.
103
103
  export const LEXICAL_CUTOFF = 1.5;
104
104
  // Query-interpretation segments above this size open lazily (kind 3's
105
105
  // inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
@@ -1200,12 +1200,16 @@ export async function openPikeletFile(input, options = {}) {
1200
1200
  const pending = (async () => {
1201
1201
  const from = Math.min(recordCount, page * pageRecords);
1202
1202
  const to = Math.min(recordCount, (page + 1) * pageRecords);
1203
+ const key = toHex(pageTable.subarray(32 * page, 32 * page + 32));
1204
+ const cached = await blobCacheGet(options.blobCache, key, 32 * (to - from));
1205
+ if (cached) return cached;
1203
1206
  const bytes = await readChecked(source, corpus.offset + recordDigestsAt + 32 * from, 32 * (to - from),
1204
1207
  `corpus digest page ${page}`, maxReadBytes, fileBytes);
1205
- if (await sha256hex(bytes) !== toHex(pageTable.subarray(32 * page, 32 * page + 32))) {
1208
+ if (await sha256hex(bytes) !== key) {
1206
1209
  digestPages.delete(page);
1207
1210
  throw new Error(`.pikelet corpus digest page ${page} failed hash verification`);
1208
1211
  }
1212
+ blobCachePut(options.blobCache, key, bytes);
1209
1213
  return bytes;
1210
1214
  })();
1211
1215
  digestPages.set(page, pending);
@@ -1234,14 +1238,29 @@ export async function openPikeletFile(input, options = {}) {
1234
1238
  }
1235
1239
  const start = recordOffsets[id];
1236
1240
  const end = recordOffsets[id + 1];
1237
- const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
1238
- if (perRecord && verifyRecords) {
1241
+ // With options.blobCache a verified record is keyed by its own
1242
+ // digest, so the digest comes first; another process may already
1243
+ // have fetched the record. Only verified reads use the cache.
1244
+ const cacheable = perRecord && verifyRecords && options.blobCache;
1245
+ let digest = null;
1246
+ if (cacheable) {
1239
1247
  const page = Math.floor(id / pageRecords);
1240
- const digests = await pageDigests(page);
1241
1248
  const at = 32 * (id - page * pageRecords);
1242
- if (await sha256hex(bytes) !== toHex(digests.subarray(at, at + 32))) {
1249
+ digest = toHex((await pageDigests(page)).subarray(at, at + 32));
1250
+ const cached = await blobCacheGet(options.blobCache, digest, end - start);
1251
+ if (cached) return cached;
1252
+ }
1253
+ const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
1254
+ if (perRecord && verifyRecords) {
1255
+ if (!digest) {
1256
+ const page = Math.floor(id / pageRecords);
1257
+ const at = 32 * (id - page * pageRecords);
1258
+ digest = toHex((await pageDigests(page)).subarray(at, at + 32));
1259
+ }
1260
+ if (await sha256hex(bytes) !== digest) {
1243
1261
  throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
1244
1262
  }
1263
+ blobCachePut(options.blobCache, digest, bytes);
1245
1264
  }
1246
1265
  return bytes;
1247
1266
  };
@@ -9,7 +9,7 @@ import { nodeFetch } from './node-fetch.mjs';
9
9
 
10
10
  export * from './index.mjs';
11
11
  export { nodeFetch };
12
- export { diskBlobCache, defaultBlobCacheDir, DEFAULT_BLOB_CACHE_BYTES } from './blob-cache.node.mjs';
12
+ export { diskBlobCache, defaultBlobCacheDir, DEFAULT_BLOB_CACHE_BYTES, DEFAULT_SMALL_CACHE_BYTES } from './blob-cache.node.mjs';
13
13
 
14
14
  export function httpRangeSource(url, options = {}) {
15
15
  return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikelet-wasm",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Vector search engine and .pikelet artifact readers/builders, in WebAssembly. Compile a corpus into one self-contained, hash-verified search file, or use the underlying HNSW engine directly. Runs in Node.js, browsers, and Cloudflare Workers with no native dependencies.",
5
5
  "main": "src/pikelet.js",
6
6
  "types": "src/pikelet.d.ts",
@@ -462,25 +462,31 @@ async function sha256BytesAsync(bytes) {
462
462
  }
463
463
  }
464
464
 
465
- // Read-through cache for whole regions that carry a sha256 commitment (the
466
- // sketch's resident prefix, a pack's query-interp segment), keyed by that
467
- // digest. A hit is hashed exactly as a network read would be, so the cache
468
- // can only ever hand back the committed bytes: anything else it holds (a
469
- // partial write, disk corruption, a bug elsewhere) reads as a miss and the
470
- // entry is dropped. Because the key is the content, entries never go stale
471
- // and need no invalidation. Cache faults never fail a read.
465
+ // Read-through cache for bytes a pack commits to by sha256: the whole
466
+ // regions a mount reads (the sketch's resident prefix, a pack's query-interp
467
+ // segment) and the pieces a query reads (rerank rows, corpus records, the
468
+ // digest pages that verify them), keyed by that digest. A key may be a
469
+ // truncated digest of at least 128 bits (sketch row digests are 16-32
470
+ // bytes), and a hit must hash to a digest that starts with it. A hit is
471
+ // hashed exactly as a network read would be, so the cache can only ever hand
472
+ // back the committed bytes: anything else it holds (a partial write, disk
473
+ // corruption, a bug elsewhere) reads as a miss and the entry is dropped.
474
+ // Because the key is the content, entries never go stale and need no
475
+ // invalidation. Cache faults never fail a read. `length`, when given, must
476
+ // match too.
472
477
  async function blobCacheGet(cache, digestHex, length) {
473
478
  if (!cache || typeof cache.get !== 'function') return null;
479
+ if (typeof digestHex !== 'string' || digestHex.length < 32 || digestHex.length > 64) return null;
474
480
  let hit;
475
481
  try {
476
482
  hit = await cache.get(digestHex);
477
483
  if (!hit) return null;
478
484
  hit = asUint8Array(hit);
479
485
  } catch { return null; }
480
- const digest = hit.byteLength === length ? await sha256BytesAsync(hit) : null;
486
+ const digest = length === undefined || hit.byteLength === length ? await sha256BytesAsync(hit) : null;
481
487
  let hex = '';
482
488
  if (digest) for (const b of digest) hex += b.toString(16).padStart(2, '0');
483
- if (hex === digestHex) return hit;
489
+ if (hex && hex.startsWith(digestHex)) return hit;
484
490
  try { await cache.delete?.(digestHex); } catch { /* a miss either way */ }
485
491
  return null;
486
492
  }
@@ -471,6 +471,9 @@ class PikeletSketchArtifact {
471
471
  // Per-row verification on format 2 follows the same switch as the
472
472
  // resident hash: on by default, verify:false opts the whole open out.
473
473
  artifact.verifyRows = version >= 2 && verify;
474
+ // Per-query pieces (digest pages and rows) use the same cache as the
475
+ // resident prefix, but only where each read is verified on its own.
476
+ artifact.blobCache = artifact.verifyRows && options.blobCache ? options.blobCache : null;
474
477
  const staged = options.staged === true && artifact.microDims > 0;
475
478
  artifact.tier = 'full';
476
479
  artifact.fullyResident = Promise.resolve(artifact);
@@ -781,8 +784,37 @@ class PikeletSketchArtifact {
781
784
  }
782
785
  }
783
786
  if (!missing.length) return rows;
784
- missing.sort((a, b) => a - b);
785
787
  const { dim, rowsPerBlock, rowDigestBytes, pageBytes, blockBytes, vectorsOffset } = this;
788
+ // options.blobCache at open: a block's digest page is keyed by its
789
+ // page-table entry and each row by its digest in that page, so a row
790
+ // another process already fetched and verified is read from disk.
791
+ // Every hit is hash-checked against its key (blobCacheGet).
792
+ const cache = this.blobCache;
793
+ if (cache) {
794
+ const byBlock = new Map();
795
+ for (const id of missing) {
796
+ const b = Math.floor(id / rowsPerBlock);
797
+ if (!byBlock.has(b)) byBlock.set(b, []);
798
+ byBlock.get(b).push(id);
799
+ }
800
+ const still = [];
801
+ await mapLimit([...byBlock], 16, async ([b, ids]) => {
802
+ const page = await blobCacheGet(cache, hexOf(this.pageTable.subarray(b * 32, b * 32 + 32)), pageBytes);
803
+ if (!page) { still.push(...ids); return; }
804
+ for (const id of ids) {
805
+ const idx = id - b * rowsPerBlock;
806
+ const hit = await blobCacheGet(cache, hexOf(page.subarray(idx * rowDigestBytes, (idx + 1) * rowDigestBytes)), dim);
807
+ if (!hit) { still.push(id); continue; }
808
+ const row = new Uint8Array(hit);
809
+ rows.set(id, row);
810
+ this.cacheRow(id, row);
811
+ }
812
+ });
813
+ if (!still.length) return rows;
814
+ missing.length = 0;
815
+ missing.push(...still);
816
+ }
817
+ missing.sort((a, b) => a - b);
786
818
  const blocks = new Map();
787
819
  for (const id of missing) {
788
820
  const b = Math.floor(id / rowsPerBlock);
@@ -836,6 +868,7 @@ class PikeletSketchArtifact {
836
868
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row digest page failed hash verification', { block: b });
837
869
  }
838
870
  }
871
+ if (cache) blobCachePut(cache, hexOf(expected), page.slice());
839
872
  }
840
873
  for (const id of blocks.get(b).ids) {
841
874
  const idx = id - b * rowsPerBlock;
@@ -848,6 +881,7 @@ class PikeletSketchArtifact {
848
881
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row failed digest verification', { id });
849
882
  }
850
883
  }
884
+ if (cache) blobCachePut(cache, hexOf(slot), row);
851
885
  }
852
886
  if (rows.has(id)) rows.set(id, row);
853
887
  this.cacheRow(id, row);