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 type { 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_DT,
50 defaultChoiceParams,
51 fmtChoice,
52 type DiscreteChoice,
53} from './cache/options.ts';
54import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
55import { lookupFor, fetchCached, headCached, type CacheLookup } from './cache/client.ts';
56import { autoOrder, specForTarget, type AutoTarget } from './cache/autoWalk.ts';
57import { decodeCacheFile } from './cache/h5file.ts';
58import { SolverSession } from './cache/solver.ts';
59import { runSpec, type RunEvents, type RunOutcome, type RunSummary } from './cache/runSpec.ts';
60import { fillWalk } from './cache/fillWalk.ts';
62const $ = <T extends HTMLElement>(id: string): T =>
63 document.getElementById(id) as T;
65const elModel = $<HTMLSelectElement>('model');
66const elParams = $('params');
67const elGeometry = $<HTMLSelectElement>('geometry');
68const elGeomParams = $('geomparams');
69const elSeed = $<HTMLSelectElement>('seed');
70const elTend = $<HTMLSelectElement>('tend');
71const elSolve = $<HTMLButtonElement>('solve');
72const elStop = $<HTMLButtonElement>('stop');
73const elReset = $<HTMLButtonElement>('reset');
74const elCacheNote = $('cachenote');
75const elStatus = $('status');
76const elPanels = $('panels');
77const elResetView = $<HTMLButtonElement>('resetview');
78const elDownload = $<HTMLAnchorElement>('download');
79const elStats = $('stats');
80const elApiKey = $<HTMLInputElement>('apikey');
81const elUploadNote = $('uploadnote');
82const elAutoBar = $('autobar');
83const elAuto = $<HTMLButtonElement>('auto');
84const elAutoNote = $('autonote');
85const elCliBar = $('clibar');
86const elCliCmd = $('clicmd');
87const elCliCopy = $<HTMLButtonElement>('clicopy');
88const elCliCopied = $('clicopied');
89const elCliHelp = $('clihelp');
90const elErr = $('err');
92/**
93 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
94 * values (still cached under their own honest specs — a test end time hashes
95 * to its own object). The headless checks use this to keep their computed
96 * runs short; it is not part of the normal UI.
97 */
98{
99 const param = new URLSearchParams(location.search).get('tend');
100 if (param) {
101 const values = param
102 .split(',')
103 .map(Number)
104 .filter((v) => Number.isFinite(v) && v > 0);
105 if (values.length) {
106 T_END_CHOICE.values = values;
107 T_END_CHOICE.value = values[0];
108 }
109 }
110}
112const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
113const COLORMAP = colormaps.viridis;
114/** Render on a 2x finer grid than the solver's; exact interpolation. */
115const OVERSAMPLE = 2;
116/** How often the live view renders during a computation. */
117const RENDER_EVERY_MS = 250;
118/** How often the status line is rewritten during a computation. */
119const STATUS_EVERY_MS = 200;
121// ---------------------------------------------------------------- state
122let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
123let device: GPUDevice | null = null;
124/** The compiled solver and what it has applied (src/cache/solver.ts). */
125let solver: SolverSession | null = null;
126let adapterName = '';
127/** The live session, or null before the GPU is up. */
128function sess(): ModelSession | null {
129 return solver?.session ?? null;
130}
132/** The discrete selections, always exactly values from options.ts. */
133let params: Params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
134let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
135let geomParams: Params = Object.fromEntries(
136 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
137);
138let seed = SEED_CHOICE.value;
139let tEnd = T_END_CHOICE.value;
141// The URL fragment carries the whole selection, so a reload comes back to it
142// and a shared link opens on the same spec (and, through refresh(), the same
143// cached solution). Read once at startup; rewritten on every change.
144readUrlState();
146let topo: SphereMeshTopology | null = null;
147let scenes: SphereScene[] = [];
148let colorbars: Colorbar[] = [];
149/** The colorbar containers, hidden while the windows are empty. */
150let colorbarEls: HTMLElement[] = [];
151let valueBufs: Float32Array[] = [];
152let colorBufs: Float32Array[] = [];
153let ranges: { lo: number; hi: number }[] = [];
154let resizeObs: ResizeObserver | null = null;
155let coords: Float32Array | null = null;
156let posBuf: Float32Array | null = null;
158let generation = 0;
159let busy = false;
160/** True while computeLocally is stepping/reading back. Every read shares one
161 * staging buffer (GpuModel#readback), so a new solve must drain the old
162 * loop before issuing reads of its own. */
163let pumping = false;
164let stopRequested = false;
165/** Set while the auto-fill walk owns the page (see autoRun). */
166let autoRunning = false;
167let autoComputed = 0;
168let autoSkipped = 0;
169let autoFailed = 0;
170/** Simulation time of the state on display (loadState resets session.t). */
171let shownT: number | null = null;
172let downloadUrl: string | null = null;
174const nextFrame = () => new Promise<number>(requestAnimationFrame);
176// ---------------------------------------------------------------- spec
177function currentSpec(): CacheSpec {
178 return {
179 app: APP_NAME,
180 formatVersion: FORMAT_VERSION,
181 model: model.key,
182 params: { ...params },
183 geometry: geometry.key,
184 geometryParams: { ...geomParams },
185 lmax: LMAX,
186 niter: NITER,
187 lam3: LAM3,
188 seed,
189 tEnd,
190 };
191}
193// ---------------------------------------------------------------- URL state
194/**
195 * The selection lives in the URL fragment, every value written explicitly
196 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
197 * keeps meaning the same spec even if a default changes later. The fragment
198 * is chosen over the query string to leave `?tend` to the test hook. Values
199 * are only accepted if they are exactly entries of the discrete lists;
200 * anything else keeps the default.
201 */
202function readUrlState(): void {
203 const hash = location.hash.replace(/^#/, '');
204 if (!hash) return;
205 const p = new URLSearchParams(hash);
206 // `name` is the key as it appears in the URL; it defaults to the choice's
207 // own key but is passed explicitly where the two differ (tEnd vs tend).
208 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
209 const raw = p.get(name);
210 if (raw === null) return current;
211 const v = Number(raw);
212 return choice.values.includes(v) ? v : current;
213 };
214 const m = p.get('model');
215 if (m && mModelByKey(m) && MODEL_CHOICES[m]) {
216 model = mModelByKey(m)!;
217 params = defaultChoiceParams(MODEL_CHOICES[m]);
218 }
219 const g = p.get('geometry');
220 if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
221 geometry = mGeometryByKey(g)!;
222 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
223 }
224 for (const c of MODEL_CHOICES[model.key]) params[c.key] = pick(c, params[c.key]);
225 for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
226 seed = pick(SEED_CHOICE, seed);
227 tEnd = pick(T_END_CHOICE, tEnd, 'tend');
228}
230function writeUrlState(): void {
231 const p = new URLSearchParams();
232 p.set('model', model.key);
233 for (const c of MODEL_CHOICES[model.key]) p.set(c.key, fmtChoice(params[c.key]));
234 p.set('geometry', geometry.key);
235 for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
236 p.set('seed', String(seed));
237 p.set('tend', fmtChoice(tEnd));
238 history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
239}
241// ---------------------------------------------------------------- controls
242/** Every select made by makeSelect, so a reset can push new values into the
243 * ones still on the page. */
244const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
246function syncSelects(): void {
247 // Pruned as it goes: auto mode rebuilds the parameter controls once per
248 // target, so entries for replaced selects would otherwise pile up.
249 for (let i = boundSelects.length - 1; i >= 0; i--) {
250 const b = boundSelects[i];
251 if (b.el.isConnected) b.el.value = String(b.get());
252 else boundSelects.splice(i, 1);
253 }
254}
256function makeSelect(
257 choice: DiscreteChoice,
258 get: () => number,
259 set: (v: number) => void,
260): HTMLLabelElement {
261 const label = document.createElement('label');
262 label.textContent = `${choice.label} `;
263 const select = document.createElement('select');
264 for (const v of choice.values) {
265 const opt = document.createElement('option');
266 opt.value = String(v);
267 opt.textContent = fmtChoice(v);
268 select.append(opt);
269 }
270 select.value = String(get());
271 select.addEventListener('change', () => {
272 set(Number(select.value));
273 onSelectionChange();
274 });
275 label.append(select);
276 boundSelects.push({ el: select, get });
277 return label;
278}
280/** Put every selection back to its default, without refreshing the display. */
281function applyDefaults(): void {
282 model = mModelByKey(DEFAULT_MODEL_KEY)!;
283 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
284 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
285 elModel.value = model.key;
286 buildModelParamControls();
287 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
288 seed = SEED_CHOICE.value;
289 tEnd = T_END_CHOICE.value;
290 elGeometry.value = geometry.key;
291 buildGeomParamControls();
292 elSeed.value = String(seed);
293 elTend.value = String(tEnd);
294 syncSelects();
295 writeUrlState();
296}
298/** The Reset button: back to the defaults, and show what is there. */
299function resetDefaults(): void {
300 applyDefaults();
301 onSelectionChange();
302}
304/** Point every control at one walk target (auto mode drives the same
305 * selection the user otherwise would, so the URL and the dropdowns always
306 * say what is being computed). The values come from the target's own spec,
307 * so currentSpec() reproduces exactly what the walk asked for. */
308function setSelection(t: AutoTarget): void {
309 const spec = specForTarget(t);
310 const nextModel = mModelByKey(spec.model)!;
311 if (nextModel !== model) {
312 model = nextModel;
313 elModel.value = model.key;
314 buildModelParamControls();
315 }
316 params = { ...spec.params };
317 const nextGeom = mGeometryByKey(spec.geometry)!;
318 if (nextGeom !== geometry) {
319 geometry = nextGeom;
320 elGeometry.value = geometry.key;
321 buildGeomParamControls();
322 }
323 geomParams = { ...spec.geometryParams };
324 seed = spec.seed;
325 elSeed.value = String(seed);
326 tEnd = spec.tEnd;
327 elTend.value = String(tEnd);
328 syncSelects();
329 writeUrlState();
330}
332function buildModelParamControls(): void {
333 elParams.replaceChildren();
334 for (const choice of MODEL_CHOICES[model.key]) {
335 elParams.append(
336 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
337 );
338 }
339}
341function buildControls(): void {
342 for (const m of mModels) {
343 const opt = document.createElement('option');
344 opt.value = m.key;
345 opt.textContent = m.label;
346 elModel.append(opt);
347 }
348 elModel.value = model.key;
349 elModel.addEventListener('change', () => {
350 model = mModelByKey(elModel.value)!;
351 params = defaultChoiceParams(MODEL_CHOICES[model.key]);
352 buildModelParamControls();
353 onSelectionChange();
354 });
355 buildModelParamControls();
356 for (const g of mGeometries) {
357 const opt = document.createElement('option');
358 opt.value = g.key;
359 opt.textContent = g.label.toLowerCase();
360 elGeometry.append(opt);
361 }
362 elGeometry.value = geometry.key;
363 elGeometry.addEventListener('change', () => {
364 geometry = mGeometryByKey(elGeometry.value)!;
365 geomParams = Object.fromEntries(
366 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
367 );
368 buildGeomParamControls();
369 onSelectionChange();
370 });
371 buildGeomParamControls();
373 for (const v of SEED_CHOICE.values) {
374 const opt = document.createElement('option');
375 opt.value = String(v);
376 opt.textContent = String(v);
377 elSeed.append(opt);
378 }
379 elSeed.value = String(seed);
380 elSeed.addEventListener('change', () => {
381 seed = Number(elSeed.value);
382 onSelectionChange();
383 });
385 for (const v of T_END_CHOICE.values) {
386 const opt = document.createElement('option');
387 opt.value = String(v);
388 opt.textContent = String(v);
389 elTend.append(opt);
390 }
391 elTend.value = String(tEnd);
392 elTend.addEventListener('change', () => {
393 tEnd = Number(elTend.value);
394 onSelectionChange();
395 });
396}
398function buildGeomParamControls(): void {
399 elGeomParams.replaceChildren();
400 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
401 elGeomParams.append(
402 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
403 );
404 }
405}
407/**
408 * A selection change refreshes the display: a cached solution loads and
409 * shows immediately, an uncached one shows empty surfaces until the user
410 * explicitly presses Compute solution. While a computation is running the
411 * change touches nothing — the run keeps going and only the is-it-cached
412 * note follows the dropdowns.
413 *
414 * Refreshes and button presses are chained so two flows never talk to the
415 * session at once.
416 */
417let flowChain: Promise<void> = Promise.resolve();
418function onSelectionChange(): void {
419 writeUrlState();
420 // During a computation the refresh is deferred until the run finishes; the
421 // is-it-cached note should follow the dropdowns right away regardless.
422 if (busy) void updateCacheNote();
423 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
424}
426// The note carries a token so a slow HEAD for a superseded selection never
427// overwrites the note for the current one.
428let cacheNoteToken = 0;
429async function updateCacheNote(): Promise<void> {
430 const token = ++cacheNoteToken;
431 elCacheNote.textContent = '';
432 let lookup: CacheLookup;
433 try {
434 lookup = await lookupFor(currentSpec());
435 } catch {
436 return;
437 }
438 const present = await headCached(lookup);
439 if (token !== cacheNoteToken) return;
440 setCacheNote(present);
441}
443function setCacheNote(present: boolean | null): void {
444 if (present === true) {
445 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
446 } else if (present === false) {
447 elCacheNote.textContent = 'not cached yet';
448 } else {
449 elCacheNote.textContent = '';
450 }
451}
453// ---------------------------------------------------------------- view
454function disposeView(): void {
455 for (const s of scenes) s.dispose();
456 scenes = [];
457 colorbars = [];
458 colorbarEls = [];
459 topo = null;
460 coords = null;
461 posBuf = null;
462 resizeObs?.disconnect();
463 resizeObs = null;
464 elPanels.replaceChildren();
465}
467function buildView(surface: Float32Array): void {
468 const session = sess();
469 if (!session) return;
470 const view = session.viewSht;
471 const { nphi } = view.cfg;
472 const phi = new Float64Array(nphi);
473 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
474 topo = buildTopology(view.cosTheta, phi);
475 coords = surface;
476 posBuf = new Float32Array(topo.numVertices * 3);
477 fillPositions(posBuf, coords, topo, 1);
479 const sphereBg = getComputedStyle(document.documentElement)
480 .getPropertyValue('--sphere-bg')
481 .trim();
482 for (let k = 0; k < model.species.length; k++) {
483 const panel = document.createElement('div');
484 panel.className = 'panel';
485 const box = document.createElement('div');
486 box.className = 'sphere-box';
487 const tag = document.createElement('div');
488 tag.className = 'species-tag';
489 tag.textContent = model.species[k];
490 box.append(tag);
491 const side = document.createElement('div');
492 panel.append(box, side);
493 elPanels.append(panel);
495 const scene = new SphereScene(
496 box,
497 topo.numVertices,
498 topo.indices,
499 Float32Array.from(posBuf),
500 sphereBg || undefined,
501 );
502 scene.fitCamera();
503 scenes.push(scene);
504 colorbars.push(new Colorbar(side));
505 colorbarEls.push(side);
506 valueBufs[k] = new Float32Array(topo.numVertices);
507 colorBufs[k] = new Float32Array(topo.numVertices * 3);
508 ranges[k] = { lo: NaN, hi: NaN };
509 }
510 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
512 resizeObs = new ResizeObserver(() => {
513 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
514 boxes.forEach((box, i) => {
515 scenes[i]?.resize(box.clientWidth, box.clientHeight);
516 });
517 });
518 elPanels
519 .querySelectorAll<HTMLElement>('.sphere-box')
520 .forEach((box) => resizeObs!.observe(box));
521}
523async function draw(): Promise<void> {
524 const session = sess();
525 if (!session || !topo) return;
526 const gen = generation;
527 for (let k = 0; k < model.species.length; k++) {
528 let field: Float32Array;
529 try {
530 field = await session.readSpecies(k);
531 } catch (e) {
532 if (gen !== generation) return;
533 throw e;
534 }
535 if (gen !== generation || !topo) return;
536 fillFieldValues(valueBufs[k], field, topo);
537 let lo = Infinity;
538 let hi = -Infinity;
539 for (const v of valueBufs[k]) {
540 if (v < lo) lo = v;
541 if (v > hi) hi = v;
542 }
543 // Smooth the color range in both directions so the shading evolves gently
544 // as the pattern grows (out-of-range values clamp meanwhile).
545 const r = ranges[k];
546 if (!Number.isFinite(r.lo)) {
547 r.lo = lo;
548 r.hi = hi;
549 } else {
550 const a = 0.15;
551 r.lo += a * (lo - r.lo);
552 r.hi += a * (hi - r.hi);
553 }
554 const shown = floorRange(r.lo, r.hi);
555 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
556 scenes[k]?.updateColors(colorBufs[k]);
557 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
558 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
559 }
560}
562/** Empty windows: the selected surface with no field on it. Shown when the
563 * selection has no cached solution and nothing has been computed yet. */
564function clearDisplay(): void {
565 shownT = null;
566 elDownload.hidden = true;
567 if (!topo) return;
568 for (let k = 0; k < model.species.length; k++) {
569 // NaN renders as neutral gray in fillColors — the shape without a field.
570 valueBufs[k].fill(NaN);
571 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
572 scenes[k]?.updateColors(colorBufs[k]);
573 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
574 }
575 updateStats();
576}
578function resetRanges(): void {
579 for (const r of ranges) {
580 r.lo = NaN;
581 r.hi = NaN;
582 }
583}
585function updateStats(): void {
586 const session = sess();
587 if (!session) return;
588 const { nlat, nphi } = session.cfg;
589 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
590 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
591 elStats.innerHTML =
592 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
593 `solve iters ${NITER}${t}`;
594}
596// ---------------------------------------------------------------- statuses
597function status(html: string): void {
598 elStatus.innerHTML = html;
599}
601function setBusy(next: boolean): void {
602 busy = next;
603 elSolve.disabled = next;
604 elStop.hidden = !next;
605}
607function offerDownload(bytes: Uint8Array, name: string): void {
608 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
609 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
610 elDownload.href = downloadUrl;
611 elDownload.download = name;
612 elDownload.hidden = false;
613}
615// ---------------------------------------------------------------- solving
616/** Rebuild the mesh and panels from the session's current surface, keeping
617 * the camera. Fresh buffers render black until the first fill, so the bare
618 * surface is shown; the caller's draw or clearDisplay follows right behind. */
619async function rebuildViewFromSession(): Promise<void> {
620 const session = sess();
621 if (!session) return;
622 const surface = await session.renderPositions();
623 const cam = scenes[0]?.cameraState();
624 disposeView();
625 buildView(surface);
626 if (cam) for (const s of scenes) s.setCameraState(cam);
627 clearDisplay();
628}
630/**
631 * Apply a selection to the solver. Which changes are cheap and which pay a
632 * recompile is the solver's business (src/cache/solver.ts); the page adds the
633 * compiling status and the rebuilt view through the events it installs in
634 * boot(), since the panel count follows the model's species (Allen–Cahn has
635 * one).
636 */
637async function applySelection(spec: CacheSpec): Promise<void> {
638 if (!solver) throw new Error('no GPU device');
639 await solver.apply(spec);
640}
642/** Decode a fetched cache file and put it on screen. */
643async function displayCached(
644 bytes: Uint8Array,
645 lookup: CacheLookup,
646 spec: CacheSpec,
647 gen: number,
648): Promise<void> {
649 const session = sess();
650 if (!session) return;
651 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
652 if (gen !== generation) return;
653 session.loadState(decoded.final);
654 shownT = spec.tEnd;
655 resetRanges();
656 await draw();
657 updateStats();
658 const kb = (bytes.length / 1024).toFixed(0);
659 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
660 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
661 status(
662 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
663 `(${kb} KB${from}${when}).`,
664 );
665 offerDownload(bytes, lookup.fileName.split('/').pop()!);
666}
668/**
669 * Bring the display in line with the current selection, without ever
670 * starting a computation: a cached solution loads and shows, an uncached one
671 * shows empty surfaces and waits for the Compute solution button. Runs on
672 * startup and on every selection change; a no-op while a computation is
673 * running (the run is not disturbed — only the cache note follows).
674 */
675async function refresh(): Promise<void> {
676 // Before the GPU is up there is nothing to refresh; while a computation
677 // runs the note follows the dropdowns and the refresh waits its turn. A
678 // missing session is NOT a reason to bail: applySelection rebuilds it,
679 // which is also what recovers from a failed compile.
680 if (!device || busy) {
681 void updateCacheNote();
682 return;
683 }
684 generation++;
685 const gen = generation;
686 elErr.textContent = '';
687 const spec = currentSpec();
688 try {
689 const lookup = await lookupFor(spec);
690 status('checking the cloud cache…');
691 let bytes: Uint8Array | null = null;
692 let unreachable = false;
693 try {
694 bytes = await fetchCached(lookup);
695 } catch {
696 unreachable = true;
697 }
698 if (gen !== generation) return;
699 await applySelection(spec);
700 if (gen !== generation) return;
701 if (bytes) {
702 await displayCached(bytes, lookup, spec, gen);
703 setCacheNote(true);
704 return;
705 }
706 clearDisplay();
707 setCacheNote(unreachable ? null : false);
708 status(
709 unreachable
710 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
711 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
712 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
713 `cached shorter run of the same settings is picked up where it left off).`,
714 );
715 } catch (e) {
716 if (gen === generation) {
717 elErr.textContent = formatFailure(e, model.source);
718 status('failed.');
719 }
720 }
721}
723/** The Compute solution button: cache lookup, then either load or compute. */
724async function solve(): Promise<void> {
725 if (!device || busy) return;
726 generation++;
727 const gen = generation;
728 setBusy(true);
729 // A stopped run may still be inside an await; let it see the generation
730 // bump and finish before touching the session.
731 while (pumping) await nextFrame();
732 if (gen !== generation) return;
733 stopRequested = false;
734 elErr.textContent = '';
735 elDownload.hidden = true;
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 try {
742 bytes = await fetchCached(lookup);
743 } catch (e) {
744 // An unreachable cache degrades to computing locally, and says so.
745 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
746 }
747 if (gen !== generation) return;
748 await applySelection(spec);
749 if (gen !== generation) return;
751 if (bytes) {
752 await displayCached(bytes, lookup, spec, gen);
753 return;
754 }
755 await computeLocally(spec, gen);
756 } catch (e) {
757 if (gen === generation) {
758 elErr.textContent = formatFailure(e, model.source);
759 status('failed.');
760 }
761 } finally {
762 if (gen === generation) setBusy(false);
763 void updateCacheNote();
764 }
765}
767/**
768 * How the page tells a run in progress: the status line, the live view, and
769 * when to give up. The same events drive the Compute solution button and the
770 * auto-fill walk, so the two report a run identically.
771 *
772 * `gen` is read afresh at every check rather than captured, so the events a
773 * walk installs once still speak for whichever target is current.
774 */
775function runEvents(gen: () => number): RunEvents {
776 let lastStatus = 0;
777 let lastDraw = 0;
778 return {
779 onPhase(phase) {
780 if (phase.kind === 'warm-search') {
781 status('not in the cache — looking for a shorter cached run…');
782 } else if (phase.kind === 'seeding') {
783 status('not in the cache — <b>computing locally</b>: seeding…');
784 } else if (phase.kind === 'encoding') {
785 status(`${doneLine(phase.run)} Writing the cache file…`);
786 } else {
787 status(
788 `${doneLine(phase.run)} Uploading to the cache ` +
789 `(${phase.uploaded}/${phase.started})…`,
790 );
791 }
792 },
793 onProgress(p) {
794 const now = performance.now();
795 if (now - lastStatus < STATUS_EVERY_MS) return;
796 lastStatus = now;
797 const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
798 const up = p.uploadsStarted
799 ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
800 : '';
801 status(
802 `not in the cache — <b>computing locally</b> (${from}` +
803 `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
804 `${p.rate.toFixed(0)} steps/s${up})`,
805 );
806 },
807 onStepping() {
808 shownT = null;
809 resetRanges();
810 },
811 async onTick() {
812 // Rendering is skipped entirely while the page is hidden, and the loop
813 // never waits on an animation frame there: a backgrounded tab throttles
814 // or stops requestAnimationFrame, which would stall an unattended run.
815 // The GPU sync inside the run already yields to the event loop, so Stop
816 // stays responsive either way.
817 const now = performance.now();
818 if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
819 lastDraw = now;
820 await draw();
821 if (gen() !== generation) return;
822 await nextFrame();
823 },
824 async onFinal(tEnd) {
825 shownT = tEnd;
826 await draw();
827 updateStats();
828 },
829 onFile(bytes, name) {
830 offerDownload(bytes, name);
831 },
832 cancelled: () => gen() !== generation,
833 stopRequested: () => stopRequested,
834 };
835}
837/** The first sentence of every finished run's status. */
838function doneLine(run: RunSummary): string {
839 return (
840 `<b>t = ${fmtChoice(run.tEnd)}</b> — computed locally in ${run.seconds.toFixed(1)} s` +
841 (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
842 `.`
843 );
844}
846/** Say how a finished run ended. Returns nothing; the caller counts. */
847async function reportOutcome(outcome: RunOutcome): Promise<void> {
848 if (outcome.kind === 'abandoned') return;
849 if (outcome.kind === 'diverged') {
850 elErr.textContent =
851 `the solution went non-finite at t = ${outcome.t.toFixed(2)} — nothing uploaded ` +
852 `(this combination is unstable at dt = ${fmtChoice(AUTO_DT)})`;
853 status('diverged.');
854 return;
855 }
856 if (outcome.kind === 'stopped') {
857 shownT = outcome.t;
858 await draw();
859 updateStats();
860 const n = outcome.uploaded.length;
861 const up = n ? ` ${n} snapshot${n > 1 ? 's' : ''} already uploaded.` : ' Nothing uploaded.';
862 status(`stopped at t = ${outcome.t.toFixed(2)}.${up}`);
863 return;
864 }
865 const line = doneLine(outcome);
866 if (outcome.uploadsStarted === 0) {
867 status(`${line} Not uploaded (no API key).`);
868 return;
869 }
870 if (outcome.uploadErrors.length) {
871 elErr.textContent = `upload: ${outcome.uploadErrors.join('; ')}`;
872 }
873 const n = outcome.uploaded.length;
874 if (n > 0) {
875 const times = [...outcome.uploaded].sort((a, b) => a - b).map(fmtChoice).join(', ');
876 const failed = outcome.uploadErrors.length
877 ? ` (${outcome.uploadErrors.length} failed)`
878 : '';
879 status(
880 `${line} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
881 `to the shared cache (t = ${times})${failed}.`,
882 );
883 } else {
884 status(`${line} Uploads failed.`);
885 }
886}
888/**
889 * Run the solver to the spec's end time, watching the pattern form, and
890 * capture the state at every smaller listed end time on the way
891 * (src/cache/runSpec.ts). Everything the page adds is in runEvents and
892 * reportOutcome.
893 */
894async function computeLocally(spec: CacheSpec, gen: number): Promise<RunOutcome> {
895 if (!solver?.session) return { kind: 'abandoned' };
896 pumping = true;
897 try {
898 const outcome = await runSpec({
899 solver,
900 spec,
901 adapter: adapterName,
902 runtime: 'browser-webgpu',
903 apiKey: () => elApiKey.value.trim(),
904 events: runEvents(() => gen),
905 });
906 if (gen === generation) await reportOutcome(outcome);
907 return outcome;
908 } finally {
909 pumping = false;
910 }
911}
913// ---------------------------------------------------------------- auto-fill
914function autoNote(target: AutoTarget | null): void {
915 if (!autoRunning) {
916 elAutoNote.textContent = autoComputed || autoSkipped
917 ? `stopped — computed ${autoComputed}, skipped ${autoSkipped} already cached` +
918 (autoFailed ? `, ${autoFailed} failed` : '')
919 : '';
920 return;
921 }
922 const where = target
923 ? `${mModelByKey(target.model)!.label} on ${target.geometry}, ${target.distance} ` +
924 `knob${target.distance === 1 ? '' : 's'} from the defaults`
925 : '';
926 elAutoNote.textContent =
927 `auto-filling — computed ${autoComputed}, skipped ${autoSkipped}` +
928 (autoFailed ? `, ${autoFailed} failed` : '') + (where ? ` · ${where}` : '');
929}
931function setAutoUi(on: boolean): void {
932 elAuto.textContent = on ? 'Auto-filling…' : 'Auto-fill the cache';
933 elAuto.disabled = on;
934 elReset.disabled = on;
935}
937/**
938 * Walk the parameter space on this machine, computing and contributing
939 * whatever is not cached yet, nearest the defaults first and randomly within
940 * a distance (src/cache/autoWalk.ts, src/cache/fillWalk.ts). Runs until
941 * stopped.
942 *
943 * Every target is driven through the same selection the user would set by
944 * hand, so the dropdowns and the URL always say what is being computed, and
945 * the run itself is the ordinary local computation — including its background
946 * uploads, its warm start from a shorter cached run, and its divergence
947 * guard.
948 */
949async function autoRun(): Promise<void> {
950 if (!device || !solver || busy || autoRunning) return;
951 if (!elApiKey.value.trim()) return;
952 autoRunning = true;
953 autoComputed = autoSkipped = autoFailed = 0;
954 setAutoUi(true);
955 setBusy(true);
956 elErr.textContent = '';
957 // Start from a defined point — which is also the first target, since the
958 // defaults are the one combination at distance zero.
959 applyDefaults();
960 autoNote(null);
962 // The generation of the target being computed, read by the run events.
963 let walkGen = 0;
964 await fillWalk({
965 targets: autoOrder(),
966 solver,
967 adapter: adapterName,
968 runtime: 'browser-webgpu',
969 apiKey: () => elApiKey.value.trim(),
970 beforeTarget(target) {
971 setSelection(target);
972 autoNote(target);
973 generation++;
974 walkGen = generation;
975 stopRequested = false;
976 return currentSpec();
977 },
978 events: {
979 ...runEvents(() => walkGen),
980 onTarget: () => status('checking the cloud cache…'),
981 onCached: (target) => {
982 autoSkipped++;
983 setCacheNote(true);
984 autoNote(target);
985 },
986 onComputing: () => setCacheNote(false),
987 onOutcome: (target, _spec, outcome) => {
988 if (outcome.kind === 'done') autoComputed++;
989 else if (outcome.kind === 'diverged') autoFailed++;
990 autoNote(target);
991 },
992 onFailure: (target, spec, e) => {
993 autoFailed++;
994 elErr.textContent =
995 `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
996 autoNote(target);
997 },
998 walkStopped: () => !autoRunning,
999 },
1000 });
1002 autoRunning = false;
1003 setAutoUi(false);
1004 setBusy(false);
1005 autoNote(null);
1006}
1008// ---------------------------------------------------------------- boot
1009elAuto.addEventListener('click', () => {
1010 flowChain = flowChain.then(() => autoRun()).catch(() => undefined);
1011});
1012elSolve.addEventListener('click', () => {
1013 flowChain = flowChain.then(() => solve()).catch(() => undefined);
1014});
1015elStop.addEventListener('click', () => {
1016 stopRequested = true;
1017 autoRunning = false;
1018 setBusy(false);
1019});
1020elReset.addEventListener('click', () => resetDefaults());
1021elResetView.addEventListener('click', () => {
1022 for (const s of scenes) s.resetCamera();
1023});
1024// The view is not drawn while the page is hidden, so it is stale on return.
1025// Not while a run is reading back: every read shares one staging buffer.
1026document.addEventListener('visibilitychange', () => {
1027 if (!document.hidden && sess() && !pumping) void draw();
1028});
1029elApiKey.addEventListener('change', () => {
1030 const key = elApiKey.value.trim();
1031 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1032 else localStorage.removeItem(API_KEY_STORAGE);
1033 updateUploadNote();
1034});
1036function updateUploadNote(): void {
1037 const hasKey = elApiKey.value.trim().length > 0;
1038 elUploadNote.textContent = hasKey
1039 ? 'uploads enabled — locally computed solutions will be contributed'
1040 : '';
1041 // Auto-fill exists to contribute, so it is offered only to those who can,
1042 // and so is the command that does the same thing elsewhere.
1043 elAutoBar.hidden = !hasKey;
1044 elCliBar.hidden = !hasKey;
1045 elCliHelp.hidden = !hasKey;
1046 elCliCmd.textContent = fillCommand('…');
1047 elCliCopied.textContent = '';
1048 if (!hasKey && autoRunning) autoRunning = false;
1049}
1051/**
1052 * The command that runs this same walk outside a browser. The tarball is
1053 * deployed beside the page, so the URL is derived from this one and a preview
1054 * deployment hands out its own command rather than main's.
1055 *
1056 * The key travels in the environment rather than in an option because argv is
1057 * visible to every user on the machine through `ps`, while another process's
1058 * environment is not. It is masked on screen and real in the clipboard: the
1059 * displayed command would otherwise put the key in any screenshot of a page
1060 * that has one, which is what the password field exists to prevent.
1061 */
1062function fillCommand(key: string): string {
1063 // The build id is not decoration: npx keys its install directory on the whole
1064 // spec string, so a URL that never changes keeps running whatever it first
1065 // installed. This one changes with every deployment.
1066 const url = new URL(`fill.tgz?v=${__BUILD_ID__}`, location.href).href;
1067 return `TURING_SURFACE_CACHE_KEY=${key} npx ${url}`;
1068}
1070elCliCopy.addEventListener('click', () => {
1071 const key = elApiKey.value.trim();
1072 if (!key) return;
1073 navigator.clipboard.writeText(fillCommand(key)).then(
1074 () => {
1075 elCliCopied.textContent = 'copied';
1076 setTimeout(() => (elCliCopied.textContent = ''), 4000);
1077 },
1078 () => {
1079 // No clipboard (an insecure origin, usually). Copying was the intent, so
1080 // show the whole thing and let it be selected by hand.
1081 elCliCmd.textContent = fillCommand(key);
1082 elCliCopied.textContent = 'clipboard unavailable — the key is now shown above';
1083 },
1084 );
1085});
1087async function boot(): Promise<void> {
1088 buildControls();
1089 // Written even before any change, so the address bar is always shareable.
1090 writeUrlState();
1091 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1092 updateUploadNote();
1093 void updateCacheNote();
1094 try {
1095 device = await requestShtDevice();
1096 // Before the adapter is even described, so a selection change during boot
1097 // finds a solver to apply itself to rather than an error.
1098 solver = new SolverSession(device, OVERSAMPLE, {
1099 onCompiling: (m) => status(`compiling ${m.label}…`),
1100 onSurface: () => rebuildViewFromSession(),
1101 });
1102 adapterName = await describeAdapter(device);
1103 } catch (e) {
1104 device = null;
1105 solver = null;
1106 elErr.textContent =
1107 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1108 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1109 return;
1110 }
1111 device.lost.then((info) => {
1112 if (info.reason !== 'destroyed') {
1113 elErr.textContent = `WebGPU device lost: ${info.message}`;
1114 }
1115 });
1117 try {
1118 await applySelection(currentSpec());
1119 } catch (e) {
1120 elErr.textContent = formatFailure(e, model.source);
1121 status('failed to compile.');
1122 return;
1123 }
1124 // Bring up the default selection if it is cached; otherwise show empty
1125 // surfaces. Nothing is ever computed without pressing the button.
1126 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
1127 await flowChain;
1128}
1130void boot();