1/**
2 * turing-surface-cache: reaction-diffusion solutions at a chosen end time,
3 * from a shared cloud cache when someone has computed them before, and from
4 * the local GPU when not.
5 *
6 * Every control is a choice from a short list (src/cache/options.ts), so the
7 * page's whole state is one small spec object. Get solution hashes that spec
8 * into a cache object name (src/cache/spec.ts) and fetches it; a 404 means
9 * nobody has computed it, so the solver runs here — live, watching the
10 * pattern form — and stops at exactly the requested time. A run to T passes
11 * exactly through every smaller listed end time, so those states are captured
12 * along the way; with an upload API key entered, all of them are contributed
13 * back to the cache.
14 *
15 * The solver is turing-surface's, unchanged: the model and geometry are
16 * MATLAB compiled (model) or interpreted (geometry) by numbl, the transforms
17 * are WGSL compute shaders. lmax, niter and the seed wavelength are fixed in
18 * this app (options.ts) — fewer knobs, same machinery.
19 */
20import { requestShtDevice, describeAdapter } from './sht/sht.ts';
21import { ModelSession } from './mgpu/session.ts';
22import { mModels, type MModel, type Params } from './mgpu/registry.ts';
23import { formatFailure } from './mgpu/errors.ts';
24import {
25 mGeometryByKey,
26 DEFAULT_GEOMETRY_KEY,
27 mGeometries,
28 type MGeometry,
29} from './geom/registry.ts';
30import {
31 buildTopology,
32 fillPositions,
33 fillFieldValues,
34 fillColors,
35 type SphereMeshTopology,
36} from './render/sphereMesh.ts';
37import { SphereScene } from './render/SphereScene.ts';
38import { Colorbar, floorRange } from './render/colorbar.ts';
39import { colormaps } from './render/colormaps.ts';
40import {
41 MODEL_CHOICES,
42 GEOMETRY_CHOICES,
43 SEED_CHOICE,
44 T_END_CHOICE,
45 LMAX,
46 NITER,
47 LAM3,
48 defaultChoiceParams,
49 fmtChoice,
50 type DiscreteChoice,
51} from './cache/options.ts';
52import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
53import { lookupFor, fetchCached, uploadCacheFile, type CacheLookup } from './cache/client.ts';
54import { encodeCacheFile, decodeCacheFile, type DecodedCacheFile } from './cache/h5file.ts';
56const $ = <T extends HTMLElement>(id: string): T =>
57 document.getElementById(id) as T;
59const elParams = $('params');
60const elGeometry = $<HTMLSelectElement>('geometry');
61const elGeomParams = $('geomparams');
62const elSeed = $<HTMLSelectElement>('seed');
63const elTend = $<HTMLSelectElement>('tend');
64const elSolve = $<HTMLButtonElement>('solve');
65const elStop = $<HTMLButtonElement>('stop');
66const elReset = $<HTMLButtonElement>('reset');
67const elCacheNote = $('cachenote');
68const elStatus = $('status');
69const elPanels = $('panels');
70const elResetView = $<HTMLButtonElement>('resetview');
71const elDownload = $<HTMLAnchorElement>('download');
72const elStats = $('stats');
73const elApiKey = $<HTMLInputElement>('apikey');
74const elUploadNote = $('uploadnote');
75const elErr = $('err');
77/**
78 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
79 * values (still cached under their own honest specs — a test end time hashes
80 * to its own object). The headless checks use this to keep their computed
81 * runs short; it is not part of the normal UI.
82 */
83{
84 const param = new URLSearchParams(location.search).get('tend');
85 if (param) {
86 const values = param
87 .split(',')
88 .map(Number)
89 .filter((v) => Number.isFinite(v) && v > 0);
90 if (values.length) {
91 T_END_CHOICE.values = values;
92 T_END_CHOICE.value = values[0];
93 }
94 }
95}
97const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
98const COLORMAP = colormaps.viridis;
99/** Render on a 2x finer grid than the solver's; exact interpolation. */
100const OVERSAMPLE = 2;
101/** Cap on GPU dispatches per submission (watchdog safety; see turing-surface). */
102const DISPATCH_BUDGET = 1000;
103/** Steps between syncs during a computation: many small submissions queued
104 * back to back, one wait. The readbacks and renders that pace the live view
105 * happen per chunk, not per submission — that is what lets the run advance
106 * at close to the solver's own rate. */
107const CHUNK_STEPS = 32;
108/** How often the live view renders during a computation. */
109const RENDER_EVERY_MS = 250;
111// ---------------------------------------------------------------- state
112const model: MModel = mModels[0];
113let device: GPUDevice | null = null;
114let session: ModelSession | null = null;
115let adapterName = '';
116/** Steps per GPU submission, sized in boot() so one submission stays under
117 * the dispatch budget however expensive niter has made a step. */
118let stepsPerSubmit = 4;
120/** The discrete selections, always exactly values from options.ts. */
121let params: Params = Object.fromEntries(MODEL_CHOICES.map((c) => [c.key, c.value]));
122let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
123let geomParams: Params = Object.fromEntries(
124 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
125);
126let seed = SEED_CHOICE.value;
127let tEnd = T_END_CHOICE.value;
129// The URL fragment carries the whole selection, so a reload comes back to it
130// and a shared link opens on the same spec (and, through refresh(), the same
131// cached solution). Read once at startup; rewritten on every change.
132readUrlState();
134/** What the session currently has applied (params are cheap; geometry is a
135 * rebuild of the surface and the mesh, so it is compared before applying). */
136let sessionGeomKey = '';
137let sessionGeomParams: Params = {};
139let topo: SphereMeshTopology | null = null;
140let scenes: SphereScene[] = [];
141let colorbars: Colorbar[] = [];
142/** The colorbar containers, hidden while the windows are empty. */
143let colorbarEls: HTMLElement[] = [];
144let valueBufs: Float32Array[] = [];
145let colorBufs: Float32Array[] = [];
146let ranges: { lo: number; hi: number }[] = [];
147let resizeObs: ResizeObserver | null = null;
148let coords: Float32Array | null = null;
149let posBuf: Float32Array | null = null;
151let generation = 0;
152let busy = false;
153/** True while computeLocally is stepping/reading back. Every read shares one
154 * staging buffer (GpuModel#readback), so a new solve must drain the old
155 * loop before issuing reads of its own. */
156let pumping = false;
157let stopRequested = false;
158/** Simulation time of the state on display (loadState resets session.t). */
159let shownT: number | null = null;
160let downloadUrl: string | null = null;
162const nextFrame = () => new Promise<number>(requestAnimationFrame);
164// ---------------------------------------------------------------- spec
165function currentSpec(): CacheSpec {
166 return {
167 app: APP_NAME,
168 formatVersion: FORMAT_VERSION,
169 model: model.key,
170 params: { ...params },
171 geometry: geometry.key,
172 geometryParams: { ...geomParams },
173 lmax: LMAX,
174 niter: NITER,
175 lam3: LAM3,
176 seed,
177 tEnd,
178 };
179}
181// ---------------------------------------------------------------- URL state
182/**
183 * The selection lives in the URL fragment, every value written explicitly
184 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
185 * keeps meaning the same spec even if a default changes later. The fragment
186 * is chosen over the query string to leave `?tend` to the test hook. Values
187 * are only accepted if they are exactly entries of the discrete lists;
188 * anything else keeps the default.
189 */
190function readUrlState(): void {
191 const hash = location.hash.replace(/^#/, '');
192 if (!hash) return;
193 const p = new URLSearchParams(hash);
194 // `name` is the key as it appears in the URL; it defaults to the choice's
195 // own key but is passed explicitly where the two differ (tEnd vs tend).
196 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
197 const raw = p.get(name);
198 if (raw === null) return current;
199 const v = Number(raw);
200 return choice.values.includes(v) ? v : current;
201 };
202 const g = p.get('geometry');
203 if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
204 geometry = mGeometryByKey(g)!;
205 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
206 }
207 for (const c of MODEL_CHOICES) params[c.key] = pick(c, params[c.key]);
208 for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
209 seed = pick(SEED_CHOICE, seed);
210 tEnd = pick(T_END_CHOICE, tEnd, 'tend');
211}
213function writeUrlState(): void {
214 const p = new URLSearchParams();
215 for (const c of MODEL_CHOICES) p.set(c.key, fmtChoice(params[c.key]));
216 p.set('geometry', geometry.key);
217 for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
218 p.set('seed', String(seed));
219 p.set('tend', fmtChoice(tEnd));
220 history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
221}
223// ---------------------------------------------------------------- controls
224/** Every select made by makeSelect, so a reset can push new values into the
225 * ones still on the page. */
226const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
228function syncSelects(): void {
229 for (const b of boundSelects) {
230 if (b.el.isConnected) b.el.value = String(b.get());
231 }
232}
234function makeSelect(
235 choice: DiscreteChoice,
236 get: () => number,
237 set: (v: number) => void,
238): HTMLLabelElement {
239 const label = document.createElement('label');
240 label.textContent = `${choice.label} `;
241 const select = document.createElement('select');
242 for (const v of choice.values) {
243 const opt = document.createElement('option');
244 opt.value = String(v);
245 opt.textContent = fmtChoice(v);
246 select.append(opt);
247 }
248 select.value = String(get());
249 select.addEventListener('change', () => {
250 set(Number(select.value));
251 onSelectionChange();
252 });
253 label.append(select);
254 boundSelects.push({ el: select, get });
255 return label;
256}
258/** Put every selection back to its default and refresh. */
259function resetDefaults(): void {
260 params = defaultChoiceParams(MODEL_CHOICES);
261 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
262 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
263 seed = SEED_CHOICE.value;
264 tEnd = T_END_CHOICE.value;
265 elGeometry.value = geometry.key;
266 buildGeomParamControls();
267 elSeed.value = String(seed);
268 elTend.value = String(tEnd);
269 syncSelects();
270 onSelectionChange();
271}
273function buildControls(): void {
274 for (const choice of MODEL_CHOICES) {
275 elParams.append(
276 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
277 );
278 }
279 for (const g of mGeometries) {
280 const opt = document.createElement('option');
281 opt.value = g.key;
282 opt.textContent = g.label.toLowerCase();
283 elGeometry.append(opt);
284 }
285 elGeometry.value = geometry.key;
286 elGeometry.addEventListener('change', () => {
287 geometry = mGeometryByKey(elGeometry.value)!;
288 geomParams = Object.fromEntries(
289 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
290 );
291 buildGeomParamControls();
292 onSelectionChange();
293 });
294 buildGeomParamControls();
296 for (const v of SEED_CHOICE.values) {
297 const opt = document.createElement('option');
298 opt.value = String(v);
299 opt.textContent = String(v);
300 elSeed.append(opt);
301 }
302 elSeed.value = String(seed);
303 elSeed.addEventListener('change', () => {
304 seed = Number(elSeed.value);
305 onSelectionChange();
306 });
308 for (const v of T_END_CHOICE.values) {
309 const opt = document.createElement('option');
310 opt.value = String(v);
311 opt.textContent = String(v);
312 elTend.append(opt);
313 }
314 elTend.value = String(tEnd);
315 elTend.addEventListener('change', () => {
316 tEnd = Number(elTend.value);
317 onSelectionChange();
318 });
319}
321function buildGeomParamControls(): void {
322 elGeomParams.replaceChildren();
323 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
324 elGeomParams.append(
325 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
326 );
327 }
328}
330/**
331 * A selection change refreshes the display: a cached solution loads and
332 * shows immediately, an uncached one shows empty surfaces until the user
333 * explicitly presses Compute solution. While a computation is running the
334 * change touches nothing — the run keeps going and only the is-it-cached
335 * note follows the dropdowns.
336 *
337 * Refreshes and button presses are chained so two flows never talk to the
338 * session at once.
339 */
340let flowChain: Promise<void> = Promise.resolve();
341function onSelectionChange(): void {
342 writeUrlState();
343 // During a computation the refresh is deferred until the run finishes; the
344 // is-it-cached note should follow the dropdowns right away regardless.
345 if (busy) void updateCacheNote();
346 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
347}
349// The note carries a token so a slow HEAD for a superseded selection never
350// overwrites the note for the current one.
351let cacheNoteToken = 0;
352async function updateCacheNote(): Promise<void> {
353 const token = ++cacheNoteToken;
354 elCacheNote.textContent = '';
355 let lookup: CacheLookup;
356 try {
357 lookup = await lookupFor(currentSpec());
358 } catch {
359 return;
360 }
361 let present: boolean | null = null;
362 try {
363 const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
364 present = res.ok ? true : res.status === 404 ? false : null;
365 } catch {
366 present = null;
367 }
368 if (token !== cacheNoteToken) return;
369 setCacheNote(present);
370}
372function setCacheNote(present: boolean | null): void {
373 if (present === true) {
374 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
375 } else if (present === false) {
376 elCacheNote.textContent = 'not cached yet';
377 } else {
378 elCacheNote.textContent = '';
379 }
380}
382// ---------------------------------------------------------------- view
383function disposeView(): void {
384 for (const s of scenes) s.dispose();
385 scenes = [];
386 colorbars = [];
387 colorbarEls = [];
388 topo = null;
389 coords = null;
390 posBuf = null;
391 resizeObs?.disconnect();
392 resizeObs = null;
393 elPanels.replaceChildren();
394}
396function buildView(surface: Float32Array): void {
397 if (!session) return;
398 const view = session.viewSht;
399 const { nphi } = view.cfg;
400 const phi = new Float64Array(nphi);
401 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
402 topo = buildTopology(view.cosTheta, phi);
403 coords = surface;
404 posBuf = new Float32Array(topo.numVertices * 3);
405 fillPositions(posBuf, coords, topo, 1);
407 const sphereBg = getComputedStyle(document.documentElement)
408 .getPropertyValue('--sphere-bg')
409 .trim();
410 for (let k = 0; k < model.species.length; k++) {
411 const panel = document.createElement('div');
412 panel.className = 'panel';
413 const box = document.createElement('div');
414 box.className = 'sphere-box';
415 const tag = document.createElement('div');
416 tag.className = 'species-tag';
417 tag.textContent = model.species[k];
418 box.append(tag);
419 const side = document.createElement('div');
420 panel.append(box, side);
421 elPanels.append(panel);
423 const scene = new SphereScene(
424 box,
425 topo.numVertices,
426 topo.indices,
427 Float32Array.from(posBuf),
428 sphereBg || undefined,
429 );
430 scene.fitCamera();
431 scenes.push(scene);
432 colorbars.push(new Colorbar(side));
433 colorbarEls.push(side);
434 valueBufs[k] = new Float32Array(topo.numVertices);
435 colorBufs[k] = new Float32Array(topo.numVertices * 3);
436 ranges[k] = { lo: NaN, hi: NaN };
437 }
438 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
440 resizeObs = new ResizeObserver(() => {
441 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
442 boxes.forEach((box, i) => {
443 scenes[i]?.resize(box.clientWidth, box.clientHeight);
444 });
445 });
446 elPanels
447 .querySelectorAll<HTMLElement>('.sphere-box')
448 .forEach((box) => resizeObs!.observe(box));
449}
451async function draw(): Promise<void> {
452 if (!session || !topo) return;
453 const gen = generation;
454 for (let k = 0; k < model.species.length; k++) {
455 let field: Float32Array;
456 try {
457 field = await session.readSpecies(k);
458 } catch (e) {
459 if (gen !== generation) return;
460 throw e;
461 }
462 if (gen !== generation || !topo) return;
463 fillFieldValues(valueBufs[k], field, topo);
464 let lo = Infinity;
465 let hi = -Infinity;
466 for (const v of valueBufs[k]) {
467 if (v < lo) lo = v;
468 if (v > hi) hi = v;
469 }
470 // Smooth the color range in both directions so the shading evolves gently
471 // as the pattern grows (out-of-range values clamp meanwhile).
472 const r = ranges[k];
473 if (!Number.isFinite(r.lo)) {
474 r.lo = lo;
475 r.hi = hi;
476 } else {
477 const a = 0.15;
478 r.lo += a * (lo - r.lo);
479 r.hi += a * (hi - r.hi);
480 }
481 const shown = floorRange(r.lo, r.hi);
482 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
483 scenes[k]?.updateColors(colorBufs[k]);
484 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
485 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
486 }
487}
489/** Empty windows: the selected surface with no field on it. Shown when the
490 * selection has no cached solution and nothing has been computed yet. */
491function clearDisplay(): void {
492 shownT = null;
493 elDownload.hidden = true;
494 if (!topo) return;
495 for (let k = 0; k < model.species.length; k++) {
496 // NaN renders as neutral gray in fillColors — the shape without a field.
497 valueBufs[k].fill(NaN);
498 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
499 scenes[k]?.updateColors(colorBufs[k]);
500 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
501 }
502 updateStats();
503}
505function resetRanges(): void {
506 for (const r of ranges) {
507 r.lo = NaN;
508 r.hi = NaN;
509 }
510}
512function updateStats(): void {
513 if (!session) return;
514 const { nlat, nphi } = session.cfg;
515 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
516 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
517 elStats.innerHTML =
518 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
519 `solve iters ${NITER}${t}`;
520}
522// ---------------------------------------------------------------- statuses
523function status(html: string): void {
524 elStatus.innerHTML = html;
525}
527function setBusy(next: boolean): void {
528 busy = next;
529 elSolve.disabled = next;
530 elStop.hidden = !next;
531}
533function offerDownload(bytes: Uint8Array, name: string): void {
534 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
535 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
536 elDownload.href = downloadUrl;
537 elDownload.download = name;
538 elDownload.hidden = false;
539}
541// ---------------------------------------------------------------- solving
542/** Apply the current selection to the (one, reused) session: params are a
543 * uniform upload; a geometry change re-evaluates the surface and rebuilds
544 * the mesh, keeping the camera. */
545async function applySelection(spec: CacheSpec): Promise<void> {
546 if (!session) throw new Error('no session');
547 session.setParams(spec.params);
548 const geomChanged =
549 spec.geometry !== sessionGeomKey ||
550 JSON.stringify(spec.geometryParams) !== JSON.stringify(sessionGeomParams);
551 if (!geomChanged) return;
552 const geomModel = mGeometryByKey(spec.geometry)!;
553 await session.setGeometry(geomModel, spec.geometryParams);
554 sessionGeomKey = spec.geometry;
555 sessionGeomParams = { ...spec.geometryParams };
556 const surface = await session.renderPositions();
557 const cam = scenes[0]?.cameraState();
558 disposeView();
559 buildView(surface);
560 if (cam) for (const s of scenes) s.setCameraState(cam);
561 // Fresh buffers render black until the first fill; show the bare surface
562 // instead. The caller's draw or clearDisplay follows right behind.
563 clearDisplay();
564}
566/** Decode a fetched cache file and put it on screen. */
567async function displayCached(
568 bytes: Uint8Array,
569 lookup: CacheLookup,
570 spec: CacheSpec,
571 gen: number,
572): Promise<void> {
573 if (!session) return;
574 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
575 if (gen !== generation) return;
576 session.loadState(decoded.final);
577 shownT = spec.tEnd;
578 resetRanges();
579 await draw();
580 updateStats();
581 const kb = (bytes.length / 1024).toFixed(0);
582 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
583 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
584 status(
585 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
586 `(${kb} KB${from}${when}).`,
587 );
588 offerDownload(bytes, lookup.fileName.split('/').pop()!);
589}
591/**
592 * Bring the display in line with the current selection, without ever
593 * starting a computation: a cached solution loads and shows, an uncached one
594 * shows empty surfaces and waits for the Compute solution button. Runs on
595 * startup and on every selection change; a no-op while a computation is
596 * running (the run is not disturbed — only the cache note follows).
597 */
598async function refresh(): Promise<void> {
599 if (!session || busy) {
600 void updateCacheNote();
601 return;
602 }
603 generation++;
604 const gen = generation;
605 elErr.textContent = '';
606 const spec = currentSpec();
607 try {
608 const lookup = await lookupFor(spec);
609 status('checking the cloud cache…');
610 let bytes: Uint8Array | null = null;
611 let unreachable = false;
612 try {
613 bytes = await fetchCached(lookup);
614 } catch {
615 unreachable = true;
616 }
617 if (gen !== generation) return;
618 await applySelection(spec);
619 if (gen !== generation) return;
620 if (bytes) {
621 await displayCached(bytes, lookup, spec, gen);
622 setCacheNote(true);
623 return;
624 }
625 clearDisplay();
626 setCacheNote(unreachable ? null : false);
627 status(
628 unreachable
629 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
630 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
631 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
632 `cached shorter run of the same settings is picked up where it left off).`,
633 );
634 } catch (e) {
635 if (gen === generation) {
636 elErr.textContent = formatFailure(e, model.source);
637 status('failed.');
638 }
639 }
640}
642/** The Compute solution button: cache lookup, then either load or compute. */
643async function solve(): Promise<void> {
644 if (!session || busy) return;
645 generation++;
646 const gen = generation;
647 setBusy(true);
648 // A stopped run may still be inside an await; let it see the generation
649 // bump and finish before touching the session.
650 while (pumping) await nextFrame();
651 if (gen !== generation) return;
652 stopRequested = false;
653 elErr.textContent = '';
654 elDownload.hidden = true;
655 const spec = currentSpec();
656 try {
657 const lookup = await lookupFor(spec);
658 status('checking the cloud cache…');
659 let bytes: Uint8Array | null = null;
660 try {
661 bytes = await fetchCached(lookup);
662 } catch (e) {
663 // An unreachable cache degrades to computing locally, and says so.
664 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
665 }
666 if (gen !== generation) return;
667 await applySelection(spec);
668 if (gen !== generation) return;
670 if (bytes) {
671 await displayCached(bytes, lookup, spec, gen);
672 return;
673 }
674 await computeLocally(spec, gen);
675 } catch (e) {
676 if (gen === generation) {
677 elErr.textContent = formatFailure(e, model.source);
678 status('failed.');
679 }
680 } finally {
681 if (gen === generation) setBusy(false);
682 void updateCacheNote();
683 }
684}
686/** Run the solver to the spec's end time, watching the pattern form, and
687 * capture the state at every smaller listed end time on the way. */
688async function computeLocally(spec: CacheSpec, gen: number): Promise<void> {
689 if (!session) return;
690 pumping = true;
691 try {
692 await computeLocallyInner(spec, gen);
693 } finally {
694 pumping = false;
695 }
696}
698async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void> {
699 if (!session) return;
700 const steps = stepsFor(spec);
701 const dt = spec.params.dt;
703 // Warm start: the state is Markovian in (U, V), so a cached run of the
704 // same spec at a smaller listed end time is an exact prefix of this one.
705 // Take the longest one there is and continue from its final state rather
706 // than recomputing it.
707 let warm: { tEnd: number; decoded: DecodedCacheFile } | null = null;
708 const earlier = T_END_CHOICE.values.filter((T) => T < spec.tEnd).sort((a, b) => b - a);
709 if (earlier.length) status('not in the cache — looking for a shorter cached run…');
710 for (const T of earlier) {
711 const lookup = await lookupFor({ ...spec, tEnd: T });
712 let bytes: Uint8Array | null = null;
713 try {
714 bytes = await fetchCached(lookup);
715 } catch {
716 break; // cache unreachable: no point probing further down the ladder
717 }
718 if (gen !== generation) return;
719 if (!bytes) continue;
720 try {
721 warm = { tEnd: T, decoded: await decodeCacheFile(bytes, lookup.specJson, model.state) };
722 break;
723 } catch {
724 continue; // an unreadable candidate is skipped, not fatal
725 }
726 }
727 if (gen !== generation) return;
729 let initial: Record<string, Float32Array>;
730 if (warm) {
731 session.loadState(warm.decoded.final);
732 // loadState resets the clock; put it at the cached run's end so the loop
733 // below computes only the remainder.
734 session.steps = Math.round(warm.tEnd / dt);
735 session.t = warm.tEnd;
736 // The t = 0 state travels with every file of the chain, so files written
737 // from this continuation carry the same initial state as the one resumed.
738 initial = warm.decoded.initial;
739 } else {
740 status(`not in the cache — <b>computing locally</b>: seeding…`);
741 await session.seed(spec.seed);
742 if (gen !== generation) return;
743 initial = await session.readState();
744 if (gen !== generation) return;
745 }
746 const startSteps = session.steps;
748 // Snapshot points: every listed end time strictly between the starting
749 // point and this run's end. The run passes through each exactly (all are
750 // whole multiples of every dt choice).
751 const snapshotAt = new Map<number, number>(); // step index -> tEnd value
752 for (const T of T_END_CHOICE.values) {
753 if (T < spec.tEnd && T > (warm?.tEnd ?? 0)) snapshotAt.set(Math.round(T / dt), T);
754 }
755 const snapshots: { tEnd: number; state: Record<string, Float32Array> }[] = [];
757 // Everything a cache file needs exists before the run starts, so a snapshot
758 // is encoded and uploaded the moment it is captured, overlapping the
759 // network with the GPU still stepping, rather than queued for the end.
760 const geometryCoeffs = {
761 X: session.geometry.X,
762 Y: session.geometry.Y,
763 Z: session.geometry.Z,
764 };
765 const encode = (t: number, state: Record<string, Float32Array>) =>
766 encodeCacheFile({
767 spec: { ...spec, tEnd: t },
768 grid: session!.cfg,
769 species: model.state,
770 geometry: geometryCoeffs,
771 initial,
772 final: state,
773 adapter: adapterName,
774 });
775 const uploadedTimes: number[] = [];
776 const uploadErrors: string[] = [];
777 let uploadsStarted = 0;
778 const pendingUploads: Promise<void>[] = [];
779 /** Encode + upload without the stepping loop waiting. A captured snapshot
780 * is a complete solution of its own spec, so this stays valid even if the
781 * run is stopped afterwards. */
782 const uploadInBackground = (
783 t: number,
784 state: Record<string, Float32Array>,
785 apiKey: string,
786 preEncoded?: Uint8Array,
787 ): void => {
788 uploadsStarted++;
789 pendingUploads.push(
790 (async () => {
791 const bytes = preEncoded ?? (await encode(t, state));
792 const lookup = await lookupFor({ ...spec, tEnd: t });
793 await uploadCacheFile(apiKey, lookup.fileName, bytes);
794 uploadedTimes.push(t);
795 })().catch((e) => {
796 uploadErrors.push(`t = ${fmtChoice(t)}: ${e instanceof Error ? e.message : e}`);
797 }),
798 );
799 };
801 shownT = null;
802 resetRanges();
803 const t0 = performance.now();
804 let lastStatus = 0;
805 let lastDraw = 0;
806 while (session.steps < steps) {
807 if (gen !== generation) return;
808 if (stopRequested) {
809 shownT = session.steps * dt;
810 await draw();
811 updateStats();
812 const up = uploadedTimes.length
813 ? ` ${uploadedTimes.length} snapshot${uploadedTimes.length > 1 ? 's' : ''} already uploaded.`
814 : ' Nothing uploaded.';
815 status(`stopped at t = ${(session.steps * dt).toFixed(2)}.${up}`);
816 return;
817 }
818 // One chunk: up to CHUNK_STEPS steps submitted back to back (each
819 // submission stays under the dispatch budget), then a single sync and at
820 // most one render. Reading back and drawing after every submission is
821 // what made the run advance at a fraction of the solver's rate — a
822 // readback costs several times the 3-4 steps it fenced. The chunk stops
823 // exactly at snapshot points so those states are still captured exactly.
824 let target = Math.min(steps, session.steps + CHUNK_STEPS);
825 for (const s of snapshotAt.keys()) {
826 if (s > session.steps && s < target) target = s;
827 }
828 while (session.steps < target) {
829 session.step(Math.min(stepsPerSubmit, target - session.steps));
830 }
831 // The sync bounds how far the CPU runs ahead of the GPU, and (being a
832 // promise) yields to the event loop, which is what keeps Stop clickable.
833 await session.sync();
834 if (gen !== generation) return;
835 const hit = snapshotAt.get(session.steps);
836 if (hit !== undefined) {
837 const state = await session.readState();
838 if (gen !== generation) return;
839 // With a key on hand the snapshot goes straight to the cache; without
840 // one it is kept, in case a key is entered before the run ends.
841 const apiKey = elApiKey.value.trim();
842 if (apiKey) uploadInBackground(hit, state, apiKey);
843 else snapshots.push({ tEnd: hit, state });
844 }
845 const now = performance.now();
846 if (now - lastDraw > RENDER_EVERY_MS || session.steps >= steps) {
847 lastDraw = now;
848 await draw();
849 if (gen !== generation) return;
850 await nextFrame();
851 }
852 if (now - lastStatus > 200) {
853 lastStatus = now;
854 const t = session.steps * dt;
855 const pct = ((100 * (session.steps - startSteps)) / (steps - startSteps)).toFixed(0);
856 const rate = (session.steps - startSteps) / ((now - t0) / 1000);
857 const from = warm ? `resumed from cached t = ${fmtChoice(warm.tEnd)} — ` : '';
858 const up = uploadsStarted
859 ? `, uploaded ${uploadedTimes.length}/${uploadsStarted} snapshots`
860 : '';
861 status(
862 `not in the cache — <b>computing locally</b> (${from}` +
863 `t = ${t.toFixed(2)} / ${fmtChoice(spec.tEnd)}, ${pct}%, ${rate.toFixed(0)} steps/s${up})`,
864 );
865 }
866 }
868 const final = await session.readState();
869 if (gen !== generation) return;
870 shownT = spec.tEnd;
871 await draw();
872 updateStats();
873 const secs = ((performance.now() - t0) / 1000).toFixed(1);
874 const doneLine =
875 `<b>t = ${fmtChoice(spec.tEnd)}</b> — computed locally in ${secs} s` +
876 (warm ? ` (resumed from cached t = ${fmtChoice(warm.tEnd)})` : '') +
877 `.`;
878 status(`${doneLine} Writing the cache file…`);
880 const finalBytes = await encode(spec.tEnd, final);
881 if (gen !== generation) return;
882 const finalLookup = await lookupFor(spec);
883 offerDownload(finalBytes, finalLookup.fileName.split('/').pop()!);
885 // The final solution, plus any snapshots captured before a key was entered.
886 const apiKey = elApiKey.value.trim();
887 if (apiKey) {
888 uploadInBackground(spec.tEnd, final, apiKey, finalBytes);
889 for (const snap of snapshots) uploadInBackground(snap.tEnd, snap.state, apiKey);
890 }
891 if (uploadsStarted === 0) {
892 status(`${doneLine} Not uploaded (no API key).`);
893 return;
894 }
895 status(`${doneLine} Uploading to the cache (${uploadedTimes.length}/${uploadsStarted})…`);
896 await Promise.all(pendingUploads);
897 if (gen !== generation) return;
899 if (uploadErrors.length) elErr.textContent = `upload: ${uploadErrors.join('; ')}`;
900 const n = uploadedTimes.length;
901 if (n > 0) {
902 const times = [...uploadedTimes].sort((a, b) => a - b).map(fmtChoice).join(', ');
903 const failed = uploadErrors.length ? ` (${uploadErrors.length} failed)` : '';
904 status(
905 `${doneLine} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
906 `to the shared cache (t = ${times})${failed}.`,
907 );
908 } else {
909 status(`${doneLine} Uploads failed.`);
910 }
911}
913// ---------------------------------------------------------------- boot
914elSolve.addEventListener('click', () => {
915 flowChain = flowChain.then(() => solve()).catch(() => undefined);
916});
917elStop.addEventListener('click', () => {
918 stopRequested = true;
919 setBusy(false);
920});
921elReset.addEventListener('click', () => resetDefaults());
922elResetView.addEventListener('click', () => {
923 for (const s of scenes) s.resetCamera();
924});
925elApiKey.addEventListener('change', () => {
926 const key = elApiKey.value.trim();
927 if (key) localStorage.setItem(API_KEY_STORAGE, key);
928 else localStorage.removeItem(API_KEY_STORAGE);
929 updateUploadNote();
930});
932function updateUploadNote(): void {
933 elUploadNote.textContent = elApiKey.value.trim()
934 ? 'uploads enabled — locally computed solutions will be contributed'
935 : '';
936}
938async function boot(): Promise<void> {
939 buildControls();
940 // Written even before any change, so the address bar is always shareable.
941 writeUrlState();
942 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
943 updateUploadNote();
944 void updateCacheNote();
945 try {
946 device = await requestShtDevice();
947 adapterName = await describeAdapter(device);
948 } catch (e) {
949 device = null;
950 elErr.textContent =
951 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
952 `Use a WebGPU-capable browser such as Chrome or Edge.`;
953 return;
954 }
955 device.lost.then((info) => {
956 if (info.reason !== 'destroyed') {
957 elErr.textContent = `WebGPU device lost: ${info.message}`;
958 }
959 });
961 status('compiling the solver…');
962 try {
963 session = await ModelSession.create({
964 device,
965 model,
966 params,
967 lmax: LMAX,
968 oversample: OVERSAMPLE,
969 geometry,
970 geometryParams: geomParams,
971 niter: NITER,
972 lam3: LAM3,
973 });
974 } catch (e) {
975 elErr.textContent = formatFailure(e, model.source);
976 status('failed to compile.');
977 return;
978 }
979 sessionGeomKey = geometry.key;
980 sessionGeomParams = { ...geomParams };
982 // Never put more dispatches in one submission than the budget allows,
983 // however expensive niter has made one step.
984 const opsPerStep = Math.max(1, session.describe().step.length);
985 stepsPerSubmit = Math.max(1, Math.floor(DISPATCH_BUDGET / opsPerStep));
987 const surface = await session.renderPositions();
988 buildView(surface);
989 clearDisplay();
990 updateStats();
991 // Bring up the default selection if it is cached; otherwise show empty
992 // surfaces. Nothing is ever computed without pressing the button.
993 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
994 await flowChain;
995}
997void boot();