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, mModelByKey, 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 DEFAULT_MODEL_KEY,
43 GEOMETRY_CHOICES,
44 SEED_CHOICE,
45 T_END_CHOICE,
46 LMAX,
47 NITER,
48 LAM3,
49 AUTO_SEED,
50 AUTO_DT,
51 defaultChoiceParams,
52 fmtChoice,
53 type DiscreteChoice,
54} from './cache/options.ts';
55import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
56import { lookupFor, fetchCached, uploadCacheFile, type CacheLookup } from './cache/client.ts';
57import { autoOrder, type AutoTarget } from './cache/autoWalk.ts';
58import { encodeCacheFile, decodeCacheFile, type DecodedCacheFile } from './cache/h5file.ts';
60const $ = <T extends HTMLElement>(id: string): T =>
61 document.getElementById(id) as T;
63const elModel = $<HTMLSelectElement>('model');
64const elParams = $('params');
65const elGeometry = $<HTMLSelectElement>('geometry');
66const elGeomParams = $('geomparams');
67const elSeed = $<HTMLSelectElement>('seed');
68const elTend = $<HTMLSelectElement>('tend');
69const elSolve = $<HTMLButtonElement>('solve');
70const elStop = $<HTMLButtonElement>('stop');
71const elReset = $<HTMLButtonElement>('reset');
72const elCacheNote = $('cachenote');
73const elStatus = $('status');
74const elPanels = $('panels');
75const elResetView = $<HTMLButtonElement>('resetview');
76const elDownload = $<HTMLAnchorElement>('download');
77const elStats = $('stats');
78const elApiKey = $<HTMLInputElement>('apikey');
79const elUploadNote = $('uploadnote');
80const elAutoBar = $('autobar');
81const elAuto = $<HTMLButtonElement>('auto');
82const elAutoNote = $('autonote');
83const elErr = $('err');
85/**
86 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
87 * values (still cached under their own honest specs — a test end time hashes
88 * to its own object). The headless checks use this to keep their computed
89 * runs short; it is not part of the normal UI.
90 */
91{
92 const param = new URLSearchParams(location.search).get('tend');
93 if (param) {
94 const values = param
95 .split(',')
96 .map(Number)
97 .filter((v) => Number.isFinite(v) && v > 0);
98 if (values.length) {
99 T_END_CHOICE.values = values;
100 T_END_CHOICE.value = values[0];
101 }
102 }
103}
105const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
106const COLORMAP = colormaps.viridis;
107/** Render on a 2x finer grid than the solver's; exact interpolation. */
108const OVERSAMPLE = 2;
109/** Cap on GPU dispatches per submission (watchdog safety; see turing-surface). */
110const DISPATCH_BUDGET = 1000;
111/** Steps between syncs during a computation: many small submissions queued
112 * back to back, one wait. The readbacks and renders that pace the live view
113 * happen per chunk, not per submission — that is what lets the run advance
114 * at close to the solver's own rate. */
115const CHUNK_STEPS = 32;
116/** How often the live view renders during a computation. */
117const RENDER_EVERY_MS = 250;
119// ---------------------------------------------------------------- state
120let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
121let device: GPUDevice | null = null;
122let session: ModelSession | null = null;
123let adapterName = '';
124/** Steps per GPU submission, sized in boot() so one submission stays under
125 * the dispatch budget however expensive niter has made a step. */
126let stepsPerSubmit = 4;
128/** The discrete selections, always exactly values from options.ts. */
129let params: Params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
130let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
131let geomParams: Params = Object.fromEntries(
132 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
133);
134let seed = SEED_CHOICE.value;
135let tEnd = T_END_CHOICE.value;
137// The URL fragment carries the whole selection, so a reload comes back to it
138// and a shared link opens on the same spec (and, through refresh(), the same
139// cached solution). Read once at startup; rewritten on every change.
140readUrlState();
142/** What the session currently has applied. Params are cheap (uniforms); a
143 * geometry change re-evaluates the surface and rebuilds the mesh; a model
144 * change recompiles the whole session, since the model is compiled into the
145 * GPU step. */
146let sessionModelKey = '';
147let sessionGeomKey = '';
148let sessionGeomParams: Params = {};
150let topo: SphereMeshTopology | null = null;
151let scenes: SphereScene[] = [];
152let colorbars: Colorbar[] = [];
153/** The colorbar containers, hidden while the windows are empty. */
154let colorbarEls: HTMLElement[] = [];
155let valueBufs: Float32Array[] = [];
156let colorBufs: Float32Array[] = [];
157let ranges: { lo: number; hi: number }[] = [];
158let resizeObs: ResizeObserver | null = null;
159let coords: Float32Array | null = null;
160let posBuf: Float32Array | null = null;
162let generation = 0;
163let busy = false;
164/** True while computeLocally is stepping/reading back. Every read shares one
165 * staging buffer (GpuModel#readback), so a new solve must drain the old
166 * loop before issuing reads of its own. */
167let pumping = false;
168let stopRequested = false;
169/** Set while the auto-fill walk owns the page (see autoRun). */
170let autoRunning = false;
171let autoComputed = 0;
172let autoSkipped = 0;
173let autoFailed = 0;
174/** Simulation time of the state on display (loadState resets session.t). */
175let shownT: number | null = null;
176let downloadUrl: string | null = null;
178const nextFrame = () => new Promise<number>(requestAnimationFrame);
180// ---------------------------------------------------------------- spec
181function currentSpec(): CacheSpec {
182 return {
183 app: APP_NAME,
184 formatVersion: FORMAT_VERSION,
185 model: model.key,
186 params: { ...params },
187 geometry: geometry.key,
188 geometryParams: { ...geomParams },
189 lmax: LMAX,
190 niter: NITER,
191 lam3: LAM3,
192 seed,
193 tEnd,
194 };
195}
197// ---------------------------------------------------------------- URL state
198/**
199 * The selection lives in the URL fragment, every value written explicitly
200 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
201 * keeps meaning the same spec even if a default changes later. The fragment
202 * is chosen over the query string to leave `?tend` to the test hook. Values
203 * are only accepted if they are exactly entries of the discrete lists;
204 * anything else keeps the default.
205 */
206function readUrlState(): void {
207 const hash = location.hash.replace(/^#/, '');
208 if (!hash) return;
209 const p = new URLSearchParams(hash);
210 // `name` is the key as it appears in the URL; it defaults to the choice's
211 // own key but is passed explicitly where the two differ (tEnd vs tend).
212 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
213 const raw = p.get(name);
214 if (raw === null) return current;
215 const v = Number(raw);
216 return choice.values.includes(v) ? v : current;
217 };
218 const m = p.get('model');
219 if (m && mModelByKey(m) && MODEL_CHOICES[m]) {
220 model = mModelByKey(m)!;
221 params = defaultChoiceParams(MODEL_CHOICES[m]);
222 }
223 const g = p.get('geometry');
224 if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
225 geometry = mGeometryByKey(g)!;
226 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
227 }
228 for (const c of MODEL_CHOICES[model.key]) params[c.key] = pick(c, params[c.key]);
229 for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
230 seed = pick(SEED_CHOICE, seed);
231 tEnd = pick(T_END_CHOICE, tEnd, 'tend');
232}
234function writeUrlState(): void {
235 const p = new URLSearchParams();
236 p.set('model', model.key);
237 for (const c of MODEL_CHOICES[model.key]) p.set(c.key, fmtChoice(params[c.key]));
238 p.set('geometry', geometry.key);
239 for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
240 p.set('seed', String(seed));
241 p.set('tend', fmtChoice(tEnd));
242 history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
243}
245// ---------------------------------------------------------------- controls
246/** Every select made by makeSelect, so a reset can push new values into the
247 * ones still on the page. */
248const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
250function syncSelects(): void {
251 // Pruned as it goes: auto mode rebuilds the parameter controls once per
252 // target, so entries for replaced selects would otherwise pile up.
253 for (let i = boundSelects.length - 1; i >= 0; i--) {
254 const b = boundSelects[i];
255 if (b.el.isConnected) b.el.value = String(b.get());
256 else boundSelects.splice(i, 1);
257 }
258}
260function makeSelect(
261 choice: DiscreteChoice,
262 get: () => number,
263 set: (v: number) => void,
264): HTMLLabelElement {
265 const label = document.createElement('label');
266 label.textContent = `${choice.label} `;
267 const select = document.createElement('select');
268 for (const v of choice.values) {
269 const opt = document.createElement('option');
270 opt.value = String(v);
271 opt.textContent = fmtChoice(v);
272 select.append(opt);
273 }
274 select.value = String(get());
275 select.addEventListener('change', () => {
276 set(Number(select.value));
277 onSelectionChange();
278 });
279 label.append(select);
280 boundSelects.push({ el: select, get });
281 return label;
282}
284/** Put every selection back to its default, without refreshing the display. */
285function applyDefaults(): void {
286 model = mModelByKey(DEFAULT_MODEL_KEY)!;
287 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
288 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
289 elModel.value = model.key;
290 buildModelParamControls();
291 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
292 seed = SEED_CHOICE.value;
293 tEnd = T_END_CHOICE.value;
294 elGeometry.value = geometry.key;
295 buildGeomParamControls();
296 elSeed.value = String(seed);
297 elTend.value = String(tEnd);
298 syncSelects();
299 writeUrlState();
300}
302/** The Reset button: back to the defaults, and show what is there. */
303function resetDefaults(): void {
304 applyDefaults();
305 onSelectionChange();
306}
308/** Point every control at one walk target (auto mode drives the same
309 * selection the user otherwise would, so the URL and the dropdowns always
310 * say what is being computed). */
311function setSelection(t: AutoTarget): void {
312 const nextModel = mModelByKey(t.model)!;
313 if (nextModel !== model) {
314 model = nextModel;
315 elModel.value = model.key;
316 buildModelParamControls();
317 }
318 params = { ...t.params };
319 const nextGeom = mGeometryByKey(t.geometry)!;
320 if (nextGeom !== geometry) {
321 geometry = nextGeom;
322 elGeometry.value = geometry.key;
323 buildGeomParamControls();
324 }
325 geomParams = { ...t.geometryParams };
326 seed = AUTO_SEED;
327 elSeed.value = String(seed);
328 tEnd = Math.max(...T_END_CHOICE.values);
329 elTend.value = String(tEnd);
330 syncSelects();
331 writeUrlState();
332}
334function buildModelParamControls(): void {
335 elParams.replaceChildren();
336 for (const choice of MODEL_CHOICES[model.key]) {
337 elParams.append(
338 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
339 );
340 }
341}
343function buildControls(): void {
344 for (const m of mModels) {
345 const opt = document.createElement('option');
346 opt.value = m.key;
347 opt.textContent = m.label;
348 elModel.append(opt);
349 }
350 elModel.value = model.key;
351 elModel.addEventListener('change', () => {
352 model = mModelByKey(elModel.value)!;
353 params = defaultChoiceParams(MODEL_CHOICES[model.key]);
354 buildModelParamControls();
355 onSelectionChange();
356 });
357 buildModelParamControls();
358 for (const g of mGeometries) {
359 const opt = document.createElement('option');
360 opt.value = g.key;
361 opt.textContent = g.label.toLowerCase();
362 elGeometry.append(opt);
363 }
364 elGeometry.value = geometry.key;
365 elGeometry.addEventListener('change', () => {
366 geometry = mGeometryByKey(elGeometry.value)!;
367 geomParams = Object.fromEntries(
368 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
369 );
370 buildGeomParamControls();
371 onSelectionChange();
372 });
373 buildGeomParamControls();
375 for (const v of SEED_CHOICE.values) {
376 const opt = document.createElement('option');
377 opt.value = String(v);
378 opt.textContent = String(v);
379 elSeed.append(opt);
380 }
381 elSeed.value = String(seed);
382 elSeed.addEventListener('change', () => {
383 seed = Number(elSeed.value);
384 onSelectionChange();
385 });
387 for (const v of T_END_CHOICE.values) {
388 const opt = document.createElement('option');
389 opt.value = String(v);
390 opt.textContent = String(v);
391 elTend.append(opt);
392 }
393 elTend.value = String(tEnd);
394 elTend.addEventListener('change', () => {
395 tEnd = Number(elTend.value);
396 onSelectionChange();
397 });
398}
400function buildGeomParamControls(): void {
401 elGeomParams.replaceChildren();
402 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
403 elGeomParams.append(
404 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
405 );
406 }
407}
409/**
410 * A selection change refreshes the display: a cached solution loads and
411 * shows immediately, an uncached one shows empty surfaces until the user
412 * explicitly presses Compute solution. While a computation is running the
413 * change touches nothing — the run keeps going and only the is-it-cached
414 * note follows the dropdowns.
415 *
416 * Refreshes and button presses are chained so two flows never talk to the
417 * session at once.
418 */
419let flowChain: Promise<void> = Promise.resolve();
420function onSelectionChange(): void {
421 writeUrlState();
422 // During a computation the refresh is deferred until the run finishes; the
423 // is-it-cached note should follow the dropdowns right away regardless.
424 if (busy) void updateCacheNote();
425 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
426}
428// The note carries a token so a slow HEAD for a superseded selection never
429// overwrites the note for the current one.
430let cacheNoteToken = 0;
431async function updateCacheNote(): Promise<void> {
432 const token = ++cacheNoteToken;
433 elCacheNote.textContent = '';
434 let lookup: CacheLookup;
435 try {
436 lookup = await lookupFor(currentSpec());
437 } catch {
438 return;
439 }
440 let present: boolean | null = null;
441 try {
442 const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
443 present = res.ok ? true : res.status === 404 ? false : null;
444 } catch {
445 present = null;
446 }
447 if (token !== cacheNoteToken) return;
448 setCacheNote(present);
449}
451function setCacheNote(present: boolean | null): void {
452 if (present === true) {
453 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
454 } else if (present === false) {
455 elCacheNote.textContent = 'not cached yet';
456 } else {
457 elCacheNote.textContent = '';
458 }
459}
461// ---------------------------------------------------------------- view
462function disposeView(): void {
463 for (const s of scenes) s.dispose();
464 scenes = [];
465 colorbars = [];
466 colorbarEls = [];
467 topo = null;
468 coords = null;
469 posBuf = null;
470 resizeObs?.disconnect();
471 resizeObs = null;
472 elPanels.replaceChildren();
473}
475function buildView(surface: Float32Array): void {
476 if (!session) return;
477 const view = session.viewSht;
478 const { nphi } = view.cfg;
479 const phi = new Float64Array(nphi);
480 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
481 topo = buildTopology(view.cosTheta, phi);
482 coords = surface;
483 posBuf = new Float32Array(topo.numVertices * 3);
484 fillPositions(posBuf, coords, topo, 1);
486 const sphereBg = getComputedStyle(document.documentElement)
487 .getPropertyValue('--sphere-bg')
488 .trim();
489 for (let k = 0; k < model.species.length; k++) {
490 const panel = document.createElement('div');
491 panel.className = 'panel';
492 const box = document.createElement('div');
493 box.className = 'sphere-box';
494 const tag = document.createElement('div');
495 tag.className = 'species-tag';
496 tag.textContent = model.species[k];
497 box.append(tag);
498 const side = document.createElement('div');
499 panel.append(box, side);
500 elPanels.append(panel);
502 const scene = new SphereScene(
503 box,
504 topo.numVertices,
505 topo.indices,
506 Float32Array.from(posBuf),
507 sphereBg || undefined,
508 );
509 scene.fitCamera();
510 scenes.push(scene);
511 colorbars.push(new Colorbar(side));
512 colorbarEls.push(side);
513 valueBufs[k] = new Float32Array(topo.numVertices);
514 colorBufs[k] = new Float32Array(topo.numVertices * 3);
515 ranges[k] = { lo: NaN, hi: NaN };
516 }
517 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
519 resizeObs = new ResizeObserver(() => {
520 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
521 boxes.forEach((box, i) => {
522 scenes[i]?.resize(box.clientWidth, box.clientHeight);
523 });
524 });
525 elPanels
526 .querySelectorAll<HTMLElement>('.sphere-box')
527 .forEach((box) => resizeObs!.observe(box));
528}
530async function draw(): Promise<void> {
531 if (!session || !topo) return;
532 const gen = generation;
533 for (let k = 0; k < model.species.length; k++) {
534 let field: Float32Array;
535 try {
536 field = await session.readSpecies(k);
537 } catch (e) {
538 if (gen !== generation) return;
539 throw e;
540 }
541 if (gen !== generation || !topo) return;
542 fillFieldValues(valueBufs[k], field, topo);
543 let lo = Infinity;
544 let hi = -Infinity;
545 for (const v of valueBufs[k]) {
546 if (v < lo) lo = v;
547 if (v > hi) hi = v;
548 }
549 // Smooth the color range in both directions so the shading evolves gently
550 // as the pattern grows (out-of-range values clamp meanwhile).
551 const r = ranges[k];
552 if (!Number.isFinite(r.lo)) {
553 r.lo = lo;
554 r.hi = hi;
555 } else {
556 const a = 0.15;
557 r.lo += a * (lo - r.lo);
558 r.hi += a * (hi - r.hi);
559 }
560 const shown = floorRange(r.lo, r.hi);
561 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
562 scenes[k]?.updateColors(colorBufs[k]);
563 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
564 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
565 }
566}
568/** Empty windows: the selected surface with no field on it. Shown when the
569 * selection has no cached solution and nothing has been computed yet. */
570function clearDisplay(): void {
571 shownT = null;
572 elDownload.hidden = true;
573 if (!topo) return;
574 for (let k = 0; k < model.species.length; k++) {
575 // NaN renders as neutral gray in fillColors — the shape without a field.
576 valueBufs[k].fill(NaN);
577 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
578 scenes[k]?.updateColors(colorBufs[k]);
579 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
580 }
581 updateStats();
582}
584function resetRanges(): void {
585 for (const r of ranges) {
586 r.lo = NaN;
587 r.hi = NaN;
588 }
589}
591function updateStats(): void {
592 if (!session) return;
593 const { nlat, nphi } = session.cfg;
594 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
595 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
596 elStats.innerHTML =
597 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
598 `solve iters ${NITER}${t}`;
599}
601// ---------------------------------------------------------------- statuses
602function status(html: string): void {
603 elStatus.innerHTML = html;
604}
606function setBusy(next: boolean): void {
607 busy = next;
608 elSolve.disabled = next;
609 elStop.hidden = !next;
610}
612function offerDownload(bytes: Uint8Array, name: string): void {
613 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
614 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
615 elDownload.href = downloadUrl;
616 elDownload.download = name;
617 elDownload.hidden = false;
618}
620// ---------------------------------------------------------------- solving
621/** Rebuild the mesh and panels from the session's current surface, keeping
622 * the camera. Fresh buffers render black until the first fill, so the bare
623 * surface is shown; the caller's draw or clearDisplay follows right behind. */
624async function rebuildViewFromSession(): Promise<void> {
625 if (!session) return;
626 const surface = await session.renderPositions();
627 const cam = scenes[0]?.cameraState();
628 disposeView();
629 buildView(surface);
630 if (cam) for (const s of scenes) s.setCameraState(cam);
631 clearDisplay();
632}
634/**
635 * Compile a full session for the spec's model. The model is the one
636 * selection that cannot be swapped into a running session — its step is
637 * compiled into the GPU pipelines — so changing it pays a recompile
638 * (a second or two on a real GPU). The panel count follows the model's
639 * species (Allen–Cahn has one), so the view is rebuilt too.
640 */
641async function rebuildSession(spec: CacheSpec): Promise<void> {
642 if (!device) throw new Error('no GPU device');
643 const nextModel = mModelByKey(spec.model)!;
644 const geomModel = mGeometryByKey(spec.geometry)!;
645 session?.destroy();
646 session = null;
647 sessionModelKey = '';
648 status(`compiling ${nextModel.label}…`);
649 session = await ModelSession.create({
650 device,
651 model: nextModel,
652 params: spec.params,
653 lmax: spec.lmax,
654 oversample: OVERSAMPLE,
655 geometry: geomModel,
656 geometryParams: spec.geometryParams,
657 niter: spec.niter,
658 lam3: spec.lam3,
659 });
660 model = nextModel;
661 sessionModelKey = spec.model;
662 sessionGeomKey = spec.geometry;
663 sessionGeomParams = { ...spec.geometryParams };
664 // Never put more dispatches in one submission than the budget allows,
665 // however expensive this model's step is.
666 const opsPerStep = Math.max(1, session.describe().step.length);
667 stepsPerSubmit = Math.max(1, Math.floor(DISPATCH_BUDGET / opsPerStep));
668 await rebuildViewFromSession();
669 updateStats();
670}
672/** Apply the current selection to the session: params are a uniform upload;
673 * a geometry change re-evaluates the surface and rebuilds the mesh; a model
674 * change recompiles the session entirely. */
675async function applySelection(spec: CacheSpec): Promise<void> {
676 if (!session || spec.model !== sessionModelKey) {
677 await rebuildSession(spec);
678 return;
679 }
680 session.setParams(spec.params);
681 const geomChanged =
682 spec.geometry !== sessionGeomKey ||
683 JSON.stringify(spec.geometryParams) !== JSON.stringify(sessionGeomParams);
684 if (!geomChanged) return;
685 const geomModel = mGeometryByKey(spec.geometry)!;
686 await session.setGeometry(geomModel, spec.geometryParams);
687 sessionGeomKey = spec.geometry;
688 sessionGeomParams = { ...spec.geometryParams };
689 await rebuildViewFromSession();
690}
692/** Decode a fetched cache file and put it on screen. */
693async function displayCached(
694 bytes: Uint8Array,
695 lookup: CacheLookup,
696 spec: CacheSpec,
697 gen: number,
698): Promise<void> {
699 if (!session) return;
700 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
701 if (gen !== generation) return;
702 session.loadState(decoded.final);
703 shownT = spec.tEnd;
704 resetRanges();
705 await draw();
706 updateStats();
707 const kb = (bytes.length / 1024).toFixed(0);
708 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
709 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
710 status(
711 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
712 `(${kb} KB${from}${when}).`,
713 );
714 offerDownload(bytes, lookup.fileName.split('/').pop()!);
715}
717/**
718 * Bring the display in line with the current selection, without ever
719 * starting a computation: a cached solution loads and shows, an uncached one
720 * shows empty surfaces and waits for the Compute solution button. Runs on
721 * startup and on every selection change; a no-op while a computation is
722 * running (the run is not disturbed — only the cache note follows).
723 */
724async function refresh(): Promise<void> {
725 // Before the GPU is up there is nothing to refresh; while a computation
726 // runs the note follows the dropdowns and the refresh waits its turn. A
727 // missing session is NOT a reason to bail: applySelection rebuilds it,
728 // which is also what recovers from a failed compile.
729 if (!device || busy) {
730 void updateCacheNote();
731 return;
732 }
733 generation++;
734 const gen = generation;
735 elErr.textContent = '';
736 const spec = currentSpec();
737 try {
738 const lookup = await lookupFor(spec);
739 status('checking the cloud cache…');
740 let bytes: Uint8Array | null = null;
741 let unreachable = false;
742 try {
743 bytes = await fetchCached(lookup);
744 } catch {
745 unreachable = true;
746 }
747 if (gen !== generation) return;
748 await applySelection(spec);
749 if (gen !== generation) return;
750 if (bytes) {
751 await displayCached(bytes, lookup, spec, gen);
752 setCacheNote(true);
753 return;
754 }
755 clearDisplay();
756 setCacheNote(unreachable ? null : false);
757 status(
758 unreachable
759 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
760 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
761 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
762 `cached shorter run of the same settings is picked up where it left off).`,
763 );
764 } catch (e) {
765 if (gen === generation) {
766 elErr.textContent = formatFailure(e, model.source);
767 status('failed.');
768 }
769 }
770}
772/** The Compute solution button: cache lookup, then either load or compute. */
773async function solve(): Promise<void> {
774 if (!device || busy) return;
775 generation++;
776 const gen = generation;
777 setBusy(true);
778 // A stopped run may still be inside an await; let it see the generation
779 // bump and finish before touching the session.
780 while (pumping) await nextFrame();
781 if (gen !== generation) return;
782 stopRequested = false;
783 elErr.textContent = '';
784 elDownload.hidden = true;
785 const spec = currentSpec();
786 try {
787 const lookup = await lookupFor(spec);
788 status('checking the cloud cache…');
789 let bytes: Uint8Array | null = null;
790 try {
791 bytes = await fetchCached(lookup);
792 } catch (e) {
793 // An unreachable cache degrades to computing locally, and says so.
794 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
795 }
796 if (gen !== generation) return;
797 await applySelection(spec);
798 if (gen !== generation) return;
800 if (bytes) {
801 await displayCached(bytes, lookup, spec, gen);
802 return;
803 }
804 await computeLocally(spec, gen);
805 } catch (e) {
806 if (gen === generation) {
807 elErr.textContent = formatFailure(e, model.source);
808 status('failed.');
809 }
810 } finally {
811 if (gen === generation) setBusy(false);
812 void updateCacheNote();
813 }
814}
816/**
817 * Nothing that is not a number gets uploaded. A combination whose timestep is
818 * too large for its reaction blows up rather than failing, and an unattended
819 * walk would happily publish the wreckage under a hash someone later trusts.
820 */
821const stateIsFinite = (state: Record<string, Float32Array>): boolean =>
822 Object.values(state).every((a) => a.every(Number.isFinite));
824/** Set by reportDiverged, read by the auto walk so a blown-up combination is
825 * counted as a failure rather than a contribution. */
826let lastRunDiverged = false;
828function reportDiverged(t: number): void {
829 lastRunDiverged = true;
830 elErr.textContent =
831 `the solution went non-finite at t = ${t.toFixed(2)} — nothing uploaded ` +
832 `(this combination is unstable at dt = ${fmtChoice(AUTO_DT)})`;
833 status('diverged.');
834}
836/** Run the solver to the spec's end time, watching the pattern form, and
837 * capture the state at every smaller listed end time on the way. */
838async function computeLocally(spec: CacheSpec, gen: number): Promise<void> {
839 if (!session) return;
840 pumping = true;
841 try {
842 await computeLocallyInner(spec, gen);
843 } finally {
844 pumping = false;
845 }
846}
848async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void> {
849 if (!session) return;
850 lastRunDiverged = false;
851 const steps = stepsFor(spec);
852 const dt = spec.params.dt;
854 // Warm start: the state is Markovian in (U, V), so a cached run of the
855 // same spec at a smaller listed end time is an exact prefix of this one.
856 // Take the longest one there is and continue from its final state rather
857 // than recomputing it.
858 let warm: { tEnd: number; decoded: DecodedCacheFile } | null = null;
859 const earlier = T_END_CHOICE.values.filter((T) => T < spec.tEnd).sort((a, b) => b - a);
860 if (earlier.length) status('not in the cache — looking for a shorter cached run…');
861 for (const T of earlier) {
862 const lookup = await lookupFor({ ...spec, tEnd: T });
863 let bytes: Uint8Array | null = null;
864 try {
865 bytes = await fetchCached(lookup);
866 } catch {
867 break; // cache unreachable: no point probing further down the ladder
868 }
869 if (gen !== generation) return;
870 if (!bytes) continue;
871 try {
872 warm = { tEnd: T, decoded: await decodeCacheFile(bytes, lookup.specJson, model.state) };
873 break;
874 } catch {
875 continue; // an unreadable candidate is skipped, not fatal
876 }
877 }
878 if (gen !== generation) return;
880 let initial: Record<string, Float32Array>;
881 if (warm) {
882 session.loadState(warm.decoded.final);
883 // loadState resets the clock; put it at the cached run's end so the loop
884 // below computes only the remainder.
885 session.steps = Math.round(warm.tEnd / dt);
886 session.t = warm.tEnd;
887 // The t = 0 state travels with every file of the chain, so files written
888 // from this continuation carry the same initial state as the one resumed.
889 initial = warm.decoded.initial;
890 } else {
891 status(`not in the cache — <b>computing locally</b>: seeding…`);
892 await session.seed(spec.seed);
893 if (gen !== generation) return;
894 initial = await session.readState();
895 if (gen !== generation) return;
896 }
897 const startSteps = session.steps;
899 // Snapshot points: every listed end time strictly between the starting
900 // point and this run's end. The run passes through each exactly (all are
901 // whole multiples of every dt choice).
902 const snapshotAt = new Map<number, number>(); // step index -> tEnd value
903 for (const T of T_END_CHOICE.values) {
904 if (T < spec.tEnd && T > (warm?.tEnd ?? 0)) snapshotAt.set(Math.round(T / dt), T);
905 }
906 const snapshots: { tEnd: number; state: Record<string, Float32Array> }[] = [];
908 // Everything a cache file needs exists before the run starts, so a snapshot
909 // is encoded and uploaded the moment it is captured, overlapping the
910 // network with the GPU still stepping, rather than queued for the end.
911 const geometryCoeffs = {
912 X: session.geometry.X,
913 Y: session.geometry.Y,
914 Z: session.geometry.Z,
915 };
916 const encode = (t: number, state: Record<string, Float32Array>) =>
917 encodeCacheFile({
918 spec: { ...spec, tEnd: t },
919 grid: session!.cfg,
920 species: model.state,
921 geometry: geometryCoeffs,
922 initial,
923 final: state,
924 adapter: adapterName,
925 });
926 const uploadedTimes: number[] = [];
927 const uploadErrors: string[] = [];
928 let uploadsStarted = 0;
929 const pendingUploads: Promise<void>[] = [];
930 /** Encode + upload without the stepping loop waiting. A captured snapshot
931 * is a complete solution of its own spec, so this stays valid even if the
932 * run is stopped afterwards. */
933 const uploadInBackground = (
934 t: number,
935 state: Record<string, Float32Array>,
936 apiKey: string,
937 preEncoded?: Uint8Array,
938 ): void => {
939 uploadsStarted++;
940 pendingUploads.push(
941 (async () => {
942 const bytes = preEncoded ?? (await encode(t, state));
943 const lookup = await lookupFor({ ...spec, tEnd: t });
944 await uploadCacheFile(apiKey, lookup.fileName, bytes);
945 uploadedTimes.push(t);
946 })().catch((e) => {
947 uploadErrors.push(`t = ${fmtChoice(t)}: ${e instanceof Error ? e.message : e}`);
948 }),
949 );
950 };
952 shownT = null;
953 resetRanges();
954 const t0 = performance.now();
955 let lastStatus = 0;
956 let lastDraw = 0;
957 while (session.steps < steps) {
958 if (gen !== generation) return;
959 if (stopRequested) {
960 shownT = session.steps * dt;
961 await draw();
962 updateStats();
963 const up = uploadedTimes.length
964 ? ` ${uploadedTimes.length} snapshot${uploadedTimes.length > 1 ? 's' : ''} already uploaded.`
965 : ' Nothing uploaded.';
966 status(`stopped at t = ${(session.steps * dt).toFixed(2)}.${up}`);
967 return;
968 }
969 // One chunk: up to CHUNK_STEPS steps submitted back to back (each
970 // submission stays under the dispatch budget), then a single sync and at
971 // most one render. Reading back and drawing after every submission is
972 // what made the run advance at a fraction of the solver's rate — a
973 // readback costs several times the 3-4 steps it fenced. The chunk stops
974 // exactly at snapshot points so those states are still captured exactly.
975 let target = Math.min(steps, session.steps + CHUNK_STEPS);
976 for (const s of snapshotAt.keys()) {
977 if (s > session.steps && s < target) target = s;
978 }
979 while (session.steps < target) {
980 session.step(Math.min(stepsPerSubmit, target - session.steps));
981 }
982 // The sync bounds how far the CPU runs ahead of the GPU, and (being a
983 // promise) yields to the event loop, which is what keeps Stop clickable.
984 await session.sync();
985 if (gen !== generation) return;
986 const hit = snapshotAt.get(session.steps);
987 if (hit !== undefined) {
988 const state = await session.readState();
989 if (gen !== generation) return;
990 if (!stateIsFinite(state)) return void reportDiverged(session.steps * dt);
991 // With a key on hand the snapshot goes straight to the cache; without
992 // one it is kept, in case a key is entered before the run ends.
993 const apiKey = elApiKey.value.trim();
994 if (apiKey) uploadInBackground(hit, state, apiKey);
995 else snapshots.push({ tEnd: hit, state });
996 }
997 const now = performance.now();
998 // Rendering is skipped entirely while the page is hidden, and the loop
999 // never waits on an animation frame there: a backgrounded tab throttles
1000 // or stops requestAnimationFrame, which would stall an unattended run.
1001 // Awaiting the GPU sync above already yields to the event loop, so Stop
1002 // stays responsive either way.
1003 if (!document.hidden && (now - lastDraw > RENDER_EVERY_MS || session.steps >= steps)) {
1004 lastDraw = now;
1005 await draw();
1006 if (gen !== generation) return;
1007 await nextFrame();
1008 }
1009 if (now - lastStatus > 200) {
1010 lastStatus = now;
1011 const t = session.steps * dt;
1012 const pct = ((100 * (session.steps - startSteps)) / (steps - startSteps)).toFixed(0);
1013 const rate = (session.steps - startSteps) / ((now - t0) / 1000);
1014 const from = warm ? `resumed from cached t = ${fmtChoice(warm.tEnd)} — ` : '';
1015 const up = uploadsStarted
1016 ? `, uploaded ${uploadedTimes.length}/${uploadsStarted} snapshots`
1017 : '';
1018 status(
1019 `not in the cache — <b>computing locally</b> (${from}` +
1020 `t = ${t.toFixed(2)} / ${fmtChoice(spec.tEnd)}, ${pct}%, ${rate.toFixed(0)} steps/s${up})`,
1021 );
1022 }
1023 }
1025 const final = await session.readState();
1026 if (gen !== generation) return;
1027 if (!stateIsFinite(final)) return void reportDiverged(spec.tEnd);
1028 shownT = spec.tEnd;
1029 await draw();
1030 updateStats();
1031 const secs = ((performance.now() - t0) / 1000).toFixed(1);
1032 const doneLine =
1033 `<b>t = ${fmtChoice(spec.tEnd)}</b> — computed locally in ${secs} s` +
1034 (warm ? ` (resumed from cached t = ${fmtChoice(warm.tEnd)})` : '') +
1035 `.`;
1036 status(`${doneLine} Writing the cache file…`);
1038 const finalBytes = await encode(spec.tEnd, final);
1039 if (gen !== generation) return;
1040 const finalLookup = await lookupFor(spec);
1041 offerDownload(finalBytes, finalLookup.fileName.split('/').pop()!);
1043 // The final solution, plus any snapshots captured before a key was entered.
1044 const apiKey = elApiKey.value.trim();
1045 if (apiKey) {
1046 uploadInBackground(spec.tEnd, final, apiKey, finalBytes);
1047 for (const snap of snapshots) uploadInBackground(snap.tEnd, snap.state, apiKey);
1048 }
1049 if (uploadsStarted === 0) {
1050 status(`${doneLine} Not uploaded (no API key).`);
1051 return;
1052 }
1053 status(`${doneLine} Uploading to the cache (${uploadedTimes.length}/${uploadsStarted})…`);
1054 await Promise.all(pendingUploads);
1055 if (gen !== generation) return;
1057 if (uploadErrors.length) elErr.textContent = `upload: ${uploadErrors.join('; ')}`;
1058 const n = uploadedTimes.length;
1059 if (n > 0) {
1060 const times = [...uploadedTimes].sort((a, b) => a - b).map(fmtChoice).join(', ');
1061 const failed = uploadErrors.length ? ` (${uploadErrors.length} failed)` : '';
1062 status(
1063 `${doneLine} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
1064 `to the shared cache (t = ${times})${failed}.`,
1065 );
1066 } else {
1067 status(`${doneLine} Uploads failed.`);
1068 }
1069}
1071// ---------------------------------------------------------------- auto-fill
1072/** Is this solution already in the cloud? A HEAD is enough, and only the
1073 * longest end time need be asked about: a run reaching it emits every
1074 * shorter one on the way, so its presence stands for the whole chain. */
1075async function isCached(lookup: CacheLookup): Promise<boolean> {
1076 try {
1077 const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
1078 return res.ok;
1079 } catch {
1080 // A network hiccup is not evidence of absence, but computing anyway only
1081 // costs time and ends in an upload that overwrites an identical object.
1082 return false;
1083 }
1084}
1086function autoNote(target: AutoTarget | null): void {
1087 if (!autoRunning) {
1088 elAutoNote.textContent = autoComputed || autoSkipped
1089 ? `stopped — computed ${autoComputed}, skipped ${autoSkipped} already cached` +
1090 (autoFailed ? `, ${autoFailed} failed` : '')
1091 : '';
1092 return;
1093 }
1094 const where = target
1095 ? `${mModelByKey(target.model)!.label} on ${target.geometry}, ${target.distance} ` +
1096 `knob${target.distance === 1 ? '' : 's'} from the defaults`
1097 : '';
1098 elAutoNote.textContent =
1099 `auto-filling — computed ${autoComputed}, skipped ${autoSkipped}` +
1100 (autoFailed ? `, ${autoFailed} failed` : '') + (where ? ` · ${where}` : '');
1101}
1103function setAutoUi(on: boolean): void {
1104 elAuto.textContent = on ? 'Auto-filling…' : 'Auto-fill the cache';
1105 elAuto.disabled = on;
1106 elReset.disabled = on;
1107}
1109/**
1110 * Walk the parameter space on this machine, computing and contributing
1111 * whatever is not cached yet, nearest the defaults first and randomly within
1112 * a distance (src/cache/autoWalk.ts). Runs until stopped.
1113 *
1114 * Every target is driven through the same selection the user would set by
1115 * hand, so the dropdowns and the URL always say what is being computed, and
1116 * the run itself is the ordinary local computation — including its
1117 * background uploads, its warm start from a shorter cached run, and its
1118 * divergence guard.
1119 */
1120async function autoRun(): Promise<void> {
1121 if (!device || busy || autoRunning) return;
1122 if (!elApiKey.value.trim()) return;
1123 autoRunning = true;
1124 autoComputed = autoSkipped = autoFailed = 0;
1125 setAutoUi(true);
1126 setBusy(true);
1127 elErr.textContent = '';
1128 // Start from a defined point — which is also the first target, since the
1129 // defaults are the one combination at distance zero.
1130 applyDefaults();
1131 const targets = autoOrder();
1132 autoNote(null);
1134 for (const target of targets) {
1135 if (!autoRunning) break;
1136 setSelection(target);
1137 autoNote(target);
1138 generation++;
1139 const gen = generation;
1140 stopRequested = false;
1141 const spec = currentSpec();
1142 try {
1143 const lookup = await lookupFor(spec);
1144 status(`checking the cloud cache…`);
1145 if (await isCached(lookup)) {
1146 autoSkipped++;
1147 setCacheNote(true);
1148 continue;
1149 }
1150 if (!autoRunning) break;
1151 setCacheNote(false);
1152 await applySelection(spec);
1153 if (gen !== generation) break;
1154 await computeLocally(spec, gen);
1155 if (gen !== generation) break;
1156 if (lastRunDiverged) autoFailed++;
1157 else if (!stopRequested) autoComputed++;
1158 } catch (e) {
1159 // One bad combination must not end the walk: report it and move on.
1160 autoFailed++;
1161 elErr.textContent = `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
1162 }
1163 }
1165 autoRunning = false;
1166 setAutoUi(false);
1167 setBusy(false);
1168 autoNote(null);
1169}
1171// ---------------------------------------------------------------- boot
1172elAuto.addEventListener('click', () => {
1173 flowChain = flowChain.then(() => autoRun()).catch(() => undefined);
1174});
1175elSolve.addEventListener('click', () => {
1176 flowChain = flowChain.then(() => solve()).catch(() => undefined);
1177});
1178elStop.addEventListener('click', () => {
1179 stopRequested = true;
1180 autoRunning = false;
1181 setBusy(false);
1182});
1183elReset.addEventListener('click', () => resetDefaults());
1184elResetView.addEventListener('click', () => {
1185 for (const s of scenes) s.resetCamera();
1186});
1187// The view is not drawn while the page is hidden, so it is stale on return.
1188// Not while a run is reading back: every read shares one staging buffer.
1189document.addEventListener('visibilitychange', () => {
1190 if (!document.hidden && session && !pumping) void draw();
1191});
1192elApiKey.addEventListener('change', () => {
1193 const key = elApiKey.value.trim();
1194 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1195 else localStorage.removeItem(API_KEY_STORAGE);
1196 updateUploadNote();
1197});
1199function updateUploadNote(): void {
1200 const hasKey = elApiKey.value.trim().length > 0;
1201 elUploadNote.textContent = hasKey
1202 ? 'uploads enabled — locally computed solutions will be contributed'
1203 : '';
1204 // Auto-fill exists to contribute, so it is offered only to those who can.
1205 elAutoBar.hidden = !hasKey;
1206 if (!hasKey && autoRunning) autoRunning = false;
1207}
1209async function boot(): Promise<void> {
1210 buildControls();
1211 // Written even before any change, so the address bar is always shareable.
1212 writeUrlState();
1213 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1214 updateUploadNote();
1215 void updateCacheNote();
1216 try {
1217 device = await requestShtDevice();
1218 adapterName = await describeAdapter(device);
1219 } catch (e) {
1220 device = null;
1221 elErr.textContent =
1222 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1223 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1224 return;
1225 }
1226 device.lost.then((info) => {
1227 if (info.reason !== 'destroyed') {
1228 elErr.textContent = `WebGPU device lost: ${info.message}`;
1229 }
1230 });
1232 try {
1233 await rebuildSession(currentSpec());
1234 } catch (e) {
1235 elErr.textContent = formatFailure(e, model.source);
1236 status('failed to compile.');
1237 return;
1238 }
1239 // Bring up the default selection if it is cached; otherwise show empty
1240 // surfaces. Nothing is ever computed without pressing the button.
1241 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
1242 await flowChain;
1243}
1245void boot();