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