pikelet-wasm 0.12.0 → 0.13.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 +78 -0
- package/README.md +1 -1
- package/complete/blob-cache.node.mjs +111 -0
- package/complete/index.d.ts +31 -0
- package/complete/index.mjs +12 -3
- package/complete/index.node.mjs +1 -0
- package/complete/layer-reader.mjs +4 -0
- package/package.json +1 -1
- package/src/artifact/pikelet-artifact-common.js +32 -0
- package/src/artifact/pikelet-artifact-sketch.js +21 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,84 @@ 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.13.0 / pikelet 0.13.0 — 2026-11-01
|
|
12
|
+
|
|
13
|
+
Mounting a pack over HTTP is much cheaper the second time: `pikelet mcp`
|
|
14
|
+
now keeps the two largest mount reads of a URL pack on disk, so a fresh
|
|
15
|
+
process mounting the 649 MiB Wikipedia pack from R2 transfers 9.1 MiB
|
|
16
|
+
instead of 80.1 MiB. A slow remote mount no longer leaves an MCP client
|
|
17
|
+
with no tools. `compile` and `append` now name what they drop instead of
|
|
18
|
+
dropping it silently, and a layered pack keeps its name over MCP. Upgrade
|
|
19
|
+
if you mount packs by URL or update them with `append`. Packs compiled
|
|
20
|
+
before this release still verify and serve, and compiling the same inputs
|
|
21
|
+
gives the same identities as 0.12.0. Both packages move to 0.13.0 together,
|
|
22
|
+
and `pikelet` requires `pikelet-wasm` ^0.13.0.
|
|
23
|
+
|
|
24
|
+
### Compatibility
|
|
25
|
+
|
|
26
|
+
- **`pikelet mcp` writes a cache for URL packs.** The resident index prefix
|
|
27
|
+
and the query encoder of each URL pack mounted (about 71 MiB for the
|
|
28
|
+
Wikipedia pack) are stored in `~/.cache/pikelet/blobs`, up to 2 GiB,
|
|
29
|
+
evicting least recently used entries first. `$PIKELET_CACHE_DIR` or
|
|
30
|
+
`$XDG_CACHE_HOME` moves it, and `--no-cache` turns it off. Local packs
|
|
31
|
+
never use it.
|
|
32
|
+
- **A layered pack mounts under its pack's name.** A chain is named like a
|
|
33
|
+
single pack: from the shelf entry, else the base's manifest name, else
|
|
34
|
+
the file name with the `.0002` layer number dropped. A chain mounted from
|
|
35
|
+
`docs.0002.pikelet` was served as `docs.0002`; it is now `docs`, the
|
|
36
|
+
name its base alone mounted under. Tool calls that name the old pack
|
|
37
|
+
need updating. A single pack keeps a four-digit suffix (`wiki.2025`).
|
|
38
|
+
- **A missing calibration questions file is an error.** A
|
|
39
|
+
`--calibration-queries` file (or `runtime.calibrationQueries`) that does
|
|
40
|
+
not exist used to calibrate without the questions and say nothing; it
|
|
41
|
+
now fails, naming the path.
|
|
42
|
+
- **`tools/list` can answer before the packs have mounted.** After 2
|
|
43
|
+
seconds it answers with the same tools described without pack names,
|
|
44
|
+
uncached, and sends `notifications/tools/list_changed` once the mounts
|
|
45
|
+
settle; `initialize` and `server/discover` advertise
|
|
46
|
+
`tools.listChanged`. Tool calls still wait for the mounts.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **On-disk cache for URL packs (`pikelet-wasm`).** `openPikeletFile`
|
|
51
|
+
takes `blobCache`, a store keyed by sha256. It holds the sketch's
|
|
52
|
+
resident prefix and the query-interp segment that carries the encoder.
|
|
53
|
+
Every hit is hashed against its key, so a corrupt entry is a miss that
|
|
54
|
+
is deleted and refetched, and cache faults never fail an open.
|
|
55
|
+
`diskBlobCache()` and `defaultBlobCacheDir()` (Node, from
|
|
56
|
+
`pikelet-wasm/complete`) provide one on disk, and `pikelet mcp` uses it
|
|
57
|
+
for URL packs. Measured against the Wikipedia pack on R2, a fresh
|
|
58
|
+
process went from 80.1 MiB and 11.5 s to its first answer down to
|
|
59
|
+
9.1 MiB and 6.9 s. Per-query reads are not cached.
|
|
60
|
+
- **A chain reports its pack's name.** `openPikeletChain(...).info().name`
|
|
61
|
+
is the base's name.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **A slow remote mount left an MCP client with no tools.** `tools/list`
|
|
66
|
+
waited for every mount; when a URL mount stalled (6.3 MiB in 37 s from
|
|
67
|
+
R2), Claude Code gave up on the list and started the session with no
|
|
68
|
+
tools at all. See Compatibility for how it answers now.
|
|
69
|
+
- **A calibration question naming no record was dropped silently.** Each
|
|
70
|
+
is now logged by name with the reason, and with the title it probably
|
|
71
|
+
meant when Markdown formatting is the difference (titles are matched
|
|
72
|
+
after formatting is stripped). An `expectId` outside the corpus is named
|
|
73
|
+
the same way.
|
|
74
|
+
- **`append` dropped short pages without saying which.** Documents under
|
|
75
|
+
25 whitespace-separated tokens are skipped whole; `compile` and `append`
|
|
76
|
+
now log them with their token counts, and `append`'s refusal when
|
|
77
|
+
nothing is left names them.
|
|
78
|
+
- **`append`'s closing hint was not a usable next step.** It suggested
|
|
79
|
+
`openPikeletChain([...])`, which takes a query vector, not text; it now
|
|
80
|
+
prints `pikelet mcp --pack <layer>#<identity>`.
|
|
81
|
+
|
|
82
|
+
### Documentation
|
|
83
|
+
|
|
84
|
+
- **Deleting is not redacting.** `append --remove` and `--supersede` take a
|
|
85
|
+
record out of search, but earlier files keep its bytes and `get_record`
|
|
86
|
+
serves it by id, marked `tombstoned`, so old citations resolve.
|
|
87
|
+
`compact` writes a base without it. Both READMEs say so.
|
|
88
|
+
|
|
11
89
|
## pikelet-wasm 0.12.0 / pikelet 0.12.0 — 2026-10-30
|
|
12
90
|
|
|
13
91
|
Query embedding is about 1.65x faster, because the inline encoder now
|
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ npx pikelet append --parent docs.0001.pikelet --remove 412 --out docs.0002.pikel
|
|
|
88
88
|
npx pikelet mcp --pack docs.0002.pikelet
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
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. Mounting the newest layer finds and verifies the rest of the chain, locally or over HTTP, and `#<sha256>` on it pins the whole history. Layers inherit the base's calibration until the chain drifts past 20% of the base's records; then it serves `unscored` until `compact` folds it into a fresh base. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md).
|
|
91
|
+
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. Deleting hides a record from search but does not erase it: the older files still hold its bytes, and `get_record` still serves it by id, marked `tombstoned`, so old citations resolve. `compact` writes a new base without it. Mounting the newest layer finds and verifies the rest of the chain, locally or over HTTP, and `#<sha256>` on it pins the whole history. Layers inherit the base's calibration until the chain drifts past 20% of the base's records; then it serves `unscored` until `compact` folds it into a fresh base. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md).
|
|
92
92
|
|
|
93
93
|
## Match quality
|
|
94
94
|
|
|
@@ -0,0 +1,111 @@
|
|
|
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.
|
|
7
|
+
//
|
|
8
|
+
// The store is untrusted by design: the reader hashes every hit against the
|
|
9
|
+
// 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.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const KEY = /^[0-9a-f]{64}$/;
|
|
20
|
+
export const DEFAULT_BLOB_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
// $PIKELET_CACHE_DIR, else $XDG_CACHE_HOME/pikelet/blobs, else
|
|
23
|
+
// ~/.cache/pikelet/blobs: the same root the CLI already caches encoder
|
|
24
|
+
// weights under.
|
|
25
|
+
export function defaultBlobCacheDir(env = process.env) {
|
|
26
|
+
if (env.PIKELET_CACHE_DIR) return path.resolve(env.PIKELET_CACHE_DIR, 'blobs');
|
|
27
|
+
const base = env.XDG_CACHE_HOME ? path.resolve(env.XDG_CACHE_HOME) : path.join(os.homedir(), '.cache');
|
|
28
|
+
return path.join(base, 'pikelet', 'blobs');
|
|
29
|
+
}
|
|
30
|
+
|
|
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');
|
|
34
|
+
}
|
|
35
|
+
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);
|
|
39
|
+
};
|
|
40
|
+
const pending = new Set();
|
|
41
|
+
const track = (promise) => {
|
|
42
|
+
pending.add(promise);
|
|
43
|
+
promise.finally(() => pending.delete(promise)).catch(() => {});
|
|
44
|
+
return promise;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const evict = async (keep) => {
|
|
48
|
+
const entries = [];
|
|
49
|
+
let total = 0;
|
|
50
|
+
for (const shard of await fs.readdir(root).catch(() => [])) {
|
|
51
|
+
const shardDir = path.join(root, shard);
|
|
52
|
+
for (const name of await fs.readdir(shardDir).catch(() => [])) {
|
|
53
|
+
if (!KEY.test(name)) continue;
|
|
54
|
+
const file = path.join(shardDir, name);
|
|
55
|
+
const st = await fs.stat(file).catch(() => null);
|
|
56
|
+
if (!st) continue;
|
|
57
|
+
total += st.size;
|
|
58
|
+
entries.push({ file, size: st.size, mtime: st.mtimeMs });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
entries.sort((a, b) => a.mtime - b.mtime);
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (total <= maxBytes) break;
|
|
64
|
+
if (entry.file === keep) continue;
|
|
65
|
+
await fs.rm(entry.file, { force: true });
|
|
66
|
+
total -= entry.size;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
dir: root,
|
|
72
|
+
async get(key) {
|
|
73
|
+
const file = fileOf(key);
|
|
74
|
+
let bytes;
|
|
75
|
+
try { bytes = await fs.readFile(file); } catch (err) {
|
|
76
|
+
if (err?.code === 'ENOENT') return null;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
// Touch for LRU; best effort.
|
|
80
|
+
const now = new Date();
|
|
81
|
+
fs.utimes(file, now, now).catch(() => {});
|
|
82
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
83
|
+
},
|
|
84
|
+
put(key, bytes) {
|
|
85
|
+
const file = fileOf(key);
|
|
86
|
+
return track((async () => {
|
|
87
|
+
if (bytes.byteLength > maxBytes) return;
|
|
88
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
89
|
+
// Write-then-rename, so a reader in another process sees
|
|
90
|
+
// either no entry or a whole one.
|
|
91
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
92
|
+
try {
|
|
93
|
+
await fs.writeFile(tmp, bytes);
|
|
94
|
+
await fs.rename(tmp, file);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
await fs.rm(tmp, { force: true });
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
await evict(file);
|
|
100
|
+
})());
|
|
101
|
+
},
|
|
102
|
+
async delete(key) {
|
|
103
|
+
await fs.rm(fileOf(key), { force: true });
|
|
104
|
+
},
|
|
105
|
+
// Settles every write started so far: for tests, and for a process
|
|
106
|
+
// that wants its cache populated before it exits.
|
|
107
|
+
async flush() {
|
|
108
|
+
while (pending.size) await Promise.allSettled([...pending]);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/complete/index.d.ts
CHANGED
|
@@ -96,6 +96,26 @@ 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). */
|
|
100
|
+
export interface BlobCache {
|
|
101
|
+
get(key: string): Promise<Uint8Array | null> | Uint8Array | null;
|
|
102
|
+
put(key: string, bytes: Uint8Array): Promise<void> | void;
|
|
103
|
+
delete?(key: string): Promise<void> | void;
|
|
104
|
+
}
|
|
105
|
+
|
|
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.
|
|
110
|
+
*/
|
|
111
|
+
export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number }): BlobCache & {
|
|
112
|
+
dir: string;
|
|
113
|
+
flush(): Promise<void>;
|
|
114
|
+
};
|
|
115
|
+
/** $PIKELET_CACHE_DIR/blobs, else $XDG_CACHE_HOME/pikelet/blobs, else ~/.cache/pikelet/blobs. */
|
|
116
|
+
export declare function defaultBlobCacheDir(env?: Record<string, string | undefined>): string;
|
|
117
|
+
export declare const DEFAULT_BLOB_CACHE_BYTES: number;
|
|
118
|
+
|
|
99
119
|
export interface CompleteQueryResult {
|
|
100
120
|
/**
|
|
101
121
|
* The pack's calibrated verdict on this retrieval. On packs calibrated
|
|
@@ -287,6 +307,17 @@ export declare function openPikeletFile(
|
|
|
287
307
|
* true.
|
|
288
308
|
*/
|
|
289
309
|
prefetchEncoder?: boolean;
|
|
310
|
+
/**
|
|
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.
|
|
319
|
+
*/
|
|
320
|
+
blobCache?: BlobCache;
|
|
290
321
|
/**
|
|
291
322
|
* Fully verify the index's lazy vector rows at open (one streamed pass
|
|
292
323
|
* against the identity-anchored vectorsSha256). Default false: without
|
package/complete/index.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { createAbstentionScorer } from './retrieval-abstention.mjs';
|
|
|
27
27
|
import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
|
|
28
28
|
import { parseRowMapSegment } from './rowmap.mjs';
|
|
29
29
|
import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
|
|
30
|
+
import { blobCacheGet, blobCachePut } from '../src/artifact/pikelet-artifact-common.js';
|
|
30
31
|
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
31
32
|
|
|
32
33
|
import {
|
|
@@ -785,6 +786,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
785
786
|
// the same bytes the sketch parses (see expectedHeaderSha256).
|
|
786
787
|
PikeletSketchArtifact.open(windowSource(source, idx.offset, idx.length), {
|
|
787
788
|
maxReadBytes,
|
|
789
|
+
...(options.blobCache ? { blobCache: options.blobCache } : {}),
|
|
788
790
|
...(perRecord ? { expectedHeaderSha256: manifest.index.headerSha256 } : {}),
|
|
789
791
|
}),
|
|
790
792
|
readChecked(source, corpus.offset, tablesBytes, 'corpus tables', maxReadBytes, fileBytes),
|
|
@@ -1004,9 +1006,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1004
1006
|
const loadEmbedder = async () => {
|
|
1005
1007
|
let encBytes = encoderBytes;
|
|
1006
1008
|
if (qiDeferred) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1009
|
+
// The ~25 MiB encoder is the other bulk of a cold mount;
|
|
1010
|
+
// options.blobCache serves it by the manifest's segment
|
|
1011
|
+
// digest (hash-checked on the way out, as a fetch is).
|
|
1012
|
+
const cached = await blobCacheGet(options.blobCache, qi.sha256, qi.length);
|
|
1013
|
+
const full = cached || await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
1014
|
+
if (!cached) {
|
|
1015
|
+
if (await sha256hex(full) !== qi.sha256) {
|
|
1016
|
+
throw new Error('.pikelet query-interp segment failed hash verification');
|
|
1017
|
+
}
|
|
1018
|
+
blobCachePut(options.blobCache, qi.sha256, full);
|
|
1010
1019
|
}
|
|
1011
1020
|
// The header and calibration slices used at open were
|
|
1012
1021
|
// unverified; the digest-verified segment must contain
|
package/complete/index.node.mjs
CHANGED
|
@@ -9,6 +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
13
|
|
|
13
14
|
export function httpRangeSource(url, options = {}) {
|
|
14
15
|
return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
|
|
@@ -369,6 +369,10 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
369
369
|
profile: LAYER_PROFILE,
|
|
370
370
|
identity: chainMembers[chainMembers.length - 1].identity,
|
|
371
371
|
baseIdentity: base.identity,
|
|
372
|
+
// The pack's name is the base's: layers add records, not a
|
|
373
|
+
// new name. Without it a host fell back to naming the
|
|
374
|
+
// chain after its newest layer's file.
|
|
375
|
+
name: opened[0].info().name ?? null,
|
|
372
376
|
layers: chainMembers.length,
|
|
373
377
|
searchTiers: searchTiers.length,
|
|
374
378
|
records: table.rowTotal,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pikelet-wasm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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,6 +462,36 @@ 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.
|
|
472
|
+
async function blobCacheGet(cache, digestHex, length) {
|
|
473
|
+
if (!cache || typeof cache.get !== 'function') return null;
|
|
474
|
+
let hit;
|
|
475
|
+
try {
|
|
476
|
+
hit = await cache.get(digestHex);
|
|
477
|
+
if (!hit) return null;
|
|
478
|
+
hit = asUint8Array(hit);
|
|
479
|
+
} catch { return null; }
|
|
480
|
+
const digest = hit.byteLength === length ? await sha256BytesAsync(hit) : null;
|
|
481
|
+
let hex = '';
|
|
482
|
+
if (digest) for (const b of digest) hex += b.toString(16).padStart(2, '0');
|
|
483
|
+
if (hex === digestHex) return hit;
|
|
484
|
+
try { await cache.delete?.(digestHex); } catch { /* a miss either way */ }
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Store verified bytes. Fire-and-forget: a slow or failing cache must not
|
|
489
|
+
// hold up the open that just fetched them.
|
|
490
|
+
function blobCachePut(cache, digestHex, bytes) {
|
|
491
|
+
if (!cache || typeof cache.put !== 'function') return;
|
|
492
|
+
Promise.resolve().then(() => cache.put(digestHex, bytes)).catch(() => {});
|
|
493
|
+
}
|
|
494
|
+
|
|
465
495
|
// Verify bytes against an expected 32-byte SHA-256 digest. Verification was
|
|
466
496
|
// requested by the caller, so a missing crypto backend fails closed rather
|
|
467
497
|
// than admitting unverified bytes.
|
|
@@ -556,6 +586,8 @@ module.exports = {
|
|
|
556
586
|
parseUint8Snapshot,
|
|
557
587
|
sha256Bytes,
|
|
558
588
|
sha256BytesAsync,
|
|
589
|
+
blobCacheGet,
|
|
590
|
+
blobCachePut,
|
|
559
591
|
verifySha256,
|
|
560
592
|
verifySegmentSha256,
|
|
561
593
|
};
|
|
@@ -17,6 +17,8 @@ const {
|
|
|
17
17
|
parseUint8Snapshot,
|
|
18
18
|
sha256Bytes,
|
|
19
19
|
sha256BytesAsync,
|
|
20
|
+
blobCacheGet,
|
|
21
|
+
blobCachePut,
|
|
20
22
|
verifySegmentSha256,
|
|
21
23
|
} = require('./pikelet-artifact-common.js');
|
|
22
24
|
const {
|
|
@@ -28,6 +30,12 @@ const {
|
|
|
28
30
|
// ============================================================================
|
|
29
31
|
// Sketch artifact profile (.pikelet-sketch) — spec/SKETCH_PROFILE.md
|
|
30
32
|
// ============================================================================
|
|
33
|
+
|
|
34
|
+
function hexOf(bytes) {
|
|
35
|
+
let hex = '';
|
|
36
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
37
|
+
return hex;
|
|
38
|
+
}
|
|
31
39
|
//
|
|
32
40
|
// Layout: 256-byte header | scales f32[count] | offsets f32[count] |
|
|
33
41
|
// packed pooled sketches | zero padding to 16-byte alignment | raw u8 rows.
|
|
@@ -467,8 +475,16 @@ class PikeletSketchArtifact {
|
|
|
467
475
|
artifact.tier = 'full';
|
|
468
476
|
artifact.fullyResident = Promise.resolve(artifact);
|
|
469
477
|
|
|
470
|
-
|
|
471
|
-
|
|
478
|
+
// options.blobCache: the resident prefix is the bulk of a mount (46 MiB
|
|
479
|
+
// of a 649 MiB wiki pack) and is committed by residentSha256, so a
|
|
480
|
+
// cache keyed by that digest serves it to later processes. A hit is
|
|
481
|
+
// hash-checked inside blobCacheGet; only verified opens consult it,
|
|
482
|
+
// since verify:false has nothing to check a hit against. A cache hit
|
|
483
|
+
// also skips staging: the full tier is already local.
|
|
484
|
+
const cacheKey = verify && options.blobCache ? hexOf(header.subarray(56, 88)) : null;
|
|
485
|
+
const cached = cacheKey ? await blobCacheGet(options.blobCache, cacheKey, vectorsOffset - SKETCH_HEADER_BYTES) : null;
|
|
486
|
+
if (!staged || cached) {
|
|
487
|
+
const resident = cached || await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
|
|
472
488
|
if (resident.byteLength !== vectorsOffset - SKETCH_HEADER_BYTES) {
|
|
473
489
|
throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact resident prefix is truncated');
|
|
474
490
|
}
|
|
@@ -477,7 +493,9 @@ class PikeletSketchArtifact {
|
|
|
477
493
|
const residentCopy = new Uint8Array(resident.byteLength);
|
|
478
494
|
residentCopy.set(resident);
|
|
479
495
|
artifact._adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset);
|
|
480
|
-
if (
|
|
496
|
+
if (cached) artifact.residentVerified = true;
|
|
497
|
+
else if (verify) await artifact._verifyFullResident(residentCopy, header);
|
|
498
|
+
if (cacheKey && !cached) blobCachePut(options.blobCache, cacheKey, residentCopy);
|
|
481
499
|
return artifact;
|
|
482
500
|
}
|
|
483
501
|
|