WaveCut commited on
Commit
c2b7fe3
·
verified ·
1 Parent(s): 48cbfef

Add Phase 5 benchmark and runtime provenance

Browse files
.gitattributes CHANGED
@@ -1,3 +1 @@
1
  public/wasm/*.wasm filter=lfs diff=lfs merge=lfs -text
2
- dist/wasm/wllama-compat.wasm filter=lfs diff=lfs merge=lfs -text
3
- dist/wasm/wllama.wasm filter=lfs diff=lfs merge=lfs -text
 
1
  public/wasm/*.wasm filter=lfs diff=lfs merge=lfs -text
 
 
README.md CHANGED
@@ -62,6 +62,32 @@ path. Edge/Windows, Safari, and Firefox remain explicit release-matrix gaps;
62
  dense CPU fallback is offered only when that browser exposes the required
63
  cross-origin-isolated WASM features.
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  ## DFlash research lane
66
 
67
  DFlash is visible only as an inspection-only experiment. Its BF16 source and
@@ -88,6 +114,9 @@ stay in the browser; only model/static asset downloads contact Hugging Face.
88
  - Dawn native WebGPU validation: `18eb229ef5f707c1464cc581252e7603c73a3ef0`
89
  - custom wllama source: `912c18b75d4358c1405a64646b8dbe43a205943b`
90
  - custom nested llama.cpp: `00fa7cb284cbf133fc426733bd64238a3588a33e`
 
 
 
91
 
92
  The vendored runtime exposes the validated token-embedding WebGPU placement
93
  needed for single-graph Bonsai inference and fails closed when placement or the
@@ -96,8 +125,9 @@ backend tripwire disagrees. Its exact JS/WASM hashes are checked during
96
  `vendor/wllama-bonsai/SOURCE.json`. Third-party notices are in
97
  `THIRD_PARTY_NOTICES.md` and `public/licenses/`.
98
 
99
- Flash Attention remains off for the release path because quantized K/V
100
- combinations are not fully supported by the validated WebGPU backend.
 
101
 
102
  ## Licenses and attribution
103
 
 
62
  dense CPU fallback is offered only when that browser exposes the required
63
  cross-origin-isolated WASM features.
64
 
65
+ ## Growth benchmark
66
+
67
+ [`/bench/`](https://wavecut-bonsai-webgpu-chat.static.hf.space/bench/) is a
68
+ separate, shareable benchmark surface. It loads nothing until **Run benchmark**
69
+ is pressed, then records first-load and verified-cache reload wall time, TTFT,
70
+ prefill/decode throughput, graph placement, device limits, engine revisions,
71
+ the exact runtime artifact hash, and a fixed temperature-0 prompt. The report
72
+ exports as JSON; generated model text is deliberately excluded.
73
+
74
+ `n_batch` and `n_ubatch` are available there as explicitly experimental shape
75
+ controls. The browser result reports both requested and actual llama.cpp
76
+ values. A pinned 1.7B WebGPU smoke proved `256/128` end to end with one graph,
77
+ 29/29 GPU layers, and zero CPU ops. Flash Attention remains locked off and KV
78
+ remains F16/F16 on this page until candidate settings pass graph and output
79
+ parity checks.
80
+
81
+ The public prewarmed-cache baseline below is one Apple `metal-3` / headless
82
+ Chrome sample, not a cross-device study and not a first-download claim:
83
+
84
+ | Tier | Backend | Load | TTFT | Decode |
85
+ | --- | --- | ---: | ---: | ---: |
86
+ | 1.7B | WebGPU | 3,314 ms | 300 ms | 71.9 tok/s |
87
+ | 1.7B | CPU-WASM | 3,844 ms | 6,092 ms | 10.6 tok/s |
88
+ | 8B | WebGPU | 9,870 ms | 998 ms | 33.8 tok/s |
89
+ | 27B | WebGPU | 29,488 ms | 3,348 ms | 11.9 tok/s |
90
+
91
  ## DFlash research lane
92
 
93
  DFlash is visible only as an inspection-only experiment. Its BF16 source and
 
114
  - Dawn native WebGPU validation: `18eb229ef5f707c1464cc581252e7603c73a3ef0`
115
  - custom wllama source: `912c18b75d4358c1405a64646b8dbe43a205943b`
116
  - custom nested llama.cpp: `00fa7cb284cbf133fc426733bd64238a3588a33e`
117
+ - custom patch set: `62efebdb3071ba49b87c835c1b45716257fe5ea6cb7eec6ee82f6a2f33bc581c`
118
+ - JSPI WASM: `dd71f58c75f32c677a64eeb2e56efab7dedbe7395f730bb08bc80021bd182caa`
119
+ - compat WASM: `eca9754d0d8a490b5c7b58cb384d67e31aa8ab26dd330e4ad1460982df6dd82e`
120
 
121
  The vendored runtime exposes the validated token-embedding WebGPU placement
122
  needed for single-graph Bonsai inference and fails closed when placement or the
 
125
  `vendor/wllama-bonsai/SOURCE.json`. Third-party notices are in
126
  `THIRD_PARTY_NOTICES.md` and `public/licenses/`.
127
 
128
+ The pinned WebGPU source implements Flash Attention with Q4_0/Q8_0 K/V, but
129
+ those combinations are not yet validated for Bonsai browser parity. Flash
130
+ Attention therefore remains off for the release path.
131
 
132
  ## Licenses and attribution
133
 
bench/index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#111512" />
7
+ <link rel="icon" href="data:," />
8
+ <meta
9
+ name="description"
10
+ content="Bonsai browser-local benchmark — repeatable cold load, warm load, prefill, and decode evidence."
11
+ />
12
+ <title>Bonsai Growth Bench</title>
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.tsx"></script>
17
+ </body>
18
+ </html>
public/wasm/wllama-compat.wasm CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:609979201d693d58cd23972113af5cbe08d0a8e16deef26fc3c9d230c0228eed
3
- size 14864168
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eca9754d0d8a490b5c7b58cb384d67e31aa8ab26dd330e4ad1460982df6dd82e
3
+ size 14864188
public/wasm/wllama.wasm CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:829dd6c01e99893c7a0e2f9c00a97b1578ce30f0d03720b1eab5f3bc3063f973
3
- size 8017146
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dd71f58c75f32c677a64eeb2e56efab7dedbe7395f730bb08bc80021bd182caa
3
+ size 8017169
scripts/verify-wllama-assets.mjs CHANGED
@@ -12,9 +12,20 @@ if (source.wllamaRevision !== '912c18b75d4358c1405a64646b8dbe43a205943b') {
12
  if (source.llamaCppRevision !== '00fa7cb284cbf133fc426733bd64238a3588a33e') {
13
  throw new Error(`Unexpected llama.cpp revision: ${String(source.llamaCppRevision)}`);
14
  }
 
 
 
 
 
 
 
 
15
 
16
  for (const file of source.files) {
17
  const bytes = await readFile(resolve(root, file.path));
 
 
 
18
  const digest = createHash('sha256').update(bytes).digest('hex');
19
  if (digest !== file.sha256) {
20
  throw new Error(`SHA-256 mismatch for ${file.path}: ${digest}`);
@@ -26,3 +37,10 @@ const declarations = await readFile(resolve(root, 'vendor/wllama-bonsai/esm/type
26
  if (!declarations.includes('offload_token_embedding?: boolean')) {
27
  throw new Error('Vendored Bonsai wllama declarations lack offload_token_embedding');
28
  }
 
 
 
 
 
 
 
 
12
  if (source.llamaCppRevision !== '00fa7cb284cbf133fc426733bd64238a3588a33e') {
13
  throw new Error(`Unexpected llama.cpp revision: ${String(source.llamaCppRevision)}`);
14
  }
15
+ if (source.schemaVersion !== 2 || !source.patchSet?.sha256 || !source.build?.dawnArchiveSha256) {
16
+ throw new Error('Vendored Bonsai wllama provenance is incomplete');
17
+ }
18
+ const patchBytes = await readFile(resolve(root, source.patchSet.path));
19
+ const patchDigest = createHash('sha256').update(patchBytes).digest('hex');
20
+ if (patchBytes.byteLength !== source.patchSet.bytes || patchDigest !== source.patchSet.sha256) {
21
+ throw new Error(`Bonsai wllama patch-set mismatch: ${patchDigest}`);
22
+ }
23
 
24
  for (const file of source.files) {
25
  const bytes = await readFile(resolve(root, file.path));
26
+ if (bytes.byteLength !== file.bytes) {
27
+ throw new Error(`Byte-size mismatch for ${file.path}: ${bytes.byteLength}`);
28
+ }
29
  const digest = createHash('sha256').update(bytes).digest('hex');
30
  if (digest !== file.sha256) {
31
  throw new Error(`SHA-256 mismatch for ${file.path}: ${digest}`);
 
37
  if (!declarations.includes('offload_token_embedding?: boolean')) {
38
  throw new Error('Vendored Bonsai wllama declarations lack offload_token_embedding');
39
  }
40
+ if (!declarations.includes('n_ubatch?: number')) {
41
+ throw new Error('Vendored Bonsai wllama declarations lack n_ubatch');
42
+ }
43
+ const moduleSource = await readFile(resolve(root, 'vendor/wllama-bonsai/esm/index.js'), 'utf8');
44
+ if (!moduleSource.includes('n_ubatch: params.n_ubatch')) {
45
+ throw new Error('Vendored Bonsai wllama module does not forward n_ubatch');
46
+ }
src/app/App.test.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { describe, expect, it } from 'vitest';
2
  import { EngineClientError } from '../engine';
3
- import { requiresModelReload } from './App';
4
 
5
  function engineError(code: string): EngineClientError {
6
  return new EngineClientError({ code, message: code });
@@ -19,3 +19,22 @@ describe('App model invalidation policy', () => {
19
  expect(requiresModelReload(engineError('ABORTED'))).toBe(false);
20
  });
21
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { describe, expect, it } from 'vitest';
2
  import { EngineClientError } from '../engine';
3
+ import { reconcileCompletionTokens, reconcileTotalTokens, requiresModelReload } from './App';
4
 
5
  function engineError(code: string): EngineClientError {
6
  return new EngineClientError({ code, message: code });
 
19
  expect(requiresModelReload(engineError('ABORTED'))).toBe(false);
20
  });
21
  });
22
+
23
+ describe('App streamed completion telemetry', () => {
24
+ it('uses observed stream events when final engine usage is incomplete', () => {
25
+ expect(reconcileCompletionTokens(5, 64)).toBe(64);
26
+ });
27
+
28
+ it('keeps the engine count when one stream event contains multiple tokens', () => {
29
+ expect(reconcileCompletionTokens(12, 8)).toBe(12);
30
+ });
31
+
32
+ it('fails closed to the observed count for invalid engine usage', () => {
33
+ expect(reconcileCompletionTokens(Number.NaN, 7)).toBe(7);
34
+ });
35
+
36
+ it('keeps context usage consistent with a reconciled completion count', () => {
37
+ expect(reconcileTotalTokens(5, 5, 64)).toBe(69);
38
+ expect(reconcileTotalTokens(80, 5, 64)).toBe(80);
39
+ });
40
+ });
src/app/App.tsx CHANGED
@@ -148,6 +148,39 @@ export function requiresModelReload(error: unknown): error is EngineClientError
148
  ].includes(error.code);
149
  }
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  function appendSegment(current: string, next: string): string {
152
  const trimmed = next.trim();
153
  if (!trimmed) return current;
@@ -659,8 +692,19 @@ export function App() {
659
  },
660
  });
661
 
662
- totalCompletionTokens += result.usage?.completionTokens ?? streamedTokenEvents;
663
- lastTotalTokens = result.usage?.totalTokens ?? lastTotalTokens;
 
 
 
 
 
 
 
 
 
 
 
664
  tokensPerSecond = result.timings?.predictedTokensPerSecond ?? tokensPerSecond;
665
  const thought = parseThoughtStream(result.text);
666
  const textual = parseToolCalls(thought.content);
@@ -752,7 +796,7 @@ export function App() {
752
  }));
753
  setTelemetry((current) => ({
754
  ...current,
755
- contextUsed: Math.min(current.contextLimit, lastTotalTokens || totalCompletionTokens),
756
  tokensPerSecond,
757
  timeToFirstTokenMs: ttft,
758
  graphSplits: report.nGraphSplits,
 
148
  ].includes(error.code);
149
  }
150
 
151
+ export function reconcileCompletionTokens(
152
+ engineCompletionTokens: number | undefined,
153
+ streamedTokenEvents: number,
154
+ ): number {
155
+ const engineCount = typeof engineCompletionTokens === 'number'
156
+ && Number.isSafeInteger(engineCompletionTokens)
157
+ && engineCompletionTokens >= 0
158
+ ? engineCompletionTokens
159
+ : 0;
160
+ const streamCount = Number.isSafeInteger(streamedTokenEvents) && streamedTokenEvents >= 0
161
+ ? streamedTokenEvents
162
+ : 0;
163
+ return Math.max(engineCount, streamCount);
164
+ }
165
+
166
+ export function reconcileTotalTokens(
167
+ engineTotalTokens: number | undefined,
168
+ enginePromptTokens: number | undefined,
169
+ completionTokens: number,
170
+ ): number {
171
+ const totalCount = typeof engineTotalTokens === 'number'
172
+ && Number.isSafeInteger(engineTotalTokens)
173
+ && engineTotalTokens >= 0
174
+ ? engineTotalTokens
175
+ : 0;
176
+ const promptCount = typeof enginePromptTokens === 'number'
177
+ && Number.isSafeInteger(enginePromptTokens)
178
+ && enginePromptTokens >= 0
179
+ ? enginePromptTokens
180
+ : 0;
181
+ return Math.max(totalCount, promptCount + completionTokens);
182
+ }
183
+
184
  function appendSegment(current: string, next: string): string {
185
  const trimmed = next.trim();
186
  if (!trimmed) return current;
 
692
  },
693
  });
694
 
695
+ const roundCompletionTokens = reconcileCompletionTokens(
696
+ result.usage?.completionTokens,
697
+ streamedTokenEvents,
698
+ );
699
+ totalCompletionTokens += roundCompletionTokens;
700
+ lastTotalTokens = Math.max(
701
+ lastTotalTokens,
702
+ reconcileTotalTokens(
703
+ result.usage?.totalTokens,
704
+ result.usage?.promptTokens,
705
+ roundCompletionTokens,
706
+ ),
707
+ );
708
  tokensPerSecond = result.timings?.predictedTokensPerSecond ?? tokensPerSecond;
709
  const thought = parseThoughtStream(result.text);
710
  const textual = parseToolCalls(thought.content);
 
796
  }));
797
  setTelemetry((current) => ({
798
  ...current,
799
+ contextUsed: Math.min(current.contextLimit, Math.max(lastTotalTokens, totalCompletionTokens)),
800
  tokensPerSecond,
801
  timeToFirstTokenMs: ttft,
802
  graphSplits: report.nGraphSplits,
src/app/styles.css CHANGED
@@ -2155,3 +2155,609 @@ button:disabled {
2155
  transition-duration: 0.01ms !important;
2156
  }
2157
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2155
  transition-duration: 0.01ms !important;
2156
  }
2157
  }
2158
+
2159
+ /* Growth bench: a companion field instrument, served independently at /bench/. */
2160
+ .bench-page {
2161
+ min-height: 100dvh;
2162
+ color: var(--ink);
2163
+ background: var(--paper);
2164
+ }
2165
+
2166
+ .bench-masthead {
2167
+ min-height: 86px;
2168
+ display: grid;
2169
+ grid-template-columns: minmax(310px, 1fr) minmax(360px, 1.2fr) auto;
2170
+ align-items: stretch;
2171
+ color: var(--paper);
2172
+ border-bottom: 1px solid var(--phosphor);
2173
+ background: var(--ink);
2174
+ }
2175
+
2176
+ .bench-masthead .brand-block h1 {
2177
+ color: var(--paper);
2178
+ }
2179
+
2180
+ .bench-masthead-note {
2181
+ display: flex;
2182
+ flex-direction: column;
2183
+ justify-content: center;
2184
+ gap: 5px;
2185
+ padding: 14px 28px;
2186
+ border-inline: 1px solid var(--line-light);
2187
+ background: linear-gradient(110deg, rgba(185, 255, 69, 0.09), transparent 72%);
2188
+ }
2189
+
2190
+ .bench-masthead-note strong {
2191
+ color: var(--phosphor);
2192
+ font: 700 9px/1 var(--mono);
2193
+ letter-spacing: 0.11em;
2194
+ text-transform: uppercase;
2195
+ }
2196
+
2197
+ .bench-masthead-note span {
2198
+ max-width: 520px;
2199
+ color: rgba(232, 223, 202, 0.55);
2200
+ font: 10px/1.35 var(--body);
2201
+ }
2202
+
2203
+ .bench-return {
2204
+ display: flex;
2205
+ align-items: center;
2206
+ gap: 8px;
2207
+ padding: 0 25px;
2208
+ color: var(--paper);
2209
+ font: 700 8px/1 var(--mono);
2210
+ letter-spacing: 0.08em;
2211
+ text-decoration: none;
2212
+ text-transform: uppercase;
2213
+ }
2214
+
2215
+ .bench-return:hover {
2216
+ color: var(--phosphor);
2217
+ }
2218
+
2219
+ .bench-layout {
2220
+ min-height: calc(100dvh - 86px);
2221
+ display: grid;
2222
+ grid-template-columns: minmax(275px, 330px) minmax(0, 1fr);
2223
+ }
2224
+
2225
+ .bench-controls {
2226
+ padding: 28px 22px;
2227
+ color: var(--paper);
2228
+ border-right: 1px solid var(--phosphor);
2229
+ background:
2230
+ repeating-radial-gradient(ellipse at -12% 102%, transparent 0 24px, rgba(185, 255, 69, 0.045) 25px 26px),
2231
+ var(--ink);
2232
+ }
2233
+
2234
+ .bench-controls h2 {
2235
+ margin: 0;
2236
+ font: 400 30px/1 var(--display);
2237
+ letter-spacing: -0.03em;
2238
+ }
2239
+
2240
+ .bench-control-intro {
2241
+ margin: 9px 0 22px;
2242
+ color: rgba(232, 223, 202, 0.5);
2243
+ font-size: 11px;
2244
+ line-height: 1.45;
2245
+ }
2246
+
2247
+ .bench-controls form {
2248
+ display: grid;
2249
+ gap: 15px;
2250
+ }
2251
+
2252
+ .bench-field {
2253
+ min-width: 0;
2254
+ display: grid;
2255
+ gap: 6px;
2256
+ }
2257
+
2258
+ .bench-field > span {
2259
+ color: rgba(232, 223, 202, 0.78);
2260
+ font: 700 8px/1 var(--mono);
2261
+ letter-spacing: 0.08em;
2262
+ text-transform: uppercase;
2263
+ }
2264
+
2265
+ .bench-field select,
2266
+ .bench-field input {
2267
+ width: 100%;
2268
+ min-width: 0;
2269
+ height: 39px;
2270
+ padding: 0 10px;
2271
+ color: var(--paper);
2272
+ border: 1px solid rgba(232, 223, 202, 0.22);
2273
+ border-radius: 0;
2274
+ outline: 0;
2275
+ background: var(--ink-raised);
2276
+ font: 10px/1 var(--mono);
2277
+ }
2278
+
2279
+ .bench-field input::placeholder {
2280
+ color: rgba(232, 223, 202, 0.25);
2281
+ }
2282
+
2283
+ .bench-field select:disabled,
2284
+ .bench-field input:disabled {
2285
+ opacity: 0.5;
2286
+ }
2287
+
2288
+ .bench-field small {
2289
+ color: rgba(232, 223, 202, 0.35);
2290
+ font: 8px/1.2 var(--mono);
2291
+ }
2292
+
2293
+ .bench-field-pair {
2294
+ display: grid;
2295
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2296
+ gap: 10px;
2297
+ }
2298
+
2299
+ .bench-experimental {
2300
+ padding: 11px;
2301
+ border: 1px dashed rgba(201, 111, 67, 0.48);
2302
+ background: rgba(201, 111, 67, 0.05);
2303
+ }
2304
+
2305
+ .bench-experimental summary {
2306
+ color: #e19a76;
2307
+ font: 700 8px/1 var(--mono);
2308
+ letter-spacing: 0.07em;
2309
+ text-transform: uppercase;
2310
+ cursor: pointer;
2311
+ }
2312
+
2313
+ .bench-experimental > p {
2314
+ margin: 10px 0;
2315
+ color: rgba(232, 223, 202, 0.46);
2316
+ font-size: 9px;
2317
+ line-height: 1.4;
2318
+ }
2319
+
2320
+ .bench-locks {
2321
+ display: grid;
2322
+ gap: 5px;
2323
+ margin: 10px 0 0;
2324
+ color: rgba(232, 223, 202, 0.44);
2325
+ font: 7px/1.35 var(--mono);
2326
+ }
2327
+
2328
+ .bench-locks div {
2329
+ display: flex;
2330
+ justify-content: space-between;
2331
+ gap: 8px;
2332
+ }
2333
+
2334
+ .bench-locks dt,
2335
+ .bench-locks dd {
2336
+ margin: 0;
2337
+ }
2338
+
2339
+ .bench-locks dt {
2340
+ color: rgba(232, 223, 202, 0.72);
2341
+ }
2342
+
2343
+ .bench-gate {
2344
+ display: grid;
2345
+ gap: 5px;
2346
+ padding: 10px;
2347
+ border-left: 2px solid var(--rust);
2348
+ background: rgba(201, 111, 67, 0.08);
2349
+ }
2350
+
2351
+ .bench-gate.pass {
2352
+ border-color: var(--phosphor);
2353
+ background: var(--phosphor-dim);
2354
+ }
2355
+
2356
+ .bench-gate strong {
2357
+ color: #ff9a68;
2358
+ font: 700 8px/1 var(--mono);
2359
+ letter-spacing: 0.06em;
2360
+ text-transform: uppercase;
2361
+ }
2362
+
2363
+ .bench-gate.pass strong {
2364
+ color: var(--phosphor);
2365
+ }
2366
+
2367
+ .bench-gate span {
2368
+ color: rgba(232, 223, 202, 0.5);
2369
+ font: 8px/1.4 var(--mono);
2370
+ overflow-wrap: anywhere;
2371
+ }
2372
+
2373
+ .bench-run-actions {
2374
+ display: grid;
2375
+ grid-template-columns: 1fr auto;
2376
+ gap: 8px;
2377
+ }
2378
+
2379
+ .bench-run-button,
2380
+ .bench-stop-button,
2381
+ .bench-section-heading button {
2382
+ min-height: 38px;
2383
+ padding: 0 13px;
2384
+ border: 1px solid var(--ink);
2385
+ border-radius: 0;
2386
+ background: var(--phosphor);
2387
+ font: 800 8px/1 var(--mono);
2388
+ letter-spacing: 0.08em;
2389
+ text-transform: uppercase;
2390
+ cursor: pointer;
2391
+ }
2392
+
2393
+ .bench-run-button:disabled {
2394
+ opacity: 0.35;
2395
+ }
2396
+
2397
+ .bench-stop-button {
2398
+ color: #ffb08a;
2399
+ border-color: var(--rust);
2400
+ background: transparent;
2401
+ }
2402
+
2403
+ .bench-workbench {
2404
+ min-width: 0;
2405
+ padding: clamp(26px, 4vw, 58px);
2406
+ background:
2407
+ linear-gradient(90deg, rgba(17, 21, 18, 0.045) 1px, transparent 1px) 0 0 / 22px 22px,
2408
+ linear-gradient(rgba(17, 21, 18, 0.04) 1px, transparent 1px) 0 0 / 22px 22px,
2409
+ var(--paper);
2410
+ }
2411
+
2412
+ .bench-hero {
2413
+ display: grid;
2414
+ grid-template-columns: minmax(320px, 1.3fr) minmax(260px, 0.7fr);
2415
+ align-items: end;
2416
+ gap: 34px;
2417
+ padding-bottom: 29px;
2418
+ border-bottom: 1px solid var(--line-dark);
2419
+ }
2420
+
2421
+ .bench-hero h2 {
2422
+ max-width: 690px;
2423
+ margin: 0;
2424
+ font: 400 clamp(42px, 6vw, 82px)/0.84 var(--display);
2425
+ letter-spacing: -0.065em;
2426
+ }
2427
+
2428
+ .bench-hero h2 em {
2429
+ color: #4f7125;
2430
+ font-weight: 400;
2431
+ }
2432
+
2433
+ .bench-hero > p {
2434
+ max-width: 430px;
2435
+ margin: 0;
2436
+ color: rgba(17, 21, 18, 0.58);
2437
+ font-size: 11px;
2438
+ line-height: 1.55;
2439
+ }
2440
+
2441
+ .bench-progress {
2442
+ display: grid;
2443
+ gap: 8px;
2444
+ padding: 16px 0;
2445
+ }
2446
+
2447
+ .bench-progress > div {
2448
+ display: flex;
2449
+ justify-content: space-between;
2450
+ gap: 20px;
2451
+ font: 8px/1 var(--mono);
2452
+ letter-spacing: 0.05em;
2453
+ text-transform: uppercase;
2454
+ }
2455
+
2456
+ .bench-progress > div span {
2457
+ color: rgba(17, 21, 18, 0.45);
2458
+ }
2459
+
2460
+ .bench-progress-track {
2461
+ height: 3px;
2462
+ background: rgba(17, 21, 18, 0.13);
2463
+ }
2464
+
2465
+ .bench-progress-track i {
2466
+ display: block;
2467
+ height: 100%;
2468
+ background: #527426;
2469
+ transition: width 180ms ease;
2470
+ }
2471
+
2472
+ .bench-error {
2473
+ display: grid;
2474
+ gap: 5px;
2475
+ margin-bottom: 18px;
2476
+ padding: 12px 14px;
2477
+ color: var(--paper);
2478
+ border-left: 3px solid var(--rust);
2479
+ background: var(--ink);
2480
+ }
2481
+
2482
+ .bench-error strong {
2483
+ color: #ff9a68;
2484
+ font: 700 8px/1 var(--mono);
2485
+ letter-spacing: 0.08em;
2486
+ text-transform: uppercase;
2487
+ }
2488
+
2489
+ .bench-error span {
2490
+ font: 9px/1.4 var(--mono);
2491
+ overflow-wrap: anywhere;
2492
+ }
2493
+
2494
+ .bench-core-section {
2495
+ margin-top: 13px;
2496
+ }
2497
+
2498
+ .bench-section-heading {
2499
+ display: flex;
2500
+ align-items: end;
2501
+ justify-content: space-between;
2502
+ gap: 20px;
2503
+ margin-bottom: 13px;
2504
+ }
2505
+
2506
+ .bench-section-heading h3,
2507
+ .bench-output h3 {
2508
+ margin: 0;
2509
+ font: 500 24px/1 var(--display);
2510
+ }
2511
+
2512
+ .bench-section-heading button {
2513
+ min-height: 31px;
2514
+ color: var(--paper);
2515
+ border-color: var(--ink);
2516
+ background: var(--ink);
2517
+ }
2518
+
2519
+ .bench-core-sample {
2520
+ position: relative;
2521
+ overflow: hidden;
2522
+ display: grid;
2523
+ grid-template-columns: repeat(4, minmax(0, 1fr));
2524
+ border: 1px solid var(--ink);
2525
+ background: var(--paper-deep);
2526
+ }
2527
+
2528
+ .bench-core-sample::before {
2529
+ position: absolute;
2530
+ inset: -130% -5%;
2531
+ content: "";
2532
+ pointer-events: none;
2533
+ opacity: 0.35;
2534
+ background: repeating-radial-gradient(ellipse at center, transparent 0 34px, rgba(17, 21, 18, 0.17) 35px 36px);
2535
+ }
2536
+
2537
+ .bench-core-sample article {
2538
+ position: relative;
2539
+ min-height: 128px;
2540
+ display: flex;
2541
+ flex-direction: column;
2542
+ justify-content: space-between;
2543
+ padding: 17px;
2544
+ border-right: 1px solid rgba(17, 21, 18, 0.26);
2545
+ background: rgba(232, 223, 202, 0.57);
2546
+ }
2547
+
2548
+ .bench-core-sample article:nth-child(even) {
2549
+ background: rgba(216, 205, 180, 0.6);
2550
+ }
2551
+
2552
+ .bench-core-sample article:last-child {
2553
+ color: var(--paper);
2554
+ border-right: 0;
2555
+ background: rgba(17, 21, 18, 0.91);
2556
+ }
2557
+
2558
+ .bench-core-sample article > span {
2559
+ font: 800 8px/1 var(--mono);
2560
+ letter-spacing: 0.09em;
2561
+ text-transform: uppercase;
2562
+ }
2563
+
2564
+ .bench-core-sample article strong {
2565
+ margin-block: auto;
2566
+ font: 400 clamp(28px, 3vw, 43px)/1 var(--display);
2567
+ letter-spacing: -0.04em;
2568
+ }
2569
+
2570
+ .bench-core-sample article small {
2571
+ color: rgba(17, 21, 18, 0.5);
2572
+ font: 7px/1.2 var(--mono);
2573
+ text-transform: uppercase;
2574
+ }
2575
+
2576
+ .bench-core-sample article:last-child small {
2577
+ color: rgba(232, 223, 202, 0.48);
2578
+ }
2579
+
2580
+ .bench-detail-grid {
2581
+ display: grid;
2582
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2583
+ gap: 12px;
2584
+ margin-top: 12px;
2585
+ }
2586
+
2587
+ .bench-data-card {
2588
+ padding: 16px;
2589
+ border: 1px solid var(--line-dark);
2590
+ background: rgba(232, 223, 202, 0.68);
2591
+ }
2592
+
2593
+ .bench-runtime-card {
2594
+ grid-column: span 2;
2595
+ }
2596
+
2597
+ .bench-data-card dl {
2598
+ display: grid;
2599
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2600
+ gap: 12px 26px;
2601
+ margin: 0;
2602
+ }
2603
+
2604
+ .bench-runtime-card dl {
2605
+ grid-template-columns: minmax(180px, 1.5fr) repeat(3, minmax(100px, 0.7fr));
2606
+ }
2607
+
2608
+ .bench-data-card dl div {
2609
+ min-width: 0;
2610
+ display: grid;
2611
+ gap: 4px;
2612
+ border-top: 1px solid rgba(17, 21, 18, 0.13);
2613
+ padding-top: 8px;
2614
+ }
2615
+
2616
+ .bench-data-card dt {
2617
+ color: rgba(17, 21, 18, 0.45);
2618
+ font: 7px/1 var(--mono);
2619
+ letter-spacing: 0.07em;
2620
+ text-transform: uppercase;
2621
+ }
2622
+
2623
+ .bench-data-card dd {
2624
+ min-width: 0;
2625
+ overflow: hidden;
2626
+ margin: 0;
2627
+ font: 500 16px/1.2 var(--body);
2628
+ text-overflow: ellipsis;
2629
+ white-space: nowrap;
2630
+ }
2631
+
2632
+ .bench-output {
2633
+ display: grid;
2634
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2635
+ gap: 34px;
2636
+ margin-top: 12px;
2637
+ padding: 21px;
2638
+ border: 1px dashed rgba(17, 21, 18, 0.3);
2639
+ }
2640
+
2641
+ .bench-output > div:last-child {
2642
+ border-left: 1px solid var(--line-dark);
2643
+ padding-left: 34px;
2644
+ }
2645
+
2646
+ .bench-output p:not(.eyebrow) {
2647
+ margin: 8px 0 0;
2648
+ color: rgba(17, 21, 18, 0.6);
2649
+ font-size: 11px;
2650
+ line-height: 1.5;
2651
+ white-space: pre-wrap;
2652
+ }
2653
+
2654
+ @media (max-width: 1050px) {
2655
+ .bench-masthead {
2656
+ grid-template-columns: minmax(310px, 1fr) auto;
2657
+ }
2658
+
2659
+ .bench-masthead-note {
2660
+ display: none;
2661
+ }
2662
+
2663
+ .bench-layout {
2664
+ grid-template-columns: 285px minmax(0, 1fr);
2665
+ }
2666
+
2667
+ .bench-core-sample {
2668
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2669
+ }
2670
+
2671
+ .bench-core-sample article:nth-child(2) {
2672
+ border-right: 0;
2673
+ }
2674
+
2675
+ .bench-core-sample article:nth-child(-n + 2) {
2676
+ border-bottom: 1px solid rgba(17, 21, 18, 0.26);
2677
+ }
2678
+
2679
+ .bench-runtime-card dl {
2680
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2681
+ }
2682
+ }
2683
+
2684
+ @media (max-width: 760px) {
2685
+ .bench-masthead {
2686
+ display: flex;
2687
+ align-items: center;
2688
+ }
2689
+
2690
+ .bench-masthead .brand-block {
2691
+ flex: 1;
2692
+ min-width: 0;
2693
+ }
2694
+
2695
+ .bench-return {
2696
+ padding: 0 15px;
2697
+ font-size: 0;
2698
+ }
2699
+
2700
+ .bench-return span {
2701
+ font-size: 17px;
2702
+ }
2703
+
2704
+ .bench-layout {
2705
+ display: block;
2706
+ }
2707
+
2708
+ .bench-controls {
2709
+ border-right: 0;
2710
+ border-bottom: 1px solid var(--phosphor);
2711
+ }
2712
+
2713
+ .bench-workbench {
2714
+ padding: 28px 16px;
2715
+ }
2716
+
2717
+ .bench-hero,
2718
+ .bench-output {
2719
+ grid-template-columns: 1fr;
2720
+ gap: 18px;
2721
+ }
2722
+
2723
+ .bench-hero h2 {
2724
+ font-size: clamp(42px, 14vw, 68px);
2725
+ }
2726
+
2727
+ .bench-detail-grid {
2728
+ display: block;
2729
+ }
2730
+
2731
+ .bench-data-card + .bench-data-card {
2732
+ margin-top: 10px;
2733
+ }
2734
+
2735
+ .bench-output > div:last-child {
2736
+ border-top: 1px solid var(--line-dark);
2737
+ border-left: 0;
2738
+ padding-top: 18px;
2739
+ padding-left: 0;
2740
+ }
2741
+ }
2742
+
2743
+ @media (max-width: 480px) {
2744
+ .bench-masthead .brand-mark {
2745
+ display: none;
2746
+ }
2747
+
2748
+ .bench-core-sample,
2749
+ .bench-data-card dl,
2750
+ .bench-runtime-card dl {
2751
+ grid-template-columns: 1fr;
2752
+ }
2753
+
2754
+ .bench-core-sample article {
2755
+ min-height: 105px;
2756
+ border-right: 0;
2757
+ border-bottom: 1px solid rgba(17, 21, 18, 0.26);
2758
+ }
2759
+
2760
+ .bench-core-sample article:last-child {
2761
+ border-bottom: 0;
2762
+ }
2763
+ }
src/bench/BenchApp.tsx ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ BrowserEngineClient,
4
+ EngineClientError,
5
+ evaluateModelGate,
6
+ loadModelManifestV2,
7
+ type EngineCapabilities,
8
+ type ModelManifestV2,
9
+ type ModelTierId,
10
+ type RequestedBackend,
11
+ } from '../engine';
12
+ import { formatBytes } from '../lib/format';
13
+ import {
14
+ BENCHMARK_PROMPT,
15
+ BENCHMARK_PROMPT_ID,
16
+ benchmarkReportFilename,
17
+ buildBenchmarkReport,
18
+ serializeBenchmarkReport,
19
+ type BenchmarkReport,
20
+ } from './report';
21
+
22
+ const MANIFEST_PATH = 'manifest/models.json';
23
+ const DEFAULT_MODEL: ModelTierId = '1_7b';
24
+
25
+ interface RunProgress {
26
+ label: string;
27
+ loadedBytes: number;
28
+ totalBytes: number;
29
+ }
30
+
31
+ let sharedBenchClient: BrowserEngineClient | null = null;
32
+
33
+ function getBenchClient(): BrowserEngineClient {
34
+ sharedBenchClient ??= new BrowserEngineClient();
35
+ return sharedBenchClient;
36
+ }
37
+
38
+ function manifestUrl(): string {
39
+ return new URL(`${import.meta.env.BASE_URL}${MANIFEST_PATH}`, document.baseURI).href;
40
+ }
41
+
42
+ function errorMessage(error: unknown): string {
43
+ if (error instanceof EngineClientError) return `${error.code}: ${error.message}`;
44
+ return error instanceof Error ? error.message : String(error);
45
+ }
46
+
47
+ function isAbort(error: unknown, signal: AbortSignal): boolean {
48
+ return signal.aborted
49
+ || (error instanceof DOMException && error.name === 'AbortError')
50
+ || (error instanceof EngineClientError && ['ABORTED', 'SHARD_DOWNLOAD_ABORTED'].includes(error.code));
51
+ }
52
+
53
+ function throwIfAborted(signal: AbortSignal): void {
54
+ if (signal.aborted) throw new DOMException('Benchmark stopped by operator.', 'AbortError');
55
+ }
56
+
57
+ function parseOptionalInteger(value: string): number | null {
58
+ if (value.trim() === '') return null;
59
+ const parsed = Number(value);
60
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
61
+ }
62
+
63
+ function formatDuration(value: number | null | undefined): string {
64
+ if (value === null || value === undefined) return '—';
65
+ return value >= 1_000 ? `${(value / 1_000).toFixed(2)} s` : `${value.toFixed(1)} ms`;
66
+ }
67
+
68
+ function formatRate(value: number | null | undefined): string {
69
+ return value === null || value === undefined ? '—' : `${value.toFixed(2)} t/s`;
70
+ }
71
+
72
+ function downloadReport(report: BenchmarkReport): void {
73
+ const blob = new Blob([serializeBenchmarkReport(report)], { type: 'application/json' });
74
+ const url = URL.createObjectURL(blob);
75
+ const anchor = document.createElement('a');
76
+ anchor.href = url;
77
+ anchor.download = benchmarkReportFilename(report);
78
+ document.body.append(anchor);
79
+ anchor.click();
80
+ anchor.remove();
81
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
82
+ }
83
+
84
+ export function BenchApp() {
85
+ const [client] = useState(getBenchClient);
86
+ const [manifest, setManifest] = useState<ModelManifestV2 | null>(null);
87
+ const [capabilities, setCapabilities] = useState<EngineCapabilities | null>(null);
88
+ const [modelId, setModelId] = useState<ModelTierId>(DEFAULT_MODEL);
89
+ const [backend, setBackend] = useState<RequestedBackend>('auto');
90
+ const [contextSize, setContextSize] = useState(4_096);
91
+ const [maxTokens, setMaxTokens] = useState(64);
92
+ const [nBatchInput, setNBatchInput] = useState('');
93
+ const [nUbatchInput, setNUbatchInput] = useState('');
94
+ const [running, setRunning] = useState(false);
95
+ const [progress, setProgress] = useState<RunProgress>({
96
+ label: 'Waiting for an operator-initiated run',
97
+ loadedBytes: 0,
98
+ totalBytes: 0,
99
+ });
100
+ const [report, setReport] = useState<BenchmarkReport | null>(null);
101
+ const [output, setOutput] = useState('');
102
+ const [error, setError] = useState<string | null>(null);
103
+ const operationRef = useRef<AbortController | null>(null);
104
+
105
+ useEffect(() => {
106
+ let cancelled = false;
107
+ const controller = new AbortController();
108
+ setProgress((current) => ({ ...current, label: 'Inspecting manifest and browser runtime' }));
109
+ void Promise.all([
110
+ loadModelManifestV2(manifestUrl(), controller.signal),
111
+ client.capabilities(),
112
+ ]).then(([loadedManifest, loadedCapabilities]) => {
113
+ if (cancelled) return;
114
+ const initial = loadedManifest.models.find((model) => model.id === DEFAULT_MODEL)
115
+ ?? loadedManifest.models[0];
116
+ if (!initial) throw new Error('The benchmark manifest contains no model tiers.');
117
+ setManifest(loadedManifest);
118
+ setCapabilities(loadedCapabilities);
119
+ setModelId(initial.id);
120
+ setContextSize(initial.defaultContext);
121
+ setProgress({
122
+ label: 'Ready · no model is loaded until Run benchmark is pressed',
123
+ loadedBytes: 0,
124
+ totalBytes: 0,
125
+ });
126
+ }).catch((bootstrapError) => {
127
+ if (cancelled || controller.signal.aborted) return;
128
+ setError(errorMessage(bootstrapError));
129
+ setProgress((current) => ({ ...current, label: 'Runtime inspection failed' }));
130
+ });
131
+ return () => {
132
+ cancelled = true;
133
+ controller.abort();
134
+ };
135
+ }, [client]);
136
+
137
+ const model = useMemo(
138
+ () => manifest?.models.find((candidate) => candidate.id === modelId) ?? null,
139
+ [manifest, modelId],
140
+ );
141
+ const deviceGate = useMemo(() => {
142
+ if (!model || !capabilities) return null;
143
+ return evaluateModelGate(model, backend, capabilities.webgpu, {
144
+ usageBytes: null,
145
+ quotaBytes: null,
146
+ persisted: capabilities.storage.persisted,
147
+ });
148
+ }, [backend, capabilities, model]);
149
+ const storageGate = useMemo(() => {
150
+ if (!model || !capabilities) return null;
151
+ return evaluateModelGate(model, backend, capabilities.webgpu, capabilities.storage);
152
+ }, [backend, capabilities, model]);
153
+
154
+ const contextLimit = model?.id === '27b'
155
+ ? model.defaultContext
156
+ : model?.contextLength ?? 4_096;
157
+ const nBatch = parseOptionalInteger(nBatchInput);
158
+ const nUbatch = parseOptionalInteger(nUbatchInput);
159
+ const tuningValid = (nBatchInput.trim() === '' || nBatch !== null)
160
+ && (nUbatchInput.trim() === '' || nUbatch !== null);
161
+ const configurationValid = Number.isSafeInteger(contextSize)
162
+ && contextSize > 0
163
+ && contextSize <= contextLimit
164
+ && Number.isSafeInteger(maxTokens)
165
+ && maxTokens >= 8
166
+ && maxTokens <= 512
167
+ && tuningValid;
168
+ const canRun = Boolean(manifest && capabilities && model && deviceGate?.allowed && configurationValid);
169
+
170
+ const handleModelChange = (nextId: ModelTierId) => {
171
+ if (running || !manifest) return;
172
+ const next = manifest.models.find((candidate) => candidate.id === nextId);
173
+ if (!next) return;
174
+ setModelId(nextId);
175
+ setContextSize(next.defaultContext);
176
+ if (!next.cpuFallback && backend === 'wasm') setBackend('auto');
177
+ setReport(null);
178
+ setOutput('');
179
+ setError(null);
180
+ };
181
+
182
+ const runBenchmark = () => {
183
+ if (!canRun || !manifest || !capabilities || !model || operationRef.current) return;
184
+ const controller = new AbortController();
185
+ operationRef.current = controller;
186
+ setRunning(true);
187
+ setReport(null);
188
+ setOutput('');
189
+ setError(null);
190
+ const startedAt = new Date().toISOString();
191
+
192
+ const run = async () => {
193
+ let storagePersistent = capabilities.storage.persisted;
194
+ setProgress({ label: 'Requesting persistent browser storage', loadedBytes: 0, totalBytes: 0 });
195
+ try {
196
+ const storage = await client.storagePersist();
197
+ storagePersistent = storage.persisted;
198
+ setCapabilities((current) => current ? { ...current, storage } : current);
199
+ } catch {
200
+ // Persistence is a browser policy choice. The runtime storage gate remains authoritative.
201
+ }
202
+ throwIfAborted(controller.signal);
203
+
204
+ await client.unload();
205
+ throwIfAborted(controller.signal);
206
+ let coldCachedBytes: number | null = null;
207
+ const loadParams = {
208
+ manifest,
209
+ modelId: model.id,
210
+ backend,
211
+ contextSize,
212
+ ...(nBatch === null ? {} : { nBatch }),
213
+ ...(nUbatch === null ? {} : { nUbatch }),
214
+ flashAttention: false as const,
215
+ };
216
+
217
+ const coldStarted = performance.now();
218
+ const coldLoad = await client.loadModel(loadParams, {
219
+ signal: controller.signal,
220
+ onProgress: (event) => {
221
+ if (event.phase === 'download' && coldCachedBytes === null) {
222
+ coldCachedBytes = event.loadedBytes;
223
+ }
224
+ const shard = event.shardIndex === null ? '' : ` · shard ${event.shardIndex + 1}/${event.shardCount}`;
225
+ setProgress({
226
+ label: `Cold load · ${event.phase}${shard}`,
227
+ loadedBytes: event.loadedBytes,
228
+ totalBytes: event.totalBytes,
229
+ });
230
+ },
231
+ });
232
+ const coldLoadMs = performance.now() - coldStarted;
233
+ throwIfAborted(controller.signal);
234
+
235
+ setProgress({ label: 'Unloading model before verified-cache reload', loadedBytes: 0, totalBytes: 0 });
236
+ await client.unload();
237
+ throwIfAborted(controller.signal);
238
+
239
+ const warmStarted = performance.now();
240
+ const warmLoad = await client.loadModel(loadParams, {
241
+ signal: controller.signal,
242
+ onProgress: (event) => {
243
+ const shard = event.shardIndex === null ? '' : ` · shard ${event.shardIndex + 1}/${event.shardCount}`;
244
+ setProgress({
245
+ label: `Warm load · ${event.phase}${shard}`,
246
+ loadedBytes: event.loadedBytes,
247
+ totalBytes: event.totalBytes,
248
+ });
249
+ },
250
+ });
251
+ const warmLoadMs = performance.now() - warmStarted;
252
+ if (coldLoad.backend !== warmLoad.backend) {
253
+ throw new Error(`Backend changed between cold and warm loads (${coldLoad.backend} → ${warmLoad.backend}).`);
254
+ }
255
+ throwIfAborted(controller.signal);
256
+
257
+ let firstTokenAt: number | null = null;
258
+ let streamedTokenEvents = 0;
259
+ const generationStarted = performance.now();
260
+ setProgress({ label: `Generating fixed prompt · ${BENCHMARK_PROMPT_ID}`, loadedBytes: 0, totalBytes: maxTokens });
261
+ const generationResult = await client.generate({
262
+ messages: [{ role: 'user', content: BENCHMARK_PROMPT }],
263
+ maxTokens,
264
+ temperature: 0,
265
+ topK: 1,
266
+ topP: 1,
267
+ seed: 42,
268
+ toolChoice: 'none',
269
+ cachePrompt: false,
270
+ }, {
271
+ signal: controller.signal,
272
+ onToken: (text, reasoningDelta) => {
273
+ if (!text && !reasoningDelta) return;
274
+ if (firstTokenAt === null) firstTokenAt = performance.now();
275
+ streamedTokenEvents += 1;
276
+ setProgress({
277
+ label: `Generating fixed prompt · ${streamedTokenEvents} stream event${streamedTokenEvents === 1 ? '' : 's'}`,
278
+ loadedBytes: streamedTokenEvents,
279
+ totalBytes: maxTokens,
280
+ });
281
+ },
282
+ });
283
+ const generationCompleted = performance.now();
284
+ throwIfAborted(controller.signal);
285
+ const backendReport = await client.backendReport();
286
+ throwIfAborted(controller.signal);
287
+
288
+ const nextReport = buildBenchmarkReport({
289
+ startedAt,
290
+ completedAt: new Date().toISOString(),
291
+ manifest,
292
+ model,
293
+ capabilities,
294
+ requestedBackend: backend,
295
+ contextSize: warmLoad.context.size,
296
+ maxTokens,
297
+ nBatch,
298
+ nUbatch,
299
+ coldLoadMs,
300
+ warmLoadMs,
301
+ coldCachedBytes,
302
+ storagePersistent,
303
+ loadResult: warmLoad,
304
+ generationResult,
305
+ generationElapsedMs: generationCompleted - generationStarted,
306
+ timeToFirstTokenMs: firstTokenAt === null ? null : firstTokenAt - generationStarted,
307
+ streamedTokenEvents,
308
+ backendReport,
309
+ });
310
+ setReport(nextReport);
311
+ setOutput(generationResult.text);
312
+ setProgress({
313
+ label: 'Benchmark complete · JSON report is ready',
314
+ loadedBytes: 1,
315
+ totalBytes: 1,
316
+ });
317
+ };
318
+
319
+ void run().catch((runError) => {
320
+ if (isAbort(runError, controller.signal)) {
321
+ setProgress({ label: 'Benchmark stopped by operator', loadedBytes: 0, totalBytes: 0 });
322
+ return;
323
+ }
324
+ setError(errorMessage(runError));
325
+ setProgress({ label: 'Benchmark failed safely · no report exported', loadedBytes: 0, totalBytes: 0 });
326
+ }).finally(() => {
327
+ if (operationRef.current === controller) operationRef.current = null;
328
+ setRunning(false);
329
+ });
330
+ };
331
+
332
+ const progressPercent = progress.totalBytes > 0
333
+ ? Math.min(100, Math.max(0, progress.loadedBytes / progress.totalBytes * 100))
334
+ : 0;
335
+ const fullGateStorageOnly = storageGate && !storageGate.allowed && deviceGate?.allowed;
336
+
337
+ return (
338
+ <div className="bench-page station-frame">
339
+ <header className="bench-masthead">
340
+ <div className="brand-block" aria-label="Bonsai benchmark field station">
341
+ <span className="brand-mark" aria-hidden="true"><span /></span>
342
+ <div>
343
+ <p className="eyebrow">Repeatable local inference assay</p>
344
+ <h1>Bonsai <i>growth bench</i></h1>
345
+ </div>
346
+ </div>
347
+ <div className="bench-masthead-note">
348
+ <strong>Nothing runs on arrival</strong>
349
+ <span>One click performs two loads, one fixed prompt, and no network call beyond model assets.</span>
350
+ </div>
351
+ <a className="bench-return" href={import.meta.env.BASE_URL}>Open chat station <span aria-hidden="true">↗</span></a>
352
+ </header>
353
+
354
+ <div className="bench-layout">
355
+ <aside className="bench-controls">
356
+ <p className="eyebrow">Assay controls</p>
357
+ <h2>Shape one run</h2>
358
+ <p className="bench-control-intro">The fixed prompt and temperature stay locked so exported runs remain comparable.</p>
359
+
360
+ <form onSubmit={(event) => { event.preventDefault(); runBenchmark(); }}>
361
+ <label className="bench-field">
362
+ <span>Model tier</span>
363
+ <select
364
+ value={modelId}
365
+ disabled={running || !manifest}
366
+ onChange={(event) => handleModelChange(event.target.value as ModelTierId)}
367
+ >
368
+ {(manifest?.models ?? []).map((candidate) => (
369
+ <option key={candidate.id} value={candidate.id}>{candidate.displayName}</option>
370
+ ))}
371
+ </select>
372
+ <small>{model ? `${formatBytes(model.downloadBytes)} verified weights` : 'Loading manifest…'}</small>
373
+ </label>
374
+
375
+ <label className="bench-field">
376
+ <span>Backend</span>
377
+ <select
378
+ value={backend}
379
+ disabled={running}
380
+ onChange={(event) => {
381
+ setBackend(event.target.value as RequestedBackend);
382
+ setReport(null);
383
+ setOutput('');
384
+ }}
385
+ >
386
+ <option value="auto">Auto · gated</option>
387
+ <option value="webgpu">WebGPU · explicit</option>
388
+ <option value="wasm" disabled={model?.cpuFallback === false}>CPU-WASM</option>
389
+ </select>
390
+ <small>{deviceGate?.selectedBackend ? `Preflight selects ${deviceGate.selectedBackend}` : 'No backend selected'}</small>
391
+ </label>
392
+
393
+ <div className="bench-field-pair">
394
+ <label className="bench-field">
395
+ <span>Context</span>
396
+ <input
397
+ type="number"
398
+ min="256"
399
+ max={contextLimit}
400
+ step="256"
401
+ value={contextSize}
402
+ disabled={running}
403
+ onChange={(event) => {
404
+ setContextSize(Number(event.target.value));
405
+ setReport(null);
406
+ }}
407
+ />
408
+ <small>Max {contextLimit.toLocaleString()}</small>
409
+ </label>
410
+ <label className="bench-field">
411
+ <span>Max tokens</span>
412
+ <input
413
+ type="number"
414
+ min="8"
415
+ max="512"
416
+ step="8"
417
+ value={maxTokens}
418
+ disabled={running}
419
+ onChange={(event) => {
420
+ setMaxTokens(Number(event.target.value));
421
+ setReport(null);
422
+ }}
423
+ />
424
+ <small>8–512</small>
425
+ </label>
426
+ </div>
427
+
428
+ <details className="bench-experimental">
429
+ <summary>Experimental batch shape</summary>
430
+ <p>Blank uses the pinned runtime defaults. Values are exported as experiments, not release recommendations.</p>
431
+ <div className="bench-field-pair">
432
+ <label className="bench-field">
433
+ <span>nBatch</span>
434
+ <input
435
+ type="number"
436
+ min="1"
437
+ step="1"
438
+ placeholder="runtime default"
439
+ value={nBatchInput}
440
+ disabled={running}
441
+ onChange={(event) => {
442
+ setNBatchInput(event.target.value);
443
+ setReport(null);
444
+ }}
445
+ />
446
+ </label>
447
+ <label className="bench-field">
448
+ <span>nUbatch</span>
449
+ <input
450
+ type="number"
451
+ min="1"
452
+ step="1"
453
+ placeholder="runtime default"
454
+ value={nUbatchInput}
455
+ disabled={running}
456
+ onChange={(event) => {
457
+ setNUbatchInput(event.target.value);
458
+ setReport(null);
459
+ }}
460
+ />
461
+ </label>
462
+ </div>
463
+ <dl className="bench-locks">
464
+ <div><dt>flashAttention</dt><dd>false · locked, unvalidated</dd></div>
465
+ <div><dt>KV cache K / V</dt><dd>f16 / f16 · locked release default</dd></div>
466
+ </dl>
467
+ </details>
468
+
469
+ <div className={`bench-gate ${deviceGate?.allowed ? 'pass' : 'fail'}`}>
470
+ <strong>{deviceGate?.allowed ? 'Device preflight passed' : 'Device preflight blocked'}</strong>
471
+ <span>
472
+ {deviceGate?.reasons[0]
473
+ ?? (fullGateStorageOnly
474
+ ? 'Storage cannot be proven before cache inspection; the worker repeats the authoritative gate.'
475
+ : deviceGate?.warnings[0] ?? 'Waiting for capability inspection.')}
476
+ </span>
477
+ </div>
478
+
479
+ <div className="bench-run-actions">
480
+ <button className="bench-run-button" type="submit" disabled={!canRun || running}>
481
+ {running ? 'Run in progress' : 'Run benchmark'}
482
+ </button>
483
+ {running && (
484
+ <button
485
+ className="bench-stop-button"
486
+ type="button"
487
+ onClick={() => {
488
+ setProgress((current) => ({ ...current, label: 'Stopping benchmark…' }));
489
+ operationRef.current?.abort();
490
+ }}
491
+ >
492
+ Stop
493
+ </button>
494
+ )}
495
+ </div>
496
+ </form>
497
+ </aside>
498
+
499
+ <main className="bench-workbench">
500
+ <section className="bench-hero">
501
+ <div>
502
+ <p className="eyebrow ink">Phase 5 · operator evidence</p>
503
+ <h2>Measure the whole <em>growth cycle.</em></h2>
504
+ </div>
505
+ <p>Cold load includes cache inspection and any missing download. Warm load repeats initialization after unload with verified shards retained. Generation uses one public, fixed prompt.</p>
506
+ </section>
507
+
508
+ <section className="bench-progress" aria-live="polite">
509
+ <div><strong>{progress.label}</strong><span>{progress.totalBytes > 1 ? `${Math.round(progressPercent)}%` : ''}</span></div>
510
+ <span className="bench-progress-track"><i style={{ width: `${progressPercent}%` }} /></span>
511
+ </section>
512
+
513
+ {error && <div className="bench-error" role="alert"><strong>Run rejected</strong><span>{error}</span></div>}
514
+
515
+ <section className="bench-core-section" aria-label="Benchmark measurements">
516
+ <div className="bench-section-heading">
517
+ <div><p className="eyebrow ink">Core sample</p><h3>Four boundaries, one run</h3></div>
518
+ {report && <button type="button" onClick={() => downloadReport(report)}>Export JSON</button>}
519
+ </div>
520
+ <div className="bench-core-sample">
521
+ <article>
522
+ <span>Cold load</span>
523
+ <strong>{formatDuration(report?.load.cold.durationMs)}</strong>
524
+ <small>{report ? `${report.load.cold.cacheState} at start` : 'first boundary'}</small>
525
+ </article>
526
+ <article>
527
+ <span>Warm load</span>
528
+ <strong>{formatDuration(report?.load.warm.durationMs)}</strong>
529
+ <small>verified cache retained</small>
530
+ </article>
531
+ <article>
532
+ <span>TTFT</span>
533
+ <strong>{formatDuration(report?.generation.timeToFirstTokenMs)}</strong>
534
+ <small>request to first stream</small>
535
+ </article>
536
+ <article>
537
+ <span>Decode</span>
538
+ <strong>{formatRate(report?.generation.decodeTokensPerSecond)}</strong>
539
+ <small>{report ? `${report.generation.completionTokens} completion tokens` : 'engine timing'}</small>
540
+ </article>
541
+ </div>
542
+ </section>
543
+
544
+ <section className="bench-detail-grid">
545
+ <article className="bench-data-card">
546
+ <p className="eyebrow ink">Throughput</p>
547
+ <dl>
548
+ <div><dt>Prompt</dt><dd>{formatRate(report?.generation.promptTokensPerSecond)}</dd></div>
549
+ <div><dt>Decode</dt><dd>{formatRate(report?.generation.decodeTokensPerSecond)}</dd></div>
550
+ <div><dt>Token count</dt><dd>{report?.generation.completionTokens ?? '—'}</dd></div>
551
+ <div><dt>Finish</dt><dd>{report?.generation.finishReason ?? '—'}</dd></div>
552
+ </dl>
553
+ </article>
554
+ <article className="bench-data-card">
555
+ <p className="eyebrow ink">Graph tripwire</p>
556
+ <dl>
557
+ <div><dt>Graph splits</dt><dd>{report?.execution.graphSplits ?? '—'}</dd></div>
558
+ <div><dt>CPU ops</dt><dd>{report?.execution.opsOnCpu ?? '—'}</dd></div>
559
+ <div><dt>GPU layers</dt><dd>{report?.execution.gpuLayers ? `${report.execution.gpuLayers.offloaded}/${report.execution.gpuLayers.total}` : '—'}</dd></div>
560
+ <div><dt>Backend</dt><dd>{report?.execution.selectedBackend.toUpperCase() ?? '—'}</dd></div>
561
+ </dl>
562
+ </article>
563
+ <article className="bench-data-card bench-runtime-card">
564
+ <p className="eyebrow ink">Runtime pins</p>
565
+ <dl>
566
+ <div><dt>Device</dt><dd>{report?.execution.device ?? capabilities?.webgpu.name ?? 'Inspecting…'}</dd></div>
567
+ <div><dt>wllama</dt><dd title={report?.runtime.wllamaRevision}>{report?.runtime.wllamaRevision.slice(0, 12) ?? '—'}</dd></div>
568
+ <div><dt>llama.cpp</dt><dd title={report?.runtime.llamaCppRevision}>{report?.runtime.llamaCppRevision.slice(0, 12) ?? '—'}</dd></div>
569
+ <div><dt>Weights</dt><dd title={report?.runtime.manifestRevision ?? undefined}>{report?.runtime.manifestRevision?.slice(0, 12) ?? '—'}</dd></div>
570
+ </dl>
571
+ </article>
572
+ </section>
573
+
574
+ <section className="bench-output">
575
+ <div>
576
+ <p className="eyebrow ink">Fixed specimen</p>
577
+ <h3>{BENCHMARK_PROMPT_ID}</h3>
578
+ <p>{BENCHMARK_PROMPT}</p>
579
+ </div>
580
+ <div>
581
+ <p className="eyebrow ink">Generated text · excluded from export</p>
582
+ <p>{output || 'Run the assay to inspect the generated specimen.'}</p>
583
+ </div>
584
+ </section>
585
+ </main>
586
+ </div>
587
+ </div>
588
+ );
589
+ }
src/bench/report.test.ts ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+ import type {
3
+ EngineCapabilities,
4
+ LoadModelResult,
5
+ ManifestModelV2,
6
+ ModelManifestV2,
7
+ } from '../engine';
8
+ import {
9
+ benchmarkReportFilename,
10
+ buildBenchmarkReport,
11
+ classifyCacheState,
12
+ serializeBenchmarkReport,
13
+ type BenchmarkObservation,
14
+ } from './report';
15
+
16
+ const model = {
17
+ id: '8b',
18
+ displayName: 'Bonsai 8B',
19
+ architecture: 'qwen3next',
20
+ downloadBytes: 1_000,
21
+ } as ManifestModelV2;
22
+
23
+ const manifest = {
24
+ repository: {
25
+ id: 'WaveCut/Bonsai-web-GGUF',
26
+ revision: '112ea7a1a6229bde132b176b9a72477a7ecfde64',
27
+ },
28
+ dflash: { status: 'conditional' },
29
+ } as ModelManifestV2;
30
+
31
+ const capabilities = {
32
+ crossOriginIsolated: true,
33
+ sharedArrayBuffer: true,
34
+ hardwareConcurrency: 12,
35
+ storage: { usageBytes: 1_000, quotaBytes: 10_000, persisted: true },
36
+ webgpu: {
37
+ available: true,
38
+ name: 'Apple M4 Max',
39
+ vendor: 'Apple',
40
+ architecture: 'metal-3',
41
+ device: null,
42
+ description: 'Apple M4 Max',
43
+ limits: { maxStorageBufferBindingSize: 1_073_741_824 },
44
+ features: ['shader-f16'],
45
+ },
46
+ browser: {
47
+ userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
48
+ platform: 'MacIntel',
49
+ language: 'en-GB',
50
+ },
51
+ runtime: {
52
+ implementation: 'bonsai-wllama',
53
+ wllamaRevision: '912c18b75d4358c1405a64646b8dbe43a205943b',
54
+ llamaCppRevision: '00fa7cb284cbf133fc426733bd64238a3588a33e',
55
+ patchSetSha256: 'f'.repeat(64),
56
+ moduleSha256: 'e'.repeat(64),
57
+ wasmFlavor: 'jspi',
58
+ wasmSha256: 'd'.repeat(64),
59
+ compatWorkerSha256: null,
60
+ tokenEmbeddingOnWebGPU: true,
61
+ tensorPlacementOverrides: false,
62
+ },
63
+ } satisfies EngineCapabilities;
64
+
65
+ const loadResult = {
66
+ backend: 'webgpu',
67
+ context: { batchSize: 2_048, microBatchSize: 512 },
68
+ tuning: { flashAttention: false, cacheTypeK: 'f16', cacheTypeV: 'f16' },
69
+ } as LoadModelResult;
70
+
71
+ function observation(overrides: Partial<BenchmarkObservation> = {}): BenchmarkObservation {
72
+ return {
73
+ startedAt: '2026-07-15T10:00:00.000Z',
74
+ completedAt: '2026-07-15T10:00:10.123Z',
75
+ manifest,
76
+ model,
77
+ capabilities,
78
+ requestedBackend: 'auto',
79
+ contextSize: 4_096,
80
+ maxTokens: 64,
81
+ nBatch: null,
82
+ nUbatch: null,
83
+ coldLoadMs: 1_234.56,
84
+ warmLoadMs: 234.54,
85
+ coldCachedBytes: 250,
86
+ storagePersistent: true,
87
+ loadResult,
88
+ generationResult: {
89
+ text: 'fixture',
90
+ finishReason: 'stop',
91
+ toolCalls: [],
92
+ usage: { promptTokens: 20, completionTokens: 8, totalTokens: 28 },
93
+ timings: { promptTokensPerSecond: 123.456, predictedTokensPerSecond: 45.678 },
94
+ },
95
+ generationElapsedMs: 900.04,
96
+ timeToFirstTokenMs: 123.45,
97
+ streamedTokenEvents: 5,
98
+ backendReport: {
99
+ backends: ['WebGPU'],
100
+ nGraphSplits: 1,
101
+ opsOnCpu: 0,
102
+ layersGpu: { offloaded: 49, total: 49 },
103
+ },
104
+ ...overrides,
105
+ };
106
+ }
107
+
108
+ describe('benchmark report', () => {
109
+ it('classifies the shard cache observed at the first load boundary', () => {
110
+ expect(classifyCacheState(null, 1_000)).toBe('unknown');
111
+ expect(classifyCacheState(0, 1_000)).toBe('empty');
112
+ expect(classifyCacheState(1, 1_000)).toBe('partial');
113
+ expect(classifyCacheState(1_000, 1_000)).toBe('cached');
114
+ });
115
+
116
+ it('builds a shareable report with pinned runtime evidence and fixed-safe policy', () => {
117
+ const report = buildBenchmarkReport(observation());
118
+
119
+ expect(report.load).toMatchObject({
120
+ tuning: {
121
+ requestedNBatch: null,
122
+ requestedNUbatch: null,
123
+ nBatch: 2_048,
124
+ nUbatch: 512,
125
+ flashAttention: false,
126
+ cacheTypeK: 'f16',
127
+ cacheTypeV: 'f16',
128
+ status: 'release-defaults',
129
+ },
130
+ cold: { durationMs: 1_234.6, cacheState: 'partial' },
131
+ warm: { durationMs: 234.5 },
132
+ });
133
+ expect(report.generation).toMatchObject({
134
+ promptTokensPerSecond: 123.46,
135
+ decodeTokensPerSecond: 45.68,
136
+ engineCompletionTokens: 8,
137
+ streamedTokenEvents: 5,
138
+ completionTokens: 8,
139
+ tokenCountSource: 'engine-usage',
140
+ });
141
+ expect(report.execution).toMatchObject({
142
+ selectedBackend: 'webgpu',
143
+ device: 'Apple M4 Max',
144
+ graphSplits: 1,
145
+ opsOnCpu: 0,
146
+ });
147
+ expect(report.runtime.manifestRevision).toBe('112ea7a1a6229bde132b176b9a72477a7ecfde64');
148
+ expect(report.runtime).toMatchObject({
149
+ patchSetSha256: 'f'.repeat(64),
150
+ moduleSha256: 'e'.repeat(64),
151
+ wasmFlavor: 'jspi',
152
+ wasmSha256: 'd'.repeat(64),
153
+ compatWorkerSha256: null,
154
+ });
155
+ expect(report.environment).toEqual({
156
+ browser: {
157
+ userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
158
+ platform: 'MacIntel',
159
+ language: 'en-GB',
160
+ },
161
+ webgpu: {
162
+ available: true,
163
+ name: 'Apple M4 Max',
164
+ vendor: 'Apple',
165
+ architecture: 'metal-3',
166
+ device: null,
167
+ description: 'Apple M4 Max',
168
+ limits: { maxStorageBufferBindingSize: 1_073_741_824 },
169
+ features: ['shader-f16'],
170
+ },
171
+ });
172
+ expect(report.releasePolicy).toEqual({
173
+ flashAttention: {
174
+ enabled: false,
175
+ operatorConfigurable: false,
176
+ status: 'fixed-off-unvalidated-for-release',
177
+ },
178
+ kvCache: {
179
+ key: 'f16',
180
+ value: 'f16',
181
+ operatorConfigurable: false,
182
+ status: 'fixed-release-default-alternatives-unvalidated',
183
+ },
184
+ dflash: {
185
+ enabled: false,
186
+ runtimeAvailable: false,
187
+ status: 'conditional',
188
+ },
189
+ });
190
+ });
191
+
192
+ it('records experimental batch shape by the runtime field names without enabling flash attention', () => {
193
+ const report = buildBenchmarkReport(observation({ nBatch: 512, nUbatch: 128 }));
194
+
195
+ expect(report.load.tuning).toEqual({
196
+ requestedNBatch: 512,
197
+ requestedNUbatch: 128,
198
+ nBatch: 2_048,
199
+ nUbatch: 512,
200
+ flashAttention: false,
201
+ cacheTypeK: 'f16',
202
+ cacheTypeV: 'f16',
203
+ status: 'experimental-batch-shape',
204
+ });
205
+ });
206
+
207
+ it('uses streamed events when final engine usage is present but incomplete', () => {
208
+ const report = buildBenchmarkReport(observation({
209
+ streamedTokenEvents: 64,
210
+ generationResult: {
211
+ text: 'fixture',
212
+ finishReason: 'length',
213
+ toolCalls: [],
214
+ usage: { promptTokens: 5, completionTokens: 0, totalTokens: 5 },
215
+ timings: { promptTokensPerSecond: 20, predictedTokensPerSecond: 30 },
216
+ },
217
+ }));
218
+
219
+ expect(report.generation).toMatchObject({
220
+ engineCompletionTokens: 0,
221
+ streamedTokenEvents: 64,
222
+ completionTokens: 64,
223
+ engineTotalTokens: 5,
224
+ totalTokens: 69,
225
+ tokenCountSource: 'stream-events-fallback',
226
+ });
227
+ });
228
+
229
+ it('falls back to streamed events without emitting non-finite JSON metrics', () => {
230
+ const report = buildBenchmarkReport(observation({
231
+ generationElapsedMs: Number.NaN,
232
+ timeToFirstTokenMs: Number.POSITIVE_INFINITY,
233
+ generationResult: {
234
+ text: 'fixture',
235
+ finishReason: 'length',
236
+ toolCalls: [],
237
+ usage: null,
238
+ timings: null,
239
+ },
240
+ }));
241
+
242
+ expect(report.generation).toMatchObject({
243
+ elapsedMs: null,
244
+ timeToFirstTokenMs: null,
245
+ decodeTokensPerSecond: null,
246
+ completionTokens: 5,
247
+ tokenCountSource: 'stream-events-fallback',
248
+ });
249
+ expect(serializeBenchmarkReport(report)).not.toContain('NaN');
250
+ expect(serializeBenchmarkReport(report)).toMatch(/\n$/);
251
+ });
252
+
253
+ it('uses the model, selected backend, and UTC completion time in the export name', () => {
254
+ const report = buildBenchmarkReport(observation());
255
+ expect(benchmarkReportFilename(report)).toBe(
256
+ 'bonsai-bench-8b-webgpu-2026-07-15T10-00-10-123Z.json',
257
+ );
258
+ });
259
+ });
src/bench/report.ts ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ BackendReport,
3
+ EngineCapabilities,
4
+ GenerateResult,
5
+ LoadModelResult,
6
+ ManifestModelV2,
7
+ ModelManifestV2,
8
+ RequestedBackend,
9
+ } from '../engine';
10
+
11
+ export const BENCHMARK_PROMPT_ID = 'bonsai-field-core-v1';
12
+ export const BENCHMARK_PROMPT = 'Describe how a tree survives one dry season in exactly eight short, factual sentences.';
13
+
14
+ export type CacheState = 'empty' | 'partial' | 'cached' | 'unknown';
15
+
16
+ export interface BenchmarkObservation {
17
+ startedAt: string;
18
+ completedAt: string;
19
+ manifest: ModelManifestV2;
20
+ model: ManifestModelV2;
21
+ capabilities: EngineCapabilities;
22
+ requestedBackend: RequestedBackend;
23
+ contextSize: number;
24
+ maxTokens: number;
25
+ nBatch: number | null;
26
+ nUbatch: number | null;
27
+ coldLoadMs: number;
28
+ warmLoadMs: number;
29
+ coldCachedBytes: number | null;
30
+ storagePersistent: boolean;
31
+ loadResult: LoadModelResult;
32
+ generationResult: GenerateResult;
33
+ generationElapsedMs: number;
34
+ timeToFirstTokenMs: number | null;
35
+ streamedTokenEvents: number;
36
+ backendReport: BackendReport;
37
+ }
38
+
39
+ export interface BenchmarkReport {
40
+ schemaVersion: 1;
41
+ kind: 'bonsai-browser-benchmark';
42
+ startedAt: string;
43
+ completedAt: string;
44
+ model: {
45
+ id: ManifestModelV2['id'];
46
+ displayName: string;
47
+ architecture: string;
48
+ downloadBytes: number;
49
+ contextSize: number;
50
+ };
51
+ load: {
52
+ tuning: {
53
+ requestedNBatch: number | null;
54
+ requestedNUbatch: number | null;
55
+ nBatch: number;
56
+ nUbatch: number;
57
+ flashAttention: boolean;
58
+ cacheTypeK: LoadModelResult['tuning']['cacheTypeK'];
59
+ cacheTypeV: LoadModelResult['tuning']['cacheTypeV'];
60
+ status: 'release-defaults' | 'experimental-batch-shape';
61
+ };
62
+ cold: {
63
+ durationMs: number | null;
64
+ cachedBytesAtStart: number | null;
65
+ cacheState: CacheState;
66
+ definition: string;
67
+ };
68
+ warm: {
69
+ durationMs: number | null;
70
+ definition: string;
71
+ };
72
+ };
73
+ generation: {
74
+ promptId: typeof BENCHMARK_PROMPT_ID;
75
+ prompt: typeof BENCHMARK_PROMPT;
76
+ maxTokens: number;
77
+ temperature: 0;
78
+ seed: 42;
79
+ elapsedMs: number | null;
80
+ timeToFirstTokenMs: number | null;
81
+ promptTokensPerSecond: number | null;
82
+ decodeTokensPerSecond: number | null;
83
+ promptTokens: number | null;
84
+ engineCompletionTokens: number | null;
85
+ streamedTokenEvents: number;
86
+ completionTokens: number;
87
+ engineTotalTokens: number | null;
88
+ totalTokens: number | null;
89
+ tokenCountSource: 'engine-usage' | 'stream-events-fallback';
90
+ finishReason: GenerateResult['finishReason'];
91
+ };
92
+ execution: {
93
+ requestedBackend: RequestedBackend;
94
+ selectedBackend: LoadModelResult['backend'];
95
+ device: string;
96
+ graphSplits: number | null;
97
+ opsOnCpu: number;
98
+ gpuLayers: BackendReport['layersGpu'];
99
+ confirmedBackends: string[];
100
+ };
101
+ runtime: {
102
+ implementation: EngineCapabilities['runtime']['implementation'];
103
+ wllamaRevision: string;
104
+ llamaCppRevision: string;
105
+ patchSetSha256: string;
106
+ moduleSha256: string;
107
+ wasmFlavor: EngineCapabilities['runtime']['wasmFlavor'];
108
+ wasmSha256: string;
109
+ compatWorkerSha256: string | null;
110
+ manifestRepository: string;
111
+ manifestRevision: string | null;
112
+ crossOriginIsolated: boolean;
113
+ sharedArrayBuffer: boolean;
114
+ hardwareConcurrency: number;
115
+ persistentStorage: boolean;
116
+ };
117
+ environment: {
118
+ browser: EngineCapabilities['browser'];
119
+ webgpu: EngineCapabilities['webgpu'];
120
+ };
121
+ releasePolicy: {
122
+ flashAttention: {
123
+ enabled: false;
124
+ operatorConfigurable: false;
125
+ status: 'fixed-off-unvalidated-for-release';
126
+ };
127
+ kvCache: {
128
+ key: 'f16';
129
+ value: 'f16';
130
+ operatorConfigurable: false;
131
+ status: 'fixed-release-default-alternatives-unvalidated';
132
+ };
133
+ dflash: {
134
+ enabled: false;
135
+ runtimeAvailable: false;
136
+ status: ModelManifestV2['dflash']['status'];
137
+ };
138
+ };
139
+ }
140
+
141
+ function roundedNonNegative(value: number | null, digits: number): number | null {
142
+ if (value === null || !Number.isFinite(value) || value < 0) return null;
143
+ const scale = 10 ** digits;
144
+ return Math.round(value * scale) / scale;
145
+ }
146
+
147
+ export function classifyCacheState(observedBytes: number | null, totalBytes: number): CacheState {
148
+ if (observedBytes === null || !Number.isFinite(observedBytes) || totalBytes <= 0) return 'unknown';
149
+ if (observedBytes <= 0) return 'empty';
150
+ if (observedBytes >= totalBytes) return 'cached';
151
+ return 'partial';
152
+ }
153
+
154
+ export function buildBenchmarkReport(observation: BenchmarkObservation): BenchmarkReport {
155
+ const { generationResult, capabilities, backendReport, loadResult } = observation;
156
+ const usage = generationResult.usage;
157
+ const engineCompletionTokens = usage
158
+ && Number.isFinite(usage.completionTokens)
159
+ && usage.completionTokens >= 0
160
+ ? usage.completionTokens
161
+ : null;
162
+ const streamedTokenEvents = Number.isFinite(observation.streamedTokenEvents)
163
+ ? Math.floor(Math.max(0, observation.streamedTokenEvents))
164
+ : 0;
165
+ const completionTokens = Math.max(engineCompletionTokens ?? 0, streamedTokenEvents);
166
+ const promptTokens = usage && Number.isFinite(usage.promptTokens) && usage.promptTokens >= 0
167
+ ? Math.floor(usage.promptTokens)
168
+ : null;
169
+ const engineTotalTokens = usage && Number.isFinite(usage.totalTokens) && usage.totalTokens >= 0
170
+ ? Math.floor(usage.totalTokens)
171
+ : null;
172
+ const totalTokens = usage
173
+ ? Math.max(engineTotalTokens ?? 0, (promptTokens ?? 0) + completionTokens)
174
+ : null;
175
+ const decodeRate = generationResult.timings?.predictedTokensPerSecond
176
+ ?? (completionTokens > 0 && observation.generationElapsedMs > 0
177
+ ? completionTokens / (observation.generationElapsedMs / 1000)
178
+ : null);
179
+ const device = loadResult.backend === 'webgpu'
180
+ ? capabilities.webgpu.name || 'WebGPU adapter'
181
+ : `CPU · ${capabilities.hardwareConcurrency} logical cores`;
182
+
183
+ return {
184
+ schemaVersion: 1,
185
+ kind: 'bonsai-browser-benchmark',
186
+ startedAt: observation.startedAt,
187
+ completedAt: observation.completedAt,
188
+ model: {
189
+ id: observation.model.id,
190
+ displayName: observation.model.displayName,
191
+ architecture: observation.model.architecture,
192
+ downloadBytes: observation.model.downloadBytes,
193
+ contextSize: observation.contextSize,
194
+ },
195
+ load: {
196
+ tuning: {
197
+ requestedNBatch: observation.nBatch,
198
+ requestedNUbatch: observation.nUbatch,
199
+ nBatch: loadResult.context.batchSize,
200
+ nUbatch: loadResult.context.microBatchSize,
201
+ flashAttention: loadResult.tuning.flashAttention,
202
+ cacheTypeK: loadResult.tuning.cacheTypeK,
203
+ cacheTypeV: loadResult.tuning.cacheTypeV,
204
+ status: observation.nBatch === null && observation.nUbatch === null
205
+ ? 'release-defaults'
206
+ : 'experimental-batch-shape',
207
+ },
208
+ cold: {
209
+ durationMs: roundedNonNegative(observation.coldLoadMs, 1),
210
+ cachedBytesAtStart: roundedNonNegative(observation.coldCachedBytes, 0),
211
+ cacheState: classifyCacheState(observation.coldCachedBytes, observation.model.downloadBytes),
212
+ definition: 'First load in this benchmark run; previously verified browser shards may already be cached.',
213
+ },
214
+ warm: {
215
+ durationMs: roundedNonNegative(observation.warmLoadMs, 1),
216
+ definition: 'Second load after an explicit unload, with the verified shard cache retained.',
217
+ },
218
+ },
219
+ generation: {
220
+ promptId: BENCHMARK_PROMPT_ID,
221
+ prompt: BENCHMARK_PROMPT,
222
+ maxTokens: observation.maxTokens,
223
+ temperature: 0,
224
+ seed: 42,
225
+ elapsedMs: roundedNonNegative(observation.generationElapsedMs, 1),
226
+ timeToFirstTokenMs: roundedNonNegative(observation.timeToFirstTokenMs, 1),
227
+ promptTokensPerSecond: roundedNonNegative(
228
+ generationResult.timings?.promptTokensPerSecond ?? null,
229
+ 2,
230
+ ),
231
+ decodeTokensPerSecond: roundedNonNegative(decodeRate, 2),
232
+ promptTokens,
233
+ engineCompletionTokens,
234
+ streamedTokenEvents,
235
+ completionTokens,
236
+ engineTotalTokens,
237
+ totalTokens,
238
+ tokenCountSource: engineCompletionTokens !== null && engineCompletionTokens >= streamedTokenEvents
239
+ ? 'engine-usage'
240
+ : 'stream-events-fallback',
241
+ finishReason: generationResult.finishReason,
242
+ },
243
+ execution: {
244
+ requestedBackend: observation.requestedBackend,
245
+ selectedBackend: loadResult.backend,
246
+ device,
247
+ graphSplits: backendReport.nGraphSplits,
248
+ opsOnCpu: backendReport.opsOnCpu,
249
+ gpuLayers: backendReport.layersGpu,
250
+ confirmedBackends: [...backendReport.backends],
251
+ },
252
+ runtime: {
253
+ implementation: capabilities.runtime.implementation,
254
+ wllamaRevision: capabilities.runtime.wllamaRevision,
255
+ llamaCppRevision: capabilities.runtime.llamaCppRevision,
256
+ patchSetSha256: capabilities.runtime.patchSetSha256,
257
+ moduleSha256: capabilities.runtime.moduleSha256,
258
+ wasmFlavor: capabilities.runtime.wasmFlavor,
259
+ wasmSha256: capabilities.runtime.wasmSha256,
260
+ compatWorkerSha256: capabilities.runtime.compatWorkerSha256,
261
+ manifestRepository: observation.manifest.repository.id,
262
+ manifestRevision: observation.manifest.repository.revision,
263
+ crossOriginIsolated: capabilities.crossOriginIsolated,
264
+ sharedArrayBuffer: capabilities.sharedArrayBuffer,
265
+ hardwareConcurrency: capabilities.hardwareConcurrency,
266
+ persistentStorage: observation.storagePersistent,
267
+ },
268
+ environment: {
269
+ browser: { ...capabilities.browser },
270
+ webgpu: {
271
+ ...capabilities.webgpu,
272
+ limits: { ...capabilities.webgpu.limits },
273
+ features: [...capabilities.webgpu.features],
274
+ },
275
+ },
276
+ releasePolicy: {
277
+ flashAttention: {
278
+ enabled: false,
279
+ operatorConfigurable: false,
280
+ status: 'fixed-off-unvalidated-for-release',
281
+ },
282
+ kvCache: {
283
+ key: 'f16',
284
+ value: 'f16',
285
+ operatorConfigurable: false,
286
+ status: 'fixed-release-default-alternatives-unvalidated',
287
+ },
288
+ dflash: {
289
+ enabled: false,
290
+ runtimeAvailable: false,
291
+ status: observation.manifest.dflash.status,
292
+ },
293
+ },
294
+ };
295
+ }
296
+
297
+ export function serializeBenchmarkReport(report: BenchmarkReport): string {
298
+ return `${JSON.stringify(report, null, 2)}\n`;
299
+ }
300
+
301
+ export function benchmarkReportFilename(report: BenchmarkReport): string {
302
+ const timestamp = report.completedAt.replace(/[:.]/g, '-');
303
+ return `bonsai-bench-${report.model.id}-${report.execution.selectedBackend}-${timestamp}.json`;
304
+ }
src/engine/device-gate.test.ts CHANGED
@@ -80,6 +80,28 @@ describe('inspectWebGpuAdapter', () => {
80
  expect(requestAdapter).toHaveBeenCalledWith();
81
  expect(snapshot.name).toBe('Default test GPU');
82
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  });
84
 
85
  describe('supportsWllamaWebGpuRuntime', () => {
 
80
  expect(requestAdapter).toHaveBeenCalledWith();
81
  expect(snapshot.name).toBe('Default test GPU');
82
  });
83
+
84
+ it('normalizes empty adapter-info strings instead of exporting an empty device name', async () => {
85
+ vi.stubGlobal('navigator', {
86
+ gpu: {
87
+ requestAdapter: vi.fn().mockResolvedValue({
88
+ limits: {},
89
+ features: new Set(),
90
+ info: { vendor: ' Apple ', architecture: '', device: '', description: ' ' },
91
+ }),
92
+ },
93
+ });
94
+
95
+ const snapshot = await inspectWebGpuAdapter();
96
+
97
+ expect(snapshot).toMatchObject({
98
+ name: 'Apple',
99
+ vendor: 'Apple',
100
+ architecture: null,
101
+ device: null,
102
+ description: null,
103
+ });
104
+ });
105
  });
106
 
107
  describe('supportsWllamaWebGpuRuntime', () => {
src/engine/device-gate.ts CHANGED
@@ -113,14 +113,22 @@ export async function inspectWebGpuAdapter(): Promise<WebGpuAdapterSnapshot> {
113
  };
114
  }
115
  const info = adapter.info ?? await adapter.requestAdapterInfo?.() ?? {};
116
- const name = info.description ?? info.device ?? info.architecture ?? info.vendor ?? 'WebGPU adapter';
 
 
 
 
 
 
 
 
117
  return {
118
  available: true,
119
  name,
120
- vendor: info.vendor ?? null,
121
- architecture: info.architecture ?? null,
122
- device: info.device ?? null,
123
- description: info.description ?? null,
124
  limits: readLimits(adapter.limits),
125
  features: [...adapter.features].sort(),
126
  };
 
113
  };
114
  }
115
  const info = adapter.info ?? await adapter.requestAdapterInfo?.() ?? {};
116
+ const clean = (value: string | undefined): string | null => {
117
+ const trimmed = value?.trim();
118
+ return trimmed ? trimmed : null;
119
+ };
120
+ const vendor = clean(info.vendor);
121
+ const architecture = clean(info.architecture);
122
+ const device = clean(info.device);
123
+ const description = clean(info.description);
124
+ const name = description ?? device ?? architecture ?? vendor ?? 'WebGPU adapter';
125
  return {
126
  available: true,
127
  name,
128
+ vendor,
129
+ architecture,
130
+ device,
131
+ description,
132
  limits: readLimits(adapter.limits),
133
  features: [...adapter.features].sort(),
134
  };
src/engine/protocol.ts CHANGED
@@ -27,10 +27,20 @@ export interface EngineCapabilities {
27
  hardwareConcurrency: number;
28
  webgpu: WebGpuAdapterSnapshot;
29
  storage: StorageEstimate;
 
 
 
 
 
30
  runtime: {
31
  implementation: 'bonsai-wllama';
32
  wllamaRevision: string;
33
  llamaCppRevision: string;
 
 
 
 
 
34
  tokenEmbeddingOnWebGPU: true;
35
  tensorPlacementOverrides: false;
36
  };
@@ -96,6 +106,9 @@ export type LoadModelParams = ManifestSource & {
96
  backend: RequestedBackend;
97
  contextSize?: number;
98
  threads?: number;
 
 
 
99
  cacheTypeK?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
100
  cacheTypeV?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
101
  tensorPlacement?: FutureTensorPlacement;
@@ -124,6 +137,13 @@ export interface LoadModelResult {
124
  trainingSize: number;
125
  layerCount: number;
126
  vocabularySize: number;
 
 
 
 
 
 
 
127
  };
128
  chatTemplate: {
129
  bytes: number;
 
27
  hardwareConcurrency: number;
28
  webgpu: WebGpuAdapterSnapshot;
29
  storage: StorageEstimate;
30
+ browser: {
31
+ userAgent: string;
32
+ platform: string;
33
+ language: string;
34
+ };
35
  runtime: {
36
  implementation: 'bonsai-wllama';
37
  wllamaRevision: string;
38
  llamaCppRevision: string;
39
+ patchSetSha256: string;
40
+ moduleSha256: string;
41
+ wasmFlavor: 'jspi' | 'compat';
42
+ wasmSha256: string;
43
+ compatWorkerSha256: string | null;
44
  tokenEmbeddingOnWebGPU: true;
45
  tensorPlacementOverrides: false;
46
  };
 
106
  backend: RequestedBackend;
107
  contextSize?: number;
108
  threads?: number;
109
+ nBatch?: number;
110
+ nUbatch?: number;
111
+ flashAttention?: boolean;
112
  cacheTypeK?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
113
  cacheTypeV?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
114
  tensorPlacement?: FutureTensorPlacement;
 
137
  trainingSize: number;
138
  layerCount: number;
139
  vocabularySize: number;
140
+ batchSize: number;
141
+ microBatchSize: number;
142
+ };
143
+ tuning: {
144
+ flashAttention: boolean;
145
+ cacheTypeK: NonNullable<LoadModelParams['cacheTypeK']>;
146
+ cacheTypeV: NonNullable<LoadModelParams['cacheTypeV']>;
147
  };
148
  chatTemplate: {
149
  bytes: number;
src/engine/runtime-download.test.ts CHANGED
@@ -312,3 +312,30 @@ describe('BrowserEngineRuntime per-shard retry', () => {
312
  expect(progress[progress.length - 1]).toBe(0);
313
  });
314
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  expect(progress[progress.length - 1]).toBe(0);
313
  });
314
  });
315
+
316
+ describe('BrowserEngineRuntime benchmark load controls', () => {
317
+ const invalidLoad = (extra: { nBatch?: number; nUbatch?: number }) => new BrowserEngineRuntime().loadModel(
318
+ 'request-invalid-batch',
319
+ {
320
+ manifest: { schemaVersion: 2 } as never,
321
+ modelId: '1_7b',
322
+ backend: 'webgpu',
323
+ ...extra,
324
+ },
325
+ new AbortController().signal,
326
+ () => undefined,
327
+ );
328
+
329
+ it('rejects invalid n_batch before touching the manifest or model cache', async () => {
330
+ await expect(invalidLoad({ nBatch: 0 })).rejects.toMatchObject({ code: 'INVALID_BATCH_SIZE' });
331
+ });
332
+
333
+ it('rejects invalid n_ubatch before touching the manifest or model cache', async () => {
334
+ await expect(invalidLoad({ nUbatch: 1.5 })).rejects.toMatchObject({ code: 'INVALID_UBATCH_SIZE' });
335
+ });
336
+
337
+ it('rejects batch values that overflow the signed glue integer', async () => {
338
+ await expect(invalidLoad({ nBatch: 2_147_483_648 })).rejects.toMatchObject({ code: 'INVALID_BATCH_SIZE' });
339
+ await expect(invalidLoad({ nUbatch: Number.MAX_SAFE_INTEGER })).rejects.toMatchObject({ code: 'INVALID_UBATCH_SIZE' });
340
+ });
341
+ });
src/engine/runtime-generate.test.ts CHANGED
@@ -93,4 +93,52 @@ describe('BrowserEngineRuntime generation telemetry', () => {
93
  expect(result.usage).toEqual({ promptTokens: 9, completionTokens: 1, totalTokens: 10 });
94
  expect(result.timings).toEqual({ promptTokensPerSecond: 100, predictedTokensPerSecond: 50 });
95
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  });
 
93
  expect(result.usage).toEqual({ promptTokens: 9, completionTokens: 1, totalTokens: 10 });
94
  expect(result.timings).toEqual({ promptTokensPerSecond: 100, predictedTokensPerSecond: 50 });
95
  });
96
+
97
+ it('reconciles incomplete streamed usage with llama.cpp timing counts', async () => {
98
+ const runtime = new BrowserEngineRuntime();
99
+ const internals = runtime as unknown as RuntimeInternals;
100
+ internals.loaded = {
101
+ manifest: {},
102
+ backend: 'wasm',
103
+ model: {
104
+ id: '1_7b',
105
+ displayName: 'Fixture Bonsai',
106
+ cpuFallback: true,
107
+ runtimePolicy: {
108
+ flashAttention: false,
109
+ tokenEmbeddingOnWebGPU: true,
110
+ requireSingleWebGPUGraph: false,
111
+ },
112
+ },
113
+ };
114
+ internals.wllama = {
115
+ isModelLoaded: () => true,
116
+ createChatCompletion: async ({ onData }) => {
117
+ onData(chunk('one, two, three', {
118
+ usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
119
+ timings: {
120
+ cache_n: 0,
121
+ prompt_n: 9,
122
+ prompt_ms: 90,
123
+ prompt_per_token_ms: 10,
124
+ prompt_per_second: 100,
125
+ predicted_n: 64,
126
+ predicted_ms: 1_280,
127
+ predicted_per_token_ms: 20,
128
+ predicted_per_second: 50,
129
+ },
130
+ }));
131
+ onData(chunk(''));
132
+ },
133
+ };
134
+
135
+ const result = await runtime.generate(
136
+ 'request-incomplete-usage',
137
+ { messages: [{ role: 'user', content: 'Count.' }] },
138
+ new AbortController().signal,
139
+ () => undefined,
140
+ );
141
+
142
+ expect(result.usage).toEqual({ promptTokens: 9, completionTokens: 64, totalTokens: 73 });
143
+ });
144
  });
src/engine/runtime.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
  type ChatCompletionUsage,
7
  type ResultTimings,
8
  } from '../../vendor/wllama-bonsai/esm/index.js';
 
9
  import {
10
  assertBackendPolicy,
11
  estimateStorage,
@@ -46,8 +47,27 @@ const LLAMA_CPP_REVISION = '00fa7cb284cbf133fc426733bd64238a3588a33e';
46
  const WLLAMA_WASM_PATH = '/wasm/wllama.wasm';
47
  const WLLAMA_COMPAT_WASM_PATH = '/wasm/wllama-compat.wasm';
48
  const WLLAMA_COMPAT_WORKER_PATH = '/wasm/wllama-compat.js';
 
49
  const DEVICE_LOST_EXIT_GRACE_MS = 100;
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  type EventSink = (event: EngineEvent) => void;
52
 
53
  interface LoadedModelState {
@@ -145,14 +165,31 @@ function emitProgress(
145
  });
146
  }
147
 
148
- function mapUsage(usage: ChatCompletionUsage | null | undefined): GenerateResult['usage'] {
149
- if (!usage) {
 
 
 
 
 
 
 
 
 
150
  return null;
151
  }
 
 
 
 
 
 
 
 
152
  return {
153
- promptTokens: usage.prompt_tokens,
154
- completionTokens: usage.completion_tokens,
155
- totalTokens: usage.total_tokens,
156
  };
157
  }
158
 
@@ -290,16 +327,32 @@ export class BrowserEngineRuntime {
290
  inspectWebGpuAdapter(),
291
  estimateStorage(),
292
  ]);
 
 
 
 
293
  return {
294
  crossOriginIsolated: globalThis.crossOriginIsolated,
295
  sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
296
  hardwareConcurrency: navigator.hardwareConcurrency || 1,
297
  webgpu,
298
  storage,
 
 
 
 
 
299
  runtime: {
300
  implementation: 'bonsai-wllama',
301
  wllamaRevision: WLLAMA_REVISION,
302
  llamaCppRevision: LLAMA_CPP_REVISION,
 
 
 
 
 
 
 
303
  tokenEmbeddingOnWebGPU: true,
304
  tensorPlacementOverrides: false,
305
  },
@@ -488,6 +541,26 @@ export class BrowserEngineRuntime {
488
  params.tensorPlacement,
489
  );
490
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  await this.unload();
492
  this.nativeLog.clear();
493
  emitProgress(sink, requestId, {
@@ -551,15 +624,16 @@ export class BrowserEngineRuntime {
551
  if (!Number.isSafeInteger(threads) || threads <= 0) {
552
  throw new EngineRuntimeError('INVALID_THREAD_COUNT', 'Thread count must be a positive integer.');
553
  }
554
-
555
  try {
556
  await this.raceWebGpuDeviceLoss('load', gate.selectedBackend, model, wllama.loadModel(blobs, {
557
  n_gpu_layers: gate.selectedBackend === 'webgpu' ? 99999 : 0,
558
  offload_token_embedding: gate.selectedBackend === 'webgpu',
559
  n_ctx: contextSize,
560
  n_threads: threads,
 
 
561
  seed: 42,
562
- flash_attn: false,
563
  warmup: false,
564
  cache_type_k: params.cacheTypeK ?? 'f16',
565
  cache_type_v: params.cacheTypeV ?? 'f16',
@@ -597,6 +671,13 @@ export class BrowserEngineRuntime {
597
  trainingSize: context.n_ctx_train,
598
  layerCount: context.n_layer,
599
  vocabularySize: context.n_vocab,
 
 
 
 
 
 
 
600
  },
601
  chatTemplate: {
602
  bytes: new TextEncoder().encode(template).byteLength,
@@ -667,7 +748,7 @@ export class BrowserEngineRuntime {
667
  });
668
  }
669
  finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
670
- usage = mapUsage(chunk.usage) ?? usage;
671
  timings = mapTimings(chunk.timings) ?? timings;
672
  },
673
  }));
 
6
  type ChatCompletionUsage,
7
  type ResultTimings,
8
  } from '../../vendor/wllama-bonsai/esm/index.js';
9
+ import runtimeSource from '../../vendor/wllama-bonsai/SOURCE.json';
10
  import {
11
  assertBackendPolicy,
12
  estimateStorage,
 
47
  const WLLAMA_WASM_PATH = '/wasm/wllama.wasm';
48
  const WLLAMA_COMPAT_WASM_PATH = '/wasm/wllama-compat.wasm';
49
  const WLLAMA_COMPAT_WORKER_PATH = '/wasm/wllama-compat.js';
50
+ const MAX_GLUE_INT = 2_147_483_647;
51
  const DEVICE_LOST_EXIT_GRACE_MS = 100;
52
 
53
+ function runtimeArtifact(path: string): { bytes: number; sha256: string } {
54
+ const artifact = runtimeSource.files.find((file) => file.path === path);
55
+ if (!artifact) throw new Error(`Runtime provenance is missing ${path}`);
56
+ return artifact;
57
+ }
58
+
59
+ function needsCompatWasm(): boolean {
60
+ if (!(WebAssembly as typeof WebAssembly & { Suspending?: unknown }).Suspending) return true;
61
+ try {
62
+ new WebAssembly.Memory(
63
+ { address: 'i64', initial: 1n } as unknown as WebAssembly.MemoryDescriptor,
64
+ );
65
+ return false;
66
+ } catch {
67
+ return true;
68
+ }
69
+ }
70
+
71
  type EventSink = (event: EngineEvent) => void;
72
 
73
  interface LoadedModelState {
 
165
  });
166
  }
167
 
168
+ function tokenCount(value: number | null | undefined): number {
169
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
170
+ ? Math.floor(value)
171
+ : 0;
172
+ }
173
+
174
+ function mapUsage(
175
+ usage: ChatCompletionUsage | null | undefined,
176
+ timings: ResultTimings | undefined,
177
+ ): GenerateResult['usage'] {
178
+ if (!usage && !timings) {
179
  return null;
180
  }
181
+ const promptTokens = Math.max(
182
+ tokenCount(usage?.prompt_tokens),
183
+ tokenCount(timings?.prompt_n),
184
+ );
185
+ const completionTokens = Math.max(
186
+ tokenCount(usage?.completion_tokens),
187
+ tokenCount(timings?.predicted_n),
188
+ );
189
  return {
190
+ promptTokens,
191
+ completionTokens,
192
+ totalTokens: Math.max(tokenCount(usage?.total_tokens), promptTokens + completionTokens),
193
  };
194
  }
195
 
 
327
  inspectWebGpuAdapter(),
328
  estimateStorage(),
329
  ]);
330
+ const wasmFlavor = needsCompatWasm() ? 'compat' : 'jspi';
331
+ const wasmArtifact = runtimeArtifact(
332
+ wasmFlavor === 'compat' ? 'public/wasm/wllama-compat.wasm' : 'public/wasm/wllama.wasm',
333
+ );
334
  return {
335
  crossOriginIsolated: globalThis.crossOriginIsolated,
336
  sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
337
  hardwareConcurrency: navigator.hardwareConcurrency || 1,
338
  webgpu,
339
  storage,
340
+ browser: {
341
+ userAgent: navigator.userAgent,
342
+ platform: navigator.platform,
343
+ language: navigator.language,
344
+ },
345
  runtime: {
346
  implementation: 'bonsai-wllama',
347
  wllamaRevision: WLLAMA_REVISION,
348
  llamaCppRevision: LLAMA_CPP_REVISION,
349
+ patchSetSha256: runtimeSource.patchSet.sha256,
350
+ moduleSha256: runtimeArtifact('vendor/wllama-bonsai/esm/index.js').sha256,
351
+ wasmFlavor,
352
+ wasmSha256: wasmArtifact.sha256,
353
+ compatWorkerSha256: wasmFlavor === 'compat'
354
+ ? runtimeArtifact('public/wasm/wllama-compat.js').sha256
355
+ : null,
356
  tokenEmbeddingOnWebGPU: true,
357
  tensorPlacementOverrides: false,
358
  },
 
541
  params.tensorPlacement,
542
  );
543
  }
544
+ if (params.nBatch !== undefined && (
545
+ !Number.isSafeInteger(params.nBatch)
546
+ || params.nBatch <= 0
547
+ || params.nBatch > MAX_GLUE_INT
548
+ )) {
549
+ throw new EngineRuntimeError(
550
+ 'INVALID_BATCH_SIZE',
551
+ `n_batch must be a positive signed 32-bit integer (maximum ${MAX_GLUE_INT}).`,
552
+ );
553
+ }
554
+ if (params.nUbatch !== undefined && (
555
+ !Number.isSafeInteger(params.nUbatch)
556
+ || params.nUbatch <= 0
557
+ || params.nUbatch > MAX_GLUE_INT
558
+ )) {
559
+ throw new EngineRuntimeError(
560
+ 'INVALID_UBATCH_SIZE',
561
+ `n_ubatch must be a positive signed 32-bit integer (maximum ${MAX_GLUE_INT}).`,
562
+ );
563
+ }
564
  await this.unload();
565
  this.nativeLog.clear();
566
  emitProgress(sink, requestId, {
 
624
  if (!Number.isSafeInteger(threads) || threads <= 0) {
625
  throw new EngineRuntimeError('INVALID_THREAD_COUNT', 'Thread count must be a positive integer.');
626
  }
 
627
  try {
628
  await this.raceWebGpuDeviceLoss('load', gate.selectedBackend, model, wllama.loadModel(blobs, {
629
  n_gpu_layers: gate.selectedBackend === 'webgpu' ? 99999 : 0,
630
  offload_token_embedding: gate.selectedBackend === 'webgpu',
631
  n_ctx: contextSize,
632
  n_threads: threads,
633
+ n_batch: params.nBatch,
634
+ n_ubatch: params.nUbatch,
635
  seed: 42,
636
+ flash_attn: params.flashAttention ?? false,
637
  warmup: false,
638
  cache_type_k: params.cacheTypeK ?? 'f16',
639
  cache_type_v: params.cacheTypeV ?? 'f16',
 
671
  trainingSize: context.n_ctx_train,
672
  layerCount: context.n_layer,
673
  vocabularySize: context.n_vocab,
674
+ batchSize: context.n_batch,
675
+ microBatchSize: context.n_ubatch,
676
+ },
677
+ tuning: {
678
+ flashAttention: params.flashAttention ?? false,
679
+ cacheTypeK: params.cacheTypeK ?? 'f16',
680
+ cacheTypeV: params.cacheTypeV ?? 'f16',
681
  },
682
  chatTemplate: {
683
  bytes: new TextEncoder().encode(template).byteLength,
 
748
  });
749
  }
750
  finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
751
+ usage = mapUsage(chunk.usage, chunk.timings) ?? usage;
752
  timings = mapTimings(chunk.timings) ?? timings;
753
  },
754
  }));
src/main.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { StrictMode } from 'react';
2
  import { createRoot } from 'react-dom/client';
3
  import { App } from './app/App';
 
4
  import './app/styles.css';
5
 
6
  const root = document.getElementById('root');
@@ -9,8 +10,13 @@ if (!root) {
9
  throw new Error('Bonsai shell root element is missing');
10
  }
11
 
 
 
 
 
 
12
  createRoot(root).render(
13
  <StrictMode>
14
- <App />
15
  </StrictMode>,
16
  );
 
1
  import { StrictMode } from 'react';
2
  import { createRoot } from 'react-dom/client';
3
  import { App } from './app/App';
4
+ import { BenchApp } from './bench/BenchApp';
5
  import './app/styles.css';
6
 
7
  const root = document.getElementById('root');
 
10
  throw new Error('Bonsai shell root element is missing');
11
  }
12
 
13
+ export function isBenchPath(pathname: string): boolean {
14
+ const normalized = pathname.replace(/\/+$/, '');
15
+ return /(?:^|\/)bench(?:\/index\.html|\.html)?$/.test(normalized);
16
+ }
17
+
18
  createRoot(root).render(
19
  <StrictMode>
20
+ {isBenchPath(window.location.pathname) ? <BenchApp /> : <App />}
21
  </StrictMode>,
22
  );
vendor/wllama-bonsai/PATCH.diff ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/CMakeLists.txt b/CMakeLists.txt
2
+ index ba95350..33fcbbf 100644
3
+ --- a/CMakeLists.txt
4
+ +++ b/CMakeLists.txt
5
+ @@ -103,6 +103,7 @@ set(LLAMA_SERVER_SRC llama.cpp/tools/server/server-context.cpp
6
+ llama.cpp/tools/server/server-task.cpp
7
+ llama.cpp/tools/server/server-chat.cpp
8
+ llama.cpp/tools/server/server-common.cpp
9
+ + llama.cpp/tools/server/server-schema.cpp
10
+ )
11
+
12
+ set(WLLAMA_SRC ${LLAMA_COMMON_SRC} ${LLAMA_SERVER_SRC}
13
+ diff --git a/cpp/glue.hpp b/cpp/glue.hpp
14
+ index f1496a4..8883324 100644
15
+ --- a/cpp/glue.hpp
16
+ +++ b/cpp/glue.hpp
17
+ @@ -502,6 +502,7 @@ struct glue_msg_load_req
18
+ GLUE_FIELD(bool, use_mmap)
19
+ GLUE_FIELD(bool, use_mlock)
20
+ GLUE_FIELD(int, n_gpu_layers)
21
+ + GLUE_FIELD_NULLABLE(bool, offload_token_embedding)
22
+ GLUE_FIELD(int, n_ctx)
23
+ GLUE_FIELD(int, n_threads)
24
+ GLUE_FIELD_NULLABLE(str, model_alias)
25
+ diff --git a/cpp/wllama-context.h b/cpp/wllama-context.h
26
+ index 0dafe6d..c4b46f9 100644
27
+ --- a/cpp/wllama-context.h
28
+ +++ b/cpp/wllama-context.h
29
+ @@ -19,6 +19,7 @@
30
+
31
+ #include "server-context.h"
32
+ #include "server-queue.h"
33
+ +#include "server-schema.h"
34
+
35
+ #include "ggml-cpu.h"
36
+ #include "ggml-backend.h"
37
+ @@ -300,7 +301,7 @@ struct wllama_context
38
+ server_task task = server_task(SERVER_TASK_TYPE_COMPLETION);
39
+ task.id = rd->get_new_id();
40
+ task.index = 0;
41
+ - task.params = server_task::params_from_json_cmpl(
42
+ + task.params = server_schema::eval_llama_cmpl_schema(
43
+ vocab,
44
+ params,
45
+ meta->slot_n_ctx,
46
+ @@ -415,6 +416,8 @@ struct wllama_context
47
+ params.embedding = req.embeddings.value;
48
+ if (req.n_batch.not_null())
49
+ params.n_batch = req.n_batch.value;
50
+ + if (req.n_ubatch.not_null())
51
+ + params.n_ubatch = req.n_ubatch.value;
52
+ if (req.n_parallel.not_null())
53
+ params.n_parallel = req.n_parallel.value;
54
+ if (req.pooling_type.not_null())
55
+ @@ -568,6 +571,24 @@ struct wllama_context
56
+ // load model
57
+ llama_backend_init();
58
+ llama_numa_init(params.numa);
59
+ + if (req.offload_token_embedding.not_null() && req.offload_token_embedding.value)
60
+ + {
61
+ + auto *webgpu_dev = ggml_backend_dev_by_name("WebGPU");
62
+ + if (webgpu_dev == nullptr)
63
+ + {
64
+ + throw app_exception("offload_token_embedding was requested, but the WebGPU device is unavailable");
65
+ + }
66
+ +
67
+ + auto *webgpu_buft = ggml_backend_dev_buffer_type(webgpu_dev);
68
+ + if (webgpu_buft == nullptr)
69
+ + {
70
+ + throw app_exception("offload_token_embedding was requested, but the WebGPU buffer type is unavailable");
71
+ + }
72
+ +
73
+ + params.tensor_buft_overrides.push_back({"^token_embd\\.weight$", webgpu_buft});
74
+ + params.tensor_buft_overrides.push_back({nullptr, nullptr});
75
+ + fprintf(stderr, "@@INFO@@offload_token_embedding: token_embd.weight -> %s\n", ggml_backend_buft_name(webgpu_buft));
76
+ + }
77
+ if (!ctx_server.load_model(params))
78
+ {
79
+ glue_msg_load_res res;
80
+ diff --git a/src/glue/messages.ts b/src/glue/messages.ts
81
+ index 08f0d87..c1001da 100644
82
+ --- a/src/glue/messages.ts
83
+ +++ b/src/glue/messages.ts
84
+ @@ -53,6 +53,11 @@ export const GLUE_MESSAGE_PROTOTYPES: { [name: string]: GlueMessageProto } = {
85
+ "name": "n_gpu_layers",
86
+ "isNullable": false
87
+ },
88
+ + {
89
+ + "type": "bool",
90
+ + "name": "offload_token_embedding",
91
+ + "isNullable": true
92
+ + },
93
+ {
94
+ "type": "int",
95
+ "name": "n_ctx",
96
+ @@ -618,6 +623,7 @@ export interface GlueMsgLoadReq {
97
+ use_mmap: boolean;
98
+ use_mlock: boolean;
99
+ n_gpu_layers: number;
100
+ + offload_token_embedding?: boolean | undefined;
101
+ n_ctx: number;
102
+ n_threads: number;
103
+ model_alias?: string | undefined;
104
+ diff --git a/src/types/types.ts b/src/types/types.ts
105
+ index 2b3cca7..e91b395 100644
106
+ --- a/src/types/types.ts
107
+ +++ b/src/types/types.ts
108
+ @@ -4,8 +4,12 @@ export interface LoadModelParams {
109
+ seed?: number;
110
+ n_ctx?: number;
111
+ n_batch?: number;
112
+ + n_ubatch?: number;
113
+ // by default, all layers are offloaded if WebGPU is available
114
+ n_gpu_layers?: number;
115
+ + // keeps token lookup on WebGPU, avoiding a CPU GET_ROWS graph split;
116
+ + // defaults to true when n_gpu_layers is non-zero and WebGPU supports shader-f16
117
+ + offload_token_embedding?: boolean;
118
+ // by default, on multi-thread build, we take half number of available threads (hardwareConcurrency / 2)
119
+ n_threads?: number;
120
+ embeddings?: boolean;
121
+ diff --git a/src/wllama.ts b/src/wllama.ts
122
+ index ce74b2b..8057028 100644
123
+ --- a/src/wllama.ts
124
+ +++ b/src/wllama.ts
125
+ @@ -478,6 +478,27 @@ export class Wllama {
126
+ if (this.proxy) {
127
+ throw new WllamaError('Module is already initialized', 'load_error');
128
+ }
129
+ + const nGpuLayers = params.n_gpu_layers ?? 99999;
130
+ + const wantsTokenEmbeddingOffload =
131
+ + params.offload_token_embedding === true ||
132
+ + (params.offload_token_embedding === undefined && nGpuLayers !== 0);
133
+ + let compatibleWebGPU = false;
134
+ + if (wantsTokenEmbeddingOffload && isSupportWebGPU()) {
135
+ + try {
136
+ + const adapter = await (navigator as any).gpu.requestAdapter();
137
+ + compatibleWebGPU = !!adapter?.features?.has('shader-f16');
138
+ + } catch {
139
+ + compatibleWebGPU = false;
140
+ + }
141
+ + }
142
+ + if (params.offload_token_embedding === true && !compatibleWebGPU) {
143
+ + throw new WllamaError(
144
+ + 'offload_token_embedding was requested, but WebGPU is unavailable',
145
+ + 'load_error'
146
+ + );
147
+ + }
148
+ + const offloadTokenEmbedding =
149
+ + params.offload_token_embedding ?? compatibleWebGPU;
150
+ // detect if we can use multi-thread and webgpu
151
+ const supportMultiThread = await isSupportMultiThread();
152
+ const hwConccurency = Math.floor((navigator.hardwareConcurrency || 1) / 2);
153
+ @@ -518,7 +539,8 @@ export class Wllama {
154
+ // if async read is not supported, use mmap; refer to README-dev.md for more details
155
+ use_mmap: !canUseAsyncFileRead(workerResources.compat),
156
+ use_mlock: false,
157
+ - n_gpu_layers: params.n_gpu_layers ?? 99999,
158
+ + n_gpu_layers: nGpuLayers,
159
+ + offload_token_embedding: offloadTokenEmbedding,
160
+ n_ctx: params.n_ctx ?? 1024,
161
+ n_threads: this.useMultiThread ? nbThreads : 1,
162
+ n_ctx_auto: false, // not supported for now
163
+ @@ -529,6 +551,7 @@ export class Wllama {
164
+ embeddings: params.embeddings,
165
+ offload_kqv: params.offload_kqv,
166
+ n_batch: params.n_batch,
167
+ + n_ubatch: params.n_ubatch,
168
+ pooling_type: params.pooling_type as string,
169
+ rope_scaling_type: params.rope_scaling_type as string,
170
+ rope_freq_base: params.rope_freq_base,
171
+ @@ -579,6 +602,10 @@ export class Wllama {
172
+ skip_chat_parsing: params.skip_chat_parsing,
173
+ prefill_assistant: params.prefill_assistant,
174
+ });
175
+ + if (!loadResult.success) {
176
+ + await this.exit();
177
+ + throw new WllamaError('Failed to load model', 'load_error');
178
+ + }
179
+ const loadedCtxInfo: LoadedContextInfo & GlueMsgLoadRes = {
180
+ ...loadResult,
181
+ metadata: {},
vendor/wllama-bonsai/SOURCE.json CHANGED
@@ -1,24 +1,49 @@
1
  {
 
2
  "upstream": "https://github.com/ngxson/wllama",
3
  "wllamaRevision": "912c18b75d4358c1405a64646b8dbe43a205943b",
4
  "llamaCppRevision": "00fa7cb284cbf133fc426733bd64238a3588a33e",
5
  "license": "MIT",
6
- "patch": "Expose offload_token_embedding and place token_embd.weight on WebGPU whenever GPU layers are requested; fail loudly when the requested WebGPU buffer is unavailable.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  "files": [
8
  {
9
  "path": "vendor/wllama-bonsai/esm/index.js",
10
- "sha256": "398239a8aeb35550bcbde7f6c3702e282fb87d73254865de9cb6f8f17c184435"
 
11
  },
12
  {
13
  "path": "public/wasm/wllama.wasm",
14
- "sha256": "829dd6c01e99893c7a0e2f9c00a97b1578ce30f0d03720b1eab5f3bc3063f973"
 
15
  },
16
  {
17
  "path": "public/wasm/wllama-compat.wasm",
18
- "sha256": "609979201d693d58cd23972113af5cbe08d0a8e16deef26fc3c9d230c0228eed"
 
19
  },
20
  {
21
  "path": "public/wasm/wllama-compat.js",
 
22
  "sha256": "0b667db536815fb9e5dc9ffbfbd6ba29affdda38a4ba892d5372ddb41ce9a8c8"
23
  }
24
  ]
 
1
  {
2
+ "schemaVersion": 2,
3
  "upstream": "https://github.com/ngxson/wllama",
4
  "wllamaRevision": "912c18b75d4358c1405a64646b8dbe43a205943b",
5
  "llamaCppRevision": "00fa7cb284cbf133fc426733bd64238a3588a33e",
6
  "license": "MIT",
7
+ "patch": "Expose offload_token_embedding and n_ubatch; place token_embd.weight on WebGPU whenever GPU layers are requested; fail loudly when the requested WebGPU buffer is unavailable.",
8
+ "patchSet": {
9
+ "format": "git-diff-binary",
10
+ "path": "vendor/wllama-bonsai/PATCH.diff",
11
+ "bytes": 6896,
12
+ "sha256": "62efebdb3071ba49b87c835c1b45716257fe5ea6cb7eec6ee82f6a2f33bc581c",
13
+ "files": [
14
+ "CMakeLists.txt",
15
+ "cpp/glue.hpp",
16
+ "cpp/wllama-context.h",
17
+ "src/glue/messages.ts",
18
+ "src/types/types.ts",
19
+ "src/wllama.ts"
20
+ ]
21
+ },
22
+ "build": {
23
+ "emsdkImage": "emscripten/emsdk:4.0.20-arm64",
24
+ "builderImageId": "sha256:1143696588c47ddcfa796d7a84663a1fd1d8f28e2e1db62d50cac618776d11dd",
25
+ "dawnTag": "v20260317.182325",
26
+ "dawnArchiveSha256": "8dcae86c630d76b6794b271c6572becba36f4237d132bee121a56638c3d76575"
27
+ },
28
  "files": [
29
  {
30
  "path": "vendor/wllama-bonsai/esm/index.js",
31
+ "bytes": 364498,
32
+ "sha256": "67eb16e67297b05e8fc7424d9b273d1586ad8097f8551216b55fa4b77d5e5c51"
33
  },
34
  {
35
  "path": "public/wasm/wllama.wasm",
36
+ "bytes": 8017169,
37
+ "sha256": "dd71f58c75f32c677a64eeb2e56efab7dedbe7395f730bb08bc80021bd182caa"
38
  },
39
  {
40
  "path": "public/wasm/wllama-compat.wasm",
41
+ "bytes": 14864188,
42
+ "sha256": "eca9754d0d8a490b5c7b58cb384d67e31aa8ab26dd330e4ad1460982df6dd82e"
43
  },
44
  {
45
  "path": "public/wasm/wllama-compat.js",
46
+ "bytes": 166895,
47
  "sha256": "0b667db536815fb9e5dc9ffbfbd6ba29affdda38a4ba892d5372ddb41ce9a8c8"
48
  }
49
  ]
vendor/wllama-bonsai/esm/index.cjs CHANGED
@@ -2919,6 +2919,7 @@ var Wllama = class {
2919
  embeddings: params.embeddings,
2920
  offload_kqv: params.offload_kqv,
2921
  n_batch: params.n_batch,
 
2922
  pooling_type: params.pooling_type,
2923
  rope_scaling_type: params.rope_scaling_type,
2924
  rope_freq_base: params.rope_freq_base,
 
2919
  embeddings: params.embeddings,
2920
  offload_kqv: params.offload_kqv,
2921
  n_batch: params.n_batch,
2922
+ n_ubatch: params.n_ubatch,
2923
  pooling_type: params.pooling_type,
2924
  rope_scaling_type: params.rope_scaling_type,
2925
  rope_freq_base: params.rope_freq_base,
vendor/wllama-bonsai/esm/index.js CHANGED
@@ -2884,6 +2884,7 @@ var Wllama = class {
2884
  embeddings: params.embeddings,
2885
  offload_kqv: params.offload_kqv,
2886
  n_batch: params.n_batch,
 
2887
  pooling_type: params.pooling_type,
2888
  rope_scaling_type: params.rope_scaling_type,
2889
  rope_freq_base: params.rope_freq_base,
 
2884
  embeddings: params.embeddings,
2885
  offload_kqv: params.offload_kqv,
2886
  n_batch: params.n_batch,
2887
+ n_ubatch: params.n_ubatch,
2888
  pooling_type: params.pooling_type,
2889
  rope_scaling_type: params.rope_scaling_type,
2890
  rope_freq_base: params.rope_freq_base,
vendor/wllama-bonsai/esm/index.min.js CHANGED
The diff for this file is too large to render. See raw diff
 
vendor/wllama-bonsai/esm/index.min.js.map CHANGED
The diff for this file is too large to render. See raw diff
 
vendor/wllama-bonsai/esm/types/types.d.ts CHANGED
@@ -3,6 +3,7 @@ export interface LoadModelParams {
3
  seed?: number;
4
  n_ctx?: number;
5
  n_batch?: number;
 
6
  n_gpu_layers?: number;
7
  offload_token_embedding?: boolean;
8
  n_threads?: number;
 
3
  seed?: number;
4
  n_ctx?: number;
5
  n_batch?: number;
6
+ n_ubatch?: number;
7
  n_gpu_layers?: number;
8
  offload_token_embedding?: boolean;
9
  n_threads?: number;
vendor/wllama-bonsai/esm/wasm/source-map.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export declare const WASM_SOURCE_MAP: Record<string, string>;
vite.config.ts CHANGED
@@ -3,6 +3,14 @@ import react from '@vitejs/plugin-react';
3
 
4
  export default defineConfig({
5
  plugins: [react()],
 
 
 
 
 
 
 
 
6
  server: {
7
  headers: {
8
  'Cross-Origin-Embedder-Policy': 'require-corp',
 
3
 
4
  export default defineConfig({
5
  plugins: [react()],
6
+ build: {
7
+ rollupOptions: {
8
+ input: {
9
+ main: 'index.html',
10
+ bench: 'bench/index.html',
11
+ },
12
+ },
13
+ },
14
  server: {
15
  headers: {
16
  'Cross-Origin-Embedder-Policy': 'require-corp',