diskformer 0.2.0 → 0.4.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
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-10-13)
4
+ - Changed: errors are now `DiskformerError`s with a `code` (see below). A caller that checked `e.name === 'AbortError'`
5
+ after an aborted ingest should check `isDiskformerError(e, 'LOAD_ABORTED')`; messages are unchanged.
6
+ - Coded errors (`src/errors.js`): `DiskformerError` with a `code`, checked by name and code through
7
+ `isDiskformerError(e, code)`. `ingestGguf` throws `LOAD_ABORTED` when its signal aborted, else `LOAD_FAILED` with the
8
+ original as `cause`.
9
+ - `readStore(key)` and `listStores()`: a store is complete only when every file it names is on disk at full size;
10
+ zero-byte or short files left by a crash make it `invalid`. `examples/chat` lists the stored models and removes one
11
+ at a time.
12
+ - `engines/`: `UNSUPPORTED_DEVICE`, `BUDGET_TOO_SMALL` (with the bytes in `detail`), `CONTEXT_FULL`,
13
+ `EXECUTION_FAILED`, `RESOURCE_DISPOSED`. `generate()` and `greedy()` on one engine run one at a time (they share the
14
+ KV cache); `dispose()` waits for the step in flight. A load that fails destroys its GPU device. An invalid store is
15
+ ingested again instead of loaded.
16
+ - Gates that run from this repo: `test/browser/greedy.html` (token by token vs llama.cpp, references in `test/refs/`),
17
+ `test/browser/states.html` + `test/state-check.py` (per-layer states vs LocalMind's numpy pass).
18
+ - New helpers: `opfsFileSize(path)`, `opfsSubdirs(path)`.
19
+ - Not in the npm package, but new in the repo: the demo at diskformer.naklitechie.com now runs llama.cpp's WebGPU
20
+ backend with experts paged from OPFS (`ggml/`; LFM2 8B-A1B, OLMoE 1B-7B, Qwen3 30B-A3B, Qwen3-Next 80B-A3B); the
21
+ hand-engine page moved to `/classic/`.
22
+
23
+ ## 0.3.0 (2026-10-07)
24
+ - `fileFetch(blob)`: ingest a GGUF that is already on disk (a `File` the user picked, e.g. from a Hugging Face or
25
+ LM Studio cache) through `ingestGguf({ fetch: fileFetch(file) })`, with no download. The store is byte-identical to a
26
+ download's, and an interrupted file ingest resumes from the same file (`test/ingest-resume.test.mjs`).
27
+ - `engines/`: `load({ localFile })` ingests the pinned GGUF from a local file, refusing a file of another size.
28
+
3
29
  ## 0.2.0 (2026-10-05)
4
30
  - `engines/`: reference WebGPU engines for Gemma 4 26B-A4B (`Gemma4MoeSsd`), Qwen3.6 35B-A3B (`Qwen35MoeSsd`) and
5
31
  Qwen3-30B-A3B (`Qwen3MoeSsd`), copied from LocalMind with import paths rewritten (`scripts/sync-localmind.mjs`).
package/README.md CHANGED
@@ -10,18 +10,20 @@ Plain ES modules, no dependencies, no build step. Chromium with WebGPU. Weights
10
10
  [![license](https://img.shields.io/badge/license-MIT-555?style=flat-square)](LICENSE)
11
11
  [![dependencies](https://img.shields.io/badge/dependencies-none-555?style=flat-square)](package.json)
12
12
 
13
+ [![The diskformer.js card: keep model weights on disk in the browser, and page into WebGPU only what each step needs](marketing/social.png)](https://diskformer.naklitechie.com/)
14
+
13
15
  ## Install
14
16
 
15
17
  | How | Command |
16
18
  |---|---|
17
19
  | npm | `npm install diskformer` |
18
- | CDN | `import { Gemma4MoeSsd } from 'https://cdn.jsdelivr.net/npm/diskformer@0.2/engines/gemma4_moe_ssd.js'` |
20
+ | CDN | `import { Gemma4MoeSsd } from 'https://cdn.jsdelivr.net/npm/diskformer@0.3/engines/gemma4_moe_ssd.js'` |
19
21
  | Copy | `src/`, `engines/` and `index.js`; nothing else is needed |
20
22
 
21
23
  ## Try it: a 14.4 GB model in 2.5 GB of GPU memory
22
24
 
23
- Open the [Hugging Face Space](https://naklitechie-diskformer-chat.static.hf.space/), or serve `examples/chat` yourself.
24
- Pick a model and a GPU memory budget. The page downloads the model once into OPFS, resumably, then chats.
25
+ [diskformer.naklitechie.com](https://diskformer.naklitechie.com/) (also a [Hugging Face Space](https://huggingface.co/spaces/naklitechie/diskformer-chat)) runs llama.cpp's WebGPU backend with paged experts: LFM2 8B-A1B, OLMoE, Qwen3 30B-A3B, Qwen3-Next 80B (source in `ggml/`; references in `examples/chat/refs/`).
26
+ The hand-written engines measured below are at [/classic/](https://diskformer.naklitechie.com/classic/); serve `examples/chat` to run them locally. Each page downloads a model once into OPFS, resumably.
25
27
 
26
28
  ```bash
27
29
  python3 -m http.server 8000 # then open http://localhost:8000/examples/chat/
@@ -36,10 +38,9 @@ Measured on a MacBook M4 Pro with 24 GB, Chrome 154, on 2026-10-05:
36
38
  | Gemma 4 26B-A4B, QAT Q4_0 | 14.4 GB | 7 GB | 6.63 GB | 17.9 tok/s | 9/9 |
37
39
  | Qwen3.6 35B-A3B, Q8_0 | 36.9 GB | 4 GB | 3.52 GB | 9.2 tok/s | not compared |
38
40
 
39
- "Identical" means the reply text matches llama.cpp b9830's Metal path character for character, on the same GGUF.
40
- llama-server echoes Gemma's empty thought-channel marker before each reply; the gate removes that marker first. The
41
- test covers 9 conversations, greedy decoding, up to 64 tokens each. Qwen3.6 has no llama.cpp comparison: its Metal path cannot
42
- hold 36.9 GB on this Mac.
41
+ "Identical" means the reply text matches llama.cpp b9830's Metal path character for character on the same GGUF. The
42
+ gate first drops the empty thought-channel marker llama-server echoes before each Gemma reply. 9 conversations,
43
+ greedy, up to 64 tokens each. Qwen3.6 is not compared: llama.cpp's Metal path cannot hold 36.9 GB here.
43
44
 
44
45
  Each row is `node examples/run-demo.mjs chat --budget <GB>`; decode is the median over the 9 replies. A larger budget
45
46
  caches more experts. Only Apple silicon is tested; discrete GPUs and 8–16 GB machines are not.
@@ -53,14 +54,12 @@ const engine = await Gemma4MoeSsd.load(null, { gpuBudgetBytes: 3e9, onProgress:
53
54
  for await (const { text } of engine.generate([{ role: 'user', content: 'Hello' }], { maxNewTokens: 128 })) show(text);
54
55
  ```
55
56
 
56
- The first load downloads the GGUF from Hugging Face and copies it into OPFS in the engine's layout. Later loads take
57
- seconds.
57
+ The first load downloads the GGUF from Hugging Face into OPFS, in the engine's layout. Later loads take seconds.
58
58
 
59
59
  ## Why
60
60
 
61
- Your model does not fit on the GPU, or it fits only by crowding out everything else. Browser engines load every
62
- weight onto the GPU at load time, including the ones a step never touches. A mixture-of-experts model uses under a
63
- tenth of its weights per token. Gemma 4's per-layer embedding table is read one row per token.
61
+ Your model does not fit on the GPU, or fits only by crowding out everything else. Browser engines load every weight
62
+ at load time, though a mixture-of-experts model uses under a tenth of its weights per token.
64
63
 
65
64
  diskformer keeps those weights in OPFS, read through sync access handles in workers. Only what each step needs
66
65
  moves into a GPU cache. The core is the store and the residency layer: your engine keeps its own kernels and decides
@@ -71,9 +70,8 @@ what a step needs. `engines/` shows two complete engines built on it.
71
70
  `engines/gemma4_moe_ssd.js` runs Gemma 4 26B-A4B. `engines/qwen35_moe_ssd.js` runs Qwen3.6 35B-A3B, and
72
71
  `engines/qwen3_moe_ssd.js` runs Qwen3-30B-A3B as their base. They are from-scratch WebGPU engines. The dense layers and
73
72
  KV cache live on the GPU. The experts stream from OPFS into a GPU cache, sized by `gpuBudgetBytes`. A budget that
74
- cannot hold the dense part and two tokens' experts is refused with the numbers. Decoding is greedy. The kernels are
75
- ported from gemma4-webgpu (Apache-2.0, `engines/NOTICE`). The engine files are LocalMind's, kept in step by
76
- `scripts/sync-localmind.mjs`.
73
+ cannot hold the dense part and two tokens' experts is refused with the numbers. Decoding is greedy, one `generate()`
74
+ at a time per engine. Kernels: gemma4-webgpu (Apache-2.0, `engines/NOTICE`); files kept in step with LocalMind.
77
75
 
78
76
  ## A table on disk
79
77
 
@@ -83,14 +81,18 @@ O(1) LRU and an optional GPU id→slot map that kernels can read. `examples/embe
83
81
 
84
82
  ## Experts on disk
85
83
 
86
- `ingestGguf({ url, key, plan })` streams a GGUF over HTTP ranges into OPFS in your engine's layout. `plan.layout` says
87
- where each tensor goes. `plan.units` lists the byte ranges to copy or split: Q8_0 and Q4_0 blocks become a value plane
88
- and an f16-scale plane, bit-exact. An interrupted ingest resumes from its last 1 GiB checkpoint. `ExpertStreamer`
89
- (also `RecordPool`) keeps a fixed GPU slot pool of expert records. `ensure(layer, ids)` returns pinned slots,
90
- `prefetch(layer, guesses)` never blocks, and uploads go through a mapped staging ring.
84
+ `ingestGguf({ url, key, plan })` streams a GGUF over HTTP ranges into OPFS in your engine's layout: `plan.layout` says
85
+ where each tensor goes, `plan.units` the byte ranges to copy or split (Q8_0 and Q4_0 blocks become a value plane and an
86
+ f16-scale plane, bit-exact). It resumes from its last 1 GiB checkpoint. For a GGUF already on disk, pass a picked `File`
87
+ as `fetch: fileFetch(file)`: no download, same store. `ExpertStreamer` (also `RecordPool`) keeps a fixed GPU slot pool
88
+ of expert records: `ensure(layer, ids)` returns pinned slots, `prefetch(layer, guesses)` never blocks, and uploads go
89
+ through a mapped staging ring.
90
+
91
+ `listStores()` lists the stored models with size and state; a store with zero-byte or short files (a crash) is
92
+ `invalid` and is ingested again, never loaded. `removeStore(key)` removes one and keeps the rest. Errors carry a `code`
93
+ (`LOAD_ABORTED`, `BUDGET_TOO_SMALL`, … in `src/errors.js`), checked with `isDiskformerError(e, code)`.
91
94
 
92
- `measure({ path, recordBytes })` reports the disk's sequential MB/s and random record reads; OPFS speed varies a lot
93
- between machines, so measure where your users run.
95
+ `measure({ path, recordBytes })` reports sequential MB/s and random record reads; OPFS speed varies by machine.
94
96
 
95
97
  ## Commands
96
98
 
@@ -101,17 +103,15 @@ node examples/run-demo.mjs embedding # lookup → slots → a WGSL gathe
101
103
  node examples/run-demo.mjs chat --budget 2.5 # downloads Gemma once (14.4 GB) to a fixed profile, replays 9 llama.cpp replies, fails on any difference
102
104
  node examples/run-demo.mjs test/browser/ingest-resume.html --models <dir> --file <gguf> # abort, resume, compare bytes
103
105
  node examples/run-demo.mjs test/browser/server.html --models <dir> --file <any file> # the runner's file server rejects bad requests
106
+ node examples/run-demo.mjs test/browser/greedy.html --models <dir> --file <gguf> --ref test/refs/<ref>.json # token by token vs llama.cpp
107
+ node examples/run-demo.mjs test/browser/states.html --models <dir> --file g4-6l.gguf # per-layer states; then test/state-check.py
104
108
  npm run sync # the files shared with LocalMind match their sources
105
109
  ```
106
110
 
107
- ## Verify it yourself
108
-
109
- `node examples/run-demo.mjs chat` runs the chat page in headless Chrome under a GPU budget and replays
110
- `examples/chat/refs/gemma.json` (llama.cpp's replies); any differing reply exits 1. `--models <dir>` serves a local
111
- GGUF instead of downloading it; `--site <url>` gates a deployed copy, such as the Space.
111
+ The chat gate replays `examples/chat/refs/gemma.json` (llama.cpp's replies) under a GPU budget; any differing reply
112
+ exits 1. `--models <dir>` serves a local GGUF; `--site <url>` gates a deployed copy, such as the Space.
112
113
 
113
- The code runs in [LocalMind](https://github.com/NakliTechie/LocalMind) on its live site. `src/` (all but `measure.js`)
114
- and `engines/` are byte-identical to LocalMind's copies, apart from the engines' import paths.
114
+ The code runs in [LocalMind](https://github.com/NakliTechie/LocalMind); `src/` and `engines/` are its copies.
115
115
 
116
116
  ## License
117
117
 
@@ -1119,7 +1119,7 @@ export class Gemma4MoeSsd extends Qwen3MoeSsd {
1119
1119
  async prefillChunk(ids, want = 'none') {
1120
1120
  const c = this.cfg, dev = this.device, K = c.topK, H = c.hidden, T = ids.length, pos0 = this.position, S = pos0 + T;
1121
1121
  if (T > this.chunkTokens) throw new Error(`chunk of ${T} > ${this.chunkTokens}`);
1122
- if (S > this.maxCtx) throw new Error(`context full (${this.maxCtx} tokens)`);
1122
+ if (S > this.maxCtx) throw this.contextFull();
1123
1123
  const tStart = performance.now();
1124
1124
  dev.queue.writeBuffer(this.b.ids, 0, Uint32Array.from(ids));
1125
1125
  dev.queue.writeBuffer(this.qb, 0, new Uint32Array([T, pos0, S, 0]));
@@ -964,7 +964,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
964
964
  async prefillChunk(ids, want = 'none') {
965
965
  const c = this.cfg, dev = this.device, K = c.topK, H = c.hidden, T = ids.length, pos0 = this.position, S = pos0 + T;
966
966
  if (T > this.chunkTokens) throw new Error(`chunk of ${T} > ${this.chunkTokens}`);
967
- if (S > this.maxCtx) throw new Error(`context full (${this.maxCtx} tokens)`);
967
+ if (S > this.maxCtx) throw this.contextFull();
968
968
  const tStart = performance.now();
969
969
  dev.queue.writeBuffer(this.a.stop, 0, new Uint32Array(4)); // the head below uses guarded kernels
970
970
  dev.queue.writeBuffer(this.b.ids, 0, Uint32Array.from(ids));
@@ -1099,7 +1099,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
1099
1099
  async step(token, want = 'argmax') {
1100
1100
  if (!this.gpuRouting) return super.step(token, want);
1101
1101
  const c = this.cfg, dev = this.device, K = c.topK, L = c.layers, seg = this.seg;
1102
- if (this.position >= this.maxCtx) throw new Error(`context full (${this.maxCtx} tokens)`);
1102
+ if (this.position >= this.maxCtx) throw this.contextFull();
1103
1103
  const tStart = performance.now();
1104
1104
  const pos = this.position, seqLen = pos + 1;
1105
1105
  this.writeTokenUniforms(token, pos);
@@ -20,11 +20,12 @@
20
20
  * so the engine computes the same dot products llama.cpp does, up to summation order.
21
21
  */
22
22
 
23
- import { OpfsReaderPool, readOpfsText } from '../src/opfs-reader.js';
23
+ import { OpfsReaderPool } from '../src/opfs-reader.js';
24
24
  import { ExpertStreamer } from '../src/expert-stream.js';
25
25
  // The store layer is diskformer.js's (github.com/NakliTechie/diskformer.js), byte-identical copies: gguf.js, ingest.js.
26
26
  import { GGML, Q8_BLOCK, Q4_BLOCK, Q6K_BLOCK, parseGguf, tensorBytes } from '../src/gguf.js';
27
- import { ingestGguf as ingestStore, ingestProgress, storeKey, removeStore } from '../src/ingest.js';
27
+ import { ingestGguf as ingestStore, ingestProgress, fileFetch, storeKey, readStore, removeStore } from '../src/ingest.js';
28
+ import { DiskformerError, isDiskformerError, codedError } from '../src/errors.js';
28
29
  export { GGML, Q4_BLOCK, Q6K_BLOCK, parseGguf, tensorBytes, splitQ4 } from '../src/gguf.js';
29
30
  export { ingestProgress } from '../src/ingest.js';
30
31
 
@@ -541,43 +542,60 @@ export class Qwen3MoeSsd {
541
542
  static get ingestPlan() { return undefined; }
542
543
  static tokenizerFrom(kv) { return new BpeTokenizer(kv); }
543
544
 
545
+ // Errors are coded (errors.js): UNSUPPORTED_DEVICE, BUDGET_TOO_SMALL, LOAD_ABORTED when opts.signal aborted, else
546
+ // LOAD_FAILED with the original as `cause`. A failed load destroys the GPU device it opened.
544
547
  static async load(modelId = QWEN3_30B_A3B.repo, opts = {}) {
545
- const { fetch: fetchFn = (u, i) => fetch(u, i), onProgress = () => {}, signal } = opts;
548
+ try { return await this.open(modelId, opts); } catch (e) { throw codedError(e, { signal: opts.signal }); }
549
+ }
550
+
551
+ static async open(modelId, opts) {
552
+ const { onProgress = () => {}, signal, localFile } = opts;
546
553
  const src = opts.source || (modelId === QWEN3_30B_A3B.repo || !modelId ? QWEN3_30B_A3B : { repo: modelId, file: opts.file, revision: opts.revision || 'main' });
554
+ // localFile: the same GGUF already on disk (a File the user picked), ingested instead of downloaded. A size
555
+ // other than the pinned file's is refused before any write; the GGUF header check in the ingest plan follows.
556
+ if (localFile && src.size && localFile.size !== src.size) {
557
+ throw new Error(`${localFile.name} is ${localFile.size.toLocaleString()} bytes, not ${src.file} (${src.size.toLocaleString()} bytes). Pick that file.`);
558
+ }
559
+ const fetchFn = localFile ? fileFetch(localFile) : (opts.fetch || ((u, i) => fetch(u, i)));
547
560
  const url = opts.url || `https://huggingface.co/${src.repo}/resolve/${src.revision}/${src.file}`;
548
561
  const key = opts.key || storeKey(opts.file || src.file || url.split('/').pop());
549
562
  const root = opts.root || OPFS_ROOT; // the OPFS folder for this engine's stores
550
563
  const dir = `${root}/${key}`;
551
564
 
552
565
  onProgress({ status: 'init' });
553
- if (!navigator.gpu) throw new Error('WebGPU is not available');
566
+ const unsupported = (m) => new DiskformerError('UNSUPPORTED_DEVICE', m);
567
+ if (typeof navigator === 'undefined' || !navigator.gpu) throw unsupported('WebGPU is not available');
554
568
  const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
555
- if (!adapter) throw new Error('no WebGPU adapter');
556
- if (!adapter.features.has('shader-f16')) throw new Error('this GPU lacks shader-f16');
569
+ if (!adapter) throw unsupported('no WebGPU adapter');
570
+ if (!adapter.features.has('shader-f16')) throw unsupported('this GPU lacks shader-f16');
557
571
  const L = adapter.limits;
558
- // subgroups: optional; an engine may use subgroup kernels when the adapter offers them.
559
- const features = ['shader-f16', ...(adapter.features.has('subgroups') ? ['subgroups'] : [])];
572
+ // subgroups, subgroup matrices: optional; an engine may use subgroup (matrix) kernels when the adapter offers them.
573
+ const features = ['shader-f16', ...['subgroups', 'chromium-experimental-subgroup-matrix'].filter((f) => adapter.features.has(f))];
560
574
  const device = await adapter.requestDevice({
561
575
  requiredFeatures: features,
562
- requiredLimits: { maxBufferSize: L.maxBufferSize, maxStorageBufferBindingSize: L.maxStorageBufferBindingSize, maxStorageBuffersPerShaderStage: L.maxStorageBuffersPerShaderStage, maxComputeWorkgroupsPerDimension: L.maxComputeWorkgroupsPerDimension },
576
+ requiredLimits: { maxBufferSize: L.maxBufferSize, maxStorageBufferBindingSize: L.maxStorageBufferBindingSize, maxStorageBuffersPerShaderStage: L.maxStorageBuffersPerShaderStage, maxComputeWorkgroupsPerDimension: L.maxComputeWorkgroupsPerDimension, maxComputeWorkgroupStorageSize: L.maxComputeWorkgroupStorageSize },
563
577
  });
564
578
  device.lost.then((info) => console.warn('qwen3-moe-ssd: GPU device lost:', info.message));
565
579
 
566
- let manifest = null;
567
- try { manifest = JSON.parse(await readOpfsText(`${dir}/manifest.json`) || 'null'); } catch (_) { manifest = null; }
568
- if (opts.reingest || !manifest || !manifest.complete || manifest.format !== FORMAT) {
569
- manifest = await ingestGguf({
570
- url, key, root, fetch: fetchFn, signal, source: { repo: src.repo, file: src.file, revision: src.revision, sha256: src.sha256, size: src.size },
571
- onProgress: (e) => onProgress(ingestProgress(e)),
572
- plan: this.ingestPlan,
573
- });
574
- }
575
- const headerFile = await (await (await navigator.storage.getDirectory()).getDirectoryHandle(root)).getDirectoryHandle(key);
576
- const headerBytes = new Uint8Array(await (await (await headerFile.getFileHandle('header.bin')).getFile()).arrayBuffer());
577
- const gguf = parseGguf(headerBytes);
578
- const engine = new this(device, manifest, gguf, { ...opts, dir, key, url, adapterInfo: adapter.info || {} });
579
- await engine.init(onProgress);
580
- return engine;
580
+ try {
581
+ // Only a complete store with every file at full size is used; a partial one resumes, an invalid one (zero-byte
582
+ // or short files left by a crash) is ingested again.
583
+ const store = await readStore(key, { root, format: FORMAT });
584
+ let manifest = store.state === 'complete' ? store.manifest : null;
585
+ if (opts.reingest || !manifest) {
586
+ manifest = await ingestGguf({
587
+ url, key, root, fetch: fetchFn, signal, source: { repo: src.repo, file: src.file, revision: src.revision, sha256: src.sha256, size: src.size, ...(localFile ? { localFile: localFile.name } : {}) },
588
+ onProgress: (e) => onProgress(ingestProgress(e)),
589
+ plan: this.ingestPlan,
590
+ });
591
+ }
592
+ const headerFile = await (await (await navigator.storage.getDirectory()).getDirectoryHandle(root)).getDirectoryHandle(key);
593
+ const headerBytes = new Uint8Array(await (await (await headerFile.getFileHandle('header.bin')).getFile()).arrayBuffer());
594
+ const gguf = parseGguf(headerBytes);
595
+ const engine = new this(device, manifest, gguf, { ...opts, dir, key, url, adapterInfo: adapter.info || {} });
596
+ await engine.init(onProgress);
597
+ return engine;
598
+ } catch (e) { try { device.destroy(); } catch (_) {} throw e; }
581
599
  }
582
600
 
583
601
  constructor(device, manifest, gguf, opts) {
@@ -691,7 +709,7 @@ export class Qwen3MoeSsd {
691
709
  const fixed = this.gpuBytes.dense + this.kvBytes() + (o.workBytes ?? 512 * 2 ** 20);
692
710
  const rest = o.gpuBudgetBytes - fixed;
693
711
  const min = this.cfg.topK * 2 * this.manifest.experts.record;
694
- if (rest < min) throw new Error(`GPU budget ${gb(o.gpuBudgetBytes)} is too small: weights that stay on the GPU, the KV cache and working buffers need ${gb(fixed)}, and the expert cache at least ${gb(min)} more`);
712
+ if (rest < min) throw new DiskformerError('BUDGET_TOO_SMALL', `GPU budget ${gb(o.gpuBudgetBytes)} is too small: weights that stay on the GPU, the KV cache and working buffers need ${gb(fixed)}, and the expert cache at least ${gb(min)} more`, { detail: { budget: o.gpuBudgetBytes, fixed, minCache: min } });
695
713
  return rest;
696
714
  }
697
715
  // KV cache bytes at this.maxCtx (f16 K and V for every layer); engines with other layouts override it.
@@ -875,7 +893,7 @@ export class Qwen3MoeSsd {
875
893
  // One token through all layers at position this.position. want: 'none' | 'argmax' | 'logits'.
876
894
  async step(token, want = 'argmax') {
877
895
  const c = this.cfg, dev = this.device, K = c.topK;
878
- if (this.position >= this.maxCtx) throw new Error(`context full (${this.maxCtx} tokens)`);
896
+ if (this.position >= this.maxCtx) throw this.contextFull();
879
897
  const tStart = performance.now();
880
898
  const pos = this.position, seqLen = pos + 1;
881
899
  this.writeTokenUniforms(token, pos);
@@ -954,7 +972,11 @@ export class Qwen3MoeSsd {
954
972
 
955
973
  // Diagnostics for the llama.cpp comparison: greedy continuation of raw token ids, with the
956
974
  // top-n logits at every generated position.
957
- async greedy(ids, n, { top = 5 } = {}) {
975
+ async greedy(ids, n, opts) {
976
+ const release = await this.acquire();
977
+ try { this.inflight = this.greedyTurn(ids, n, opts); return await this.inflight; } catch (e) { throw this.stepError(e); } finally { this.inflight = null; release(); }
978
+ }
979
+ async greedyTurn(ids, n, { top = 5 } = {}) {
958
980
  this.reset();
959
981
  const out = [], tops = [];
960
982
  let logits = await this.prefill(ids, 'logits');
@@ -973,7 +995,45 @@ export class Qwen3MoeSsd {
973
995
  return { ids: out, tops };
974
996
  }
975
997
 
976
- async *generate(messages, { maxNewTokens = 512, signal, enableThinking = true } = {}) {
998
+ // generate() and greedy() share one KV cache, so calls on one engine run one at a time. A generate() takes its turn
999
+ // on its first next() (an async generator runs nothing before that), so turns follow iteration order. A caller
1000
+ // that stops iterating early must end the loop (break / return()) so the next call can start. Errors are coded:
1001
+ // EXECUTION_FAILED (original as `cause`), CONTEXT_FULL, RESOURCE_DISPOSED after dispose().
1002
+ async *generate(messages, opts) {
1003
+ const release = await this.acquire();
1004
+ const it = this.generateTurn(messages, opts);
1005
+ try {
1006
+ for (;;) {
1007
+ if (this.disposed) throw this.disposedError();
1008
+ this.inflight = it.next();
1009
+ const r = await this.inflight;
1010
+ this.inflight = null;
1011
+ if (r.done) return;
1012
+ yield r.value;
1013
+ }
1014
+ } catch (e) { throw this.stepError(e); } finally { this.inflight = null; await it.return(); release(); }
1015
+ }
1016
+
1017
+ // The turn lock: resolves with a release function once every earlier call has finished.
1018
+ async acquire() {
1019
+ if (this.disposed) throw this.disposedError();
1020
+ const prev = this.turn;
1021
+ let release;
1022
+ this.turn = new Promise((r) => { release = r; });
1023
+ await prev;
1024
+ if (this.disposed) { release(); throw this.disposedError(); }
1025
+ return release;
1026
+ }
1027
+ disposedError() { return new DiskformerError('RESOURCE_DISPOSED', 'this engine was disposed'); }
1028
+ contextFull() { return new DiskformerError('CONTEXT_FULL', `context full (${this.maxCtx} tokens)`, { detail: { maxCtx: this.maxCtx } }); }
1029
+ stepError(e) { return isDiskformerError(e) ? e : new DiskformerError('EXECUTION_FAILED', `generate failed: ${(e && e.message) || e}`, { cause: e }); }
1030
+ // Marks the engine disposed and waits for a step in flight, so the device is never destroyed under a running step.
1031
+ async retire() {
1032
+ this.disposed = true;
1033
+ if (this.inflight) await this.inflight.catch(() => {});
1034
+ }
1035
+
1036
+ async *generateTurn(messages, { maxNewTokens = 512, signal, enableThinking = true } = {}) {
977
1037
  const ids = this.tokenizer.encode(this.chatPrompt(messages, { enableThinking }));
978
1038
  const stops = new Set(this.stopTokenIds);
979
1039
  let next = await this.prefill(ids, 'argmax');
@@ -1013,6 +1073,7 @@ export class Qwen3MoeSsd {
1013
1073
  async warmup() {}
1014
1074
 
1015
1075
  async dispose() {
1076
+ await this.retire();
1016
1077
  try { await this.xs.drain(); } catch (_) {}
1017
1078
  try { await this.expertReader.close(); } catch (_) {}
1018
1079
  try { this.device.destroy(); } catch (_) {}
package/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // diskformer — a disk tier for in-browser inference. Keep model weights in OPFS and page into WebGPU only
2
2
  // what each step needs. Plain ESM, no dependencies, no build step.
3
- export { OpfsReaderPool, OpfsWriter, canInline, opfsDir, readOpfsText, writeOpfsText, removeOpfs } from './src/opfs-reader.js';
3
+ export { OpfsReaderPool, OpfsWriter, canInline, opfsDir, readOpfsText, writeOpfsText, removeOpfs, opfsFileSize, opfsSubdirs } from './src/opfs-reader.js';
4
4
  export { ExpertStreamer, ExpertStreamer as RecordPool } from './src/expert-stream.js';
5
5
  export { RowFile, RowCache, fingerprint } from './src/rows.js';
6
6
  export { parseGguf, tensorBytes, splitQ8, splitQ4, GGML, Q8_BLOCK, Q4_BLOCK, Q6K_BLOCK } from './src/gguf.js';
7
- export { ingestGguf, ingestProgress, readHeader, storeKey, removeStore } from './src/ingest.js';
7
+ export { ingestGguf, ingestProgress, readHeader, fileFetch, storeKey, readStore, listStores, removeStore } from './src/ingest.js';
8
+ export { DiskformerError, isDiskformerError, ERROR_CODES } from './src/errors.js';
8
9
  export { measure } from './src/measure.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diskformer",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Run a model larger than your GPU memory in a browser tab: weights stay in OPFS, WebGPU holds only what each step needs. A disk tier plus two reference MoE engines.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/errors.js ADDED
@@ -0,0 +1,36 @@
1
+ /* errors.js — coded errors for the store, ingest and the engines. A host branches on `code`, never on the
2
+ * message text and never on `instanceof` (two copies of this module, e.g. a CDN copy and a vendored one, make
3
+ * instanceof false for the same error). Check with isDiskformerError(e, code).
4
+ *
5
+ * try { await Engine.load(…) } catch (e) { if (isDiskformerError(e, 'LOAD_ABORTED')) … }
6
+ *
7
+ * Codes:
8
+ * LOAD_FAILED ingest or load failed (HTTP, quota, a GGUF the engine cannot run); `cause` holds the original
9
+ * LOAD_ABORTED the caller's signal aborted a load or ingest; decided from `signal.aborted`, not the error name
10
+ * UNSUPPORTED_DEVICE no WebGPU, no adapter, or a missing required feature (shader-f16)
11
+ * BUDGET_TOO_SMALL gpuBudgetBytes cannot hold the fixed GPU part plus the smallest expert cache; `detail` has the bytes
12
+ * CONTEXT_FULL the conversation outgrew the engine's context
13
+ * EXECUTION_FAILED a prefill or decode step failed; `cause` holds the original
14
+ * RESOURCE_DISPOSED the engine was disposed: no further generate()
15
+ */
16
+
17
+ export const ERROR_CODES = Object.freeze(['LOAD_FAILED', 'LOAD_ABORTED', 'UNSUPPORTED_DEVICE', 'BUDGET_TOO_SMALL', 'CONTEXT_FULL', 'EXECUTION_FAILED', 'RESOURCE_DISPOSED']);
18
+
19
+ export class DiskformerError extends Error {
20
+ constructor(code, message, { cause, detail } = {}) {
21
+ super(message, cause === undefined ? undefined : { cause });
22
+ this.name = 'DiskformerError';
23
+ this.code = code;
24
+ if (detail !== undefined) this.detail = detail;
25
+ }
26
+ }
27
+
28
+ export const isDiskformerError = (e, code) => !!e && e.name === 'DiskformerError' && typeof e.code === 'string' && (code === undefined || e.code === code);
29
+
30
+ // Re-code an error from a load or ingest: an aborted signal wins over whatever the abort threw; a coded error
31
+ // passes through; anything else becomes `fallback` with the original as `cause`.
32
+ export function codedError(e, { signal, fallback = 'LOAD_FAILED', what = 'load' } = {}) {
33
+ if (signal && signal.aborted) return isDiskformerError(e, 'LOAD_ABORTED') ? e : new DiskformerError('LOAD_ABORTED', `${what} aborted`, { cause: e });
34
+ if (isDiskformerError(e)) return e;
35
+ return new DiskformerError(fallback, `${what} failed: ${(e && e.message) || e}`, { cause: e });
36
+ }
package/src/ingest.js CHANGED
@@ -11,13 +11,14 @@
11
11
  * // OPFS: <root>/<key>/{header.bin, dense.bin, experts.bin, manifest.json}
12
12
  */
13
13
 
14
- import { OpfsWriter, readOpfsText, writeOpfsText, removeOpfs } from './opfs-reader.js';
14
+ import { OpfsWriter, readOpfsText, writeOpfsText, removeOpfs, opfsFileSize, opfsSubdirs } from './opfs-reader.js';
15
15
  import { parseGguf, splitQ8, splitQ4, Q8_BLOCK, Q4_BLOCK } from './gguf.js';
16
+ import { codedError } from './errors.js';
16
17
 
17
18
  export const ROOT = 'diskformer';
18
19
  export const FORMAT = 'diskformer-gguf/1';
19
20
 
20
- // ── Ingest: GGUF (over HTTP Range) → OPFS, in the engine layout ─────────────
21
+ // ── Ingest: GGUF (over HTTP Range, or a local file) → OPFS, in the engine layout ──
21
22
  export const storeKey = (file) => file.replace(/\.gguf$/i, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-');
22
23
 
23
24
  export async function readHeader(url, fetchFn, signal) {
@@ -33,6 +34,20 @@ export async function readHeader(url, fetchFn, signal) {
33
34
  }
34
35
  }
35
36
 
37
+ // A local file as the source: a fetch-shaped function that answers ingestGguf's Range requests from a Blob (a GGUF
38
+ // the user picked, e.g. from a Hugging Face or LM Studio cache), so nothing is downloaded. Pass it as `fetch`.
39
+ // No ETag: an interrupted ingest resumes from the same file (same header and size), never from a download's state.
40
+ export function fileFetch(blob) {
41
+ return async (_url, { headers = {}, signal } = {}) => {
42
+ if (signal) signal.throwIfAborted();
43
+ const m = /^bytes=(\d+)-(\d+)$/.exec(headers.Range || '');
44
+ if (!m) throw new Error('fileFetch: only Range requests (bytes=a-b) are supported');
45
+ const start = Number(m[1]), end = Math.min(Number(m[2]), blob.size - 1);
46
+ if (start > end) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${blob.size}` } });
47
+ return new Response(blob.slice(start, end + 1), { status: 206, headers: { 'content-range': `bytes ${start}-${end}/${blob.size}` } });
48
+ };
49
+ }
50
+
36
51
  // FNV-1a, for the resume identity: over bytes (the GGUF header) or a string (the plan's output layout).
37
52
  const fnv = (at, n) => { let h = 0x811c9dc5; for (let i = 0; i < n; i++) h = Math.imul(h ^ at(i), 0x01000193) >>> 0; return `${n}:${h.toString(16)}`; };
38
53
  const bytesId = (u8) => fnv((i) => u8[i], u8.length);
@@ -45,7 +60,12 @@ const textId = (s) => fnv((i) => s.charCodeAt(i), s.length);
45
60
  // An interrupted ingest resumes only when the GGUF header, the plan's output layout, and the source's size and ETag
46
61
  // (when the server sends them) match its checkpoint; otherwise it starts over. `rangeBytes` and `io` are for tests:
47
62
  // smaller range requests, and fake OPFS files.
48
- export async function ingestGguf({ url, key, plan, root = ROOT, format = FORMAT, fetch: fetchFn = fetch, onProgress = () => {}, signal, source = {},
63
+ // Errors are coded (errors.js): LOAD_ABORTED when `signal` aborted, else LOAD_FAILED with the original as `cause`.
64
+ export async function ingestGguf(opts) {
65
+ try { return await ingest(opts); } catch (e) { throw codedError(e, { signal: opts && opts.signal, what: 'ingest' }); }
66
+ }
67
+
68
+ async function ingest({ url, key, plan, root = ROOT, format = FORMAT, fetch: fetchFn = fetch, onProgress = () => {}, signal, source = {},
49
69
  rangeBytes = 1 << 30, io = { OpfsWriter, readOpfsText, writeOpfsText } }) {
50
70
  if (!plan || !plan.layout || !plan.units) throw new Error('ingestGguf: plan { layout, units } is required');
51
71
  const t0 = performance.now();
@@ -174,4 +194,41 @@ export function ingestProgress(e) {
174
194
  return e && e.status === 'ingest' ? { ...e, status: 'weights', loaded: e.loaded, total: e.total } : e;
175
195
  }
176
196
 
197
+ // ── The stores on disk: which are usable, which to remove ──
198
+ // A store is usable only when its manifest is complete, in `format`, and every file it names is on disk at its full
199
+ // size. A crash can leave zero-byte or short files behind a complete manifest; those count as invalid, never as a
200
+ // model. Dense files end with up to 255 bytes of alignment padding that is never written, hence the slack.
201
+ // `format` null skips the format check. → { key, state: 'complete' | 'partial' | 'invalid' | 'missing', manifest, bytes, problem }
202
+ const PAD = 255;
203
+ export async function readStore(key, { root = ROOT, format = FORMAT, io = { readOpfsText, opfsFileSize } } = {}) {
204
+ const dir = `${root}/${key}`;
205
+ let manifest = null;
206
+ try { manifest = JSON.parse(await io.readOpfsText(`${dir}/manifest.json`) || 'null'); } catch (_) { manifest = null; }
207
+ // [file, expected bytes, slack]: the header, then every layout entry the manifest names ({ file, bytes }).
208
+ const files = manifest
209
+ ? [['header.bin', manifest.source && manifest.source.dataStart, 0],
210
+ ...Object.values(manifest).filter((v) => v && typeof v.file === 'string' && typeof v.bytes === 'number').map((v) => [v.file, v.bytes, v.file === 'dense.bin' ? PAD : 0])]
211
+ : [['header.bin'], ['dense.bin'], ['experts.bin']];
212
+ const sizes = await Promise.all(files.map(([name]) => io.opfsFileSize(`${dir}/${name}`)));
213
+ const bytes = sizes.reduce((a, b) => a + (b || 0), 0);
214
+ const out = (state, problem) => ({ key, state, manifest, bytes, problem });
215
+ if (!manifest) return out(bytes ? 'invalid' : 'missing', bytes ? 'no readable manifest' : null);
216
+ if (!manifest.complete) return out('partial', 'ingest not finished');
217
+ if (format && manifest.format !== format) return out('invalid', `format ${manifest.format}, expected ${format}`);
218
+ for (let i = 0; i < files.length; i++) {
219
+ const [name, want, slack] = files[i], have = sizes[i];
220
+ if (want === 0 && have !== null) continue; // a layout entry with no bytes (a dense model's experts.bin)
221
+ if (!have) return out('invalid', `${name} is ${have === null ? 'missing' : 'empty'}`);
222
+ if (typeof want === 'number' && have < want - slack) return out('invalid', `${name} is ${have} bytes, expected ${want}`);
223
+ }
224
+ return out('complete', null);
225
+ }
226
+
227
+ // Every store under `root`, for a "models in this browser" list where the user removes one and keeps the rest.
228
+ // `format` null accepts any format (engines write their own; the chat page lists them all).
229
+ export async function listStores({ root = ROOT, format = null, io = { readOpfsText, opfsFileSize, opfsSubdirs } } = {}) {
230
+ return Promise.all((await io.opfsSubdirs(root)).map((key) => readStore(key, { root, format, io })));
231
+ }
232
+
233
+ // Removes one store by key; the others under `root` stay.
177
234
  export async function removeStore(key, { root = ROOT } = {}) { await removeOpfs(`${root}/${key}`); }
@@ -256,3 +256,21 @@ export async function removeOpfs(path) {
256
256
  const dir = await opfsDir(parts.slice(0, -1).join('/'));
257
257
  await dir.removeEntry(parts[parts.length - 1], { recursive: true });
258
258
  }
259
+
260
+ // Size in bytes of an OPFS file, or null when it does not exist.
261
+ export async function opfsFileSize(path) {
262
+ const parts = path.split('/').filter(Boolean);
263
+ try {
264
+ const dir = await opfsDir(parts.slice(0, -1).join('/'));
265
+ return (await (await dir.getFileHandle(parts[parts.length - 1])).getFile()).size;
266
+ } catch (_) { return null; }
267
+ }
268
+
269
+ // Names of the directories directly under an OPFS path; [] when the path does not exist.
270
+ export async function opfsSubdirs(path) {
271
+ let dir;
272
+ try { dir = await opfsDir(path); } catch (_) { return []; }
273
+ const out = [];
274
+ for await (const [name, h] of dir.entries()) if (h.kind === 'directory') out.push(name);
275
+ return out.sort();
276
+ }