Move the run and the walk out of the page
The compute loop, the auto-fill walk and the apply-a-selection logic were
welded to the DOM inside main.ts, which is fine for one front end and no use
to a second. They come out unchanged into src/cache/: solver.ts holds a
session together with what it has applied, and therefore the knowledge of
which changes are cheap and which pay a recompile; runSpec.ts holds one run,
warm start, snapshot ladder, background uploads and divergence guard
included; fillWalk.ts holds the loop over the walk's targets. What a caller
wants to say about a run in progress arrives through an events interface, so
none of the three knows whether it is driving a page or a terminal.
main.ts keeps every string it printed, now built from the event payloads
rather than from the loop's own variables, and drives the same three modules.
The HEAD that asks whether a solution is cached is shared too: one function
with three answers, since "could not tell" is not the same as "not there".
No behaviour change; scripts/check-app.mjs passes, warm start included.
5 changed files+736−362
src/cache/client.tsmodified+21−0View file
@@ -36,6 +36,27 @@ export async function fetchCached(lookup: CacheLookup): Promise<Uint8Array | nul
3636 return new Uint8Array(await res.arrayBuffer());
3737 }
3838
39+/**
40+ * Is this solution in the cloud? A HEAD is enough. Three answers, not two:
41+ * null is "could not tell", which a note may show as nothing at all and a
42+ * walk treats as absence — a network hiccup is not evidence that a file is
43+ * there, and computing it anyway only costs time and ends in an upload that
44+ * overwrites an identical object.
45+ */
46+export async function headCached(lookup: CacheLookup): Promise<boolean | null> {
47+ try {
48+ const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
49+ return res.ok ? true : res.status === 404 ? false : null;
50+ } catch {
51+ return null;
52+ }
53+}
54+
55+/** headCached, for a caller with nothing to say about "could not tell". */
56+export async function isCached(lookup: CacheLookup): Promise<boolean> {
57+ return (await headCached(lookup)) === true;
58+}
59+
3960 /** Upload one cache file. Resolves to its public URL. */
4061 export async function uploadCacheFile(
4162 apiKey: string,
src/cache/fillWalk.tsadded+87−0View file
@@ -0,0 +1,87 @@
1+/**
2+ * Working through the parameter space on an idle machine: for every target of
3+ * the walk (src/cache/autoWalk.ts), skip whatever the cloud already has and
4+ * compute and contribute the rest, until stopped.
5+ *
6+ * The loop is short but its rules matter, and they are the same in the page
7+ * and on the command line. A skip costs one HEAD, and only the longest end
8+ * time need be asked about, since a run reaching it emits every shorter one on
9+ * the way. One bad combination — a compile that fails, a solution that blows
10+ * up — is reported and stepped over rather than ending the walk. And every
11+ * target goes through the ordinary local run, warm start, background uploads,
12+ * divergence guard and all.
13+ */
14+import type { AutoTarget } from './autoWalk.ts';
15+import { isCached, lookupFor } from './client.ts';
16+import { runSpec, type RunEvents, type RunOutcome } from './runSpec.ts';
17+import type { SolverSession } from './solver.ts';
18+import type { CacheSpec } from './spec.ts';
19+
20+export interface FillCounts {
21+ computed: number;
22+ skipped: number;
23+ failed: number;
24+}
25+
26+export interface FillEvents extends RunEvents {
27+ /** A target is being considered; its cache status is not known yet. */
28+ onTarget?(target: AutoTarget, spec: CacheSpec): void;
29+ /** Already in the cloud — nothing to do. */
30+ onCached?(target: AutoTarget, spec: CacheSpec): void;
31+ /** Not cached: the run is about to start. */
32+ onComputing?(target: AutoTarget, spec: CacheSpec): void;
33+ onOutcome?(target: AutoTarget, spec: CacheSpec, outcome: RunOutcome): void;
34+ onFailure?(target: AutoTarget, spec: CacheSpec, error: unknown): void;
35+ /** True ends the walk at the next target boundary. */
36+ walkStopped?(): boolean;
37+}
38+
39+export interface FillOptions {
40+ targets: AutoTarget[];
41+ solver: SolverSession;
42+ adapter: string;
43+ apiKey(): string;
44+ /**
45+ * Take the selection to this target and hand back the spec to compute. The
46+ * page uses this to drive its own dropdowns and URL, so that what is on
47+ * screen always says what is being computed.
48+ */
49+ beforeTarget(target: AutoTarget): CacheSpec | Promise<CacheSpec>;
50+ events?: FillEvents;
51+}
52+
53+export async function fillWalk(opts: FillOptions): Promise<FillCounts> {
54+ const ev = opts.events ?? {};
55+ const counts: FillCounts = { computed: 0, skipped: 0, failed: 0 };
56+ for (const target of opts.targets) {
57+ if (ev.walkStopped?.()) break;
58+ const spec = await opts.beforeTarget(target);
59+ ev.onTarget?.(target, spec);
60+ try {
61+ if (await isCached(await lookupFor(spec))) {
62+ counts.skipped++;
63+ ev.onCached?.(target, spec);
64+ continue;
65+ }
66+ if (ev.walkStopped?.()) break;
67+ ev.onComputing?.(target, spec);
68+ await opts.solver.apply(spec);
69+ const outcome = await runSpec({
70+ solver: opts.solver,
71+ spec,
72+ adapter: opts.adapter,
73+ apiKey: opts.apiKey,
74+ events: ev,
75+ });
76+ if (outcome.kind === 'done') counts.computed++;
77+ else if (outcome.kind === 'diverged') counts.failed++;
78+ ev.onOutcome?.(target, spec, outcome);
79+ if (outcome.kind === 'abandoned') break;
80+ } catch (e) {
81+ // One bad combination must not end the walk: report it and move on.
82+ counts.failed++;
83+ ev.onFailure?.(target, spec, e);
84+ }
85+ }
86+ return counts;
87+}
src/cache/runSpec.tsadded+317−0View file
@@ -0,0 +1,317 @@
1+/**
2+ * Computing one cached solution: the run behind both the page's Compute
3+ * solution button and the command line's fill walk.
4+ *
5+ * What the run does besides stepping to the end time is the part worth having
6+ * in one place. It starts from the longest cached shorter run of the same
7+ * spec instead of from t = 0, since the state is Markovian in the spectral
8+ * coefficients; it captures the state at every listed end time it passes, and
9+ * encodes and uploads each one while the solver keeps stepping; and it
10+ * refuses to publish a state that has gone non-finite. All of that is worth
11+ * exactly one implementation.
12+ *
13+ * Everything a caller wants to say about a run in progress — status text,
14+ * rendering, when to stop — arrives through RunEvents, so nothing here knows
15+ * whether it is driving a page or a terminal.
16+ */
17+import { T_END_CHOICE } from './options.ts';
18+import { stepsFor, type CacheSpec } from './spec.ts';
19+import { lookupFor, fetchCached, uploadCacheFile } from './client.ts';
20+import { encodeCacheFile, decodeCacheFile, type DecodedCacheFile } from './h5file.ts';
21+import type { SolverSession } from './solver.ts';
22+
23+/**
24+ * Steps between syncs: many small submissions queued back to back, one wait.
25+ * The readbacks and renders that pace a live view happen per chunk, not per
26+ * submission — that is what lets a run advance at close to the solver's own
27+ * rate.
28+ */
29+const CHUNK_STEPS = 32;
30+
31+/** What a finished run amounts to, and all a caller needs to describe it. */
32+export interface RunSummary {
33+ tEnd: number;
34+ seconds: number;
35+ /** The end time this run resumed from, if it resumed from one. */
36+ warmFrom: number | null;
37+}
38+
39+export type RunPhase =
40+ | { kind: 'warm-search' }
41+ | { kind: 'seeding' }
42+ | { kind: 'encoding'; run: RunSummary }
43+ | { kind: 'uploading'; run: RunSummary; started: number; uploaded: number };
44+
45+export interface RunProgress {
46+ /** Simulation time reached, and where the run ends. */
47+ t: number;
48+ tEnd: number;
49+ steps: number;
50+ totalSteps: number;
51+ /** Fraction of *this* run's work done: a warm start begins at 0 here. */
52+ fraction: number;
53+ /** Steps per second over the run so far. */
54+ rate: number;
55+ /** The end time this run resumed from, if it resumed from one. */
56+ warmFrom: number | null;
57+ uploadsStarted: number;
58+ uploadsDone: number;
59+}
60+
61+export interface RunEvents {
62+ onPhase?(phase: RunPhase): void;
63+ /** The warm start or the seeding is done and the stepping is about to
64+ * begin: whatever is on display now belongs to the previous run. */
65+ onStepping?(): void;
66+ /** Once per chunk. Callers throttle their own display. */
67+ onProgress?(p: RunProgress): void;
68+ /** After each chunk: the caller's chance to draw, and to yield. */
69+ onTick?(): Promise<void> | void;
70+ /** The final state is computed and finite, before the file is written. */
71+ onFinal?(tEnd: number): Promise<void> | void;
72+ onUploaded?(tEnd: number): void;
73+ /** The finished file, named as it is in the cache. */
74+ onFile?(bytes: Uint8Array, fileName: string): void;
75+ /**
76+ * Abandon the run at the next safe point, reporting nothing: something else
77+ * has taken the session over. Distinct from stopRequested, which is a
78+ * deliberate stop whose partial results still count.
79+ */
80+ cancelled?(): boolean;
81+ /** Stop cleanly at the next chunk boundary, keeping what was uploaded. */
82+ stopRequested?(): boolean;
83+}
84+
85+export type RunOutcome =
86+ | (RunSummary & {
87+ kind: 'done';
88+ fileName: string;
89+ bytes: Uint8Array;
90+ /** End times uploaded, in the order they completed. */
91+ uploaded: number[];
92+ uploadsStarted: number;
93+ uploadErrors: string[];
94+ })
95+ | { kind: 'stopped'; t: number; uploaded: number[] }
96+ | { kind: 'diverged'; t: number }
97+ | { kind: 'abandoned' };
98+
99+export interface RunOptions {
100+ solver: SolverSession;
101+ spec: CacheSpec;
102+ /** Which GPU computed it; recorded in every file it writes. */
103+ adapter: string;
104+ /** Read at every upload point, so a key entered mid-run still contributes. */
105+ apiKey(): string;
106+ events?: RunEvents;
107+}
108+
109+/**
110+ * Nothing that is not a number gets uploaded. A combination whose timestep is
111+ * too large for its reaction blows up rather than failing, and an unattended
112+ * walk would happily publish the wreckage under a hash someone later trusts.
113+ */
114+const stateIsFinite = (state: Record<string, Float32Array>): boolean =>
115+ Object.values(state).every((a) => a.every(Number.isFinite));
116+
117+/** Run the solver to the spec's end time, contributing everything it passes. */
118+export async function runSpec(opts: RunOptions): Promise<RunOutcome> {
119+ const { solver, spec, adapter, apiKey } = opts;
120+ const ev = opts.events ?? {};
121+ const cancelled = (): boolean => ev.cancelled?.() ?? false;
122+ const session = solver.live;
123+ const steps = stepsFor(spec);
124+ const dt = spec.params.dt;
125+
126+ // Warm start: the state is Markovian in (U, V), so a cached run of the same
127+ // spec at a smaller listed end time is an exact prefix of this one. Take the
128+ // longest one there is and continue from its final state rather than
129+ // recomputing it.
130+ let warm: { tEnd: number; decoded: DecodedCacheFile } | null = null;
131+ const earlier = T_END_CHOICE.values.filter((T) => T < spec.tEnd).sort((a, b) => b - a);
132+ if (earlier.length) ev.onPhase?.({ kind: 'warm-search' });
133+ for (const T of earlier) {
134+ const lookup = await lookupFor({ ...spec, tEnd: T });
135+ let bytes: Uint8Array | null = null;
136+ try {
137+ bytes = await fetchCached(lookup);
138+ } catch {
139+ break; // cache unreachable: no point probing further down the ladder
140+ }
141+ if (cancelled()) return { kind: 'abandoned' };
142+ if (!bytes) continue;
143+ try {
144+ warm = { tEnd: T, decoded: await decodeCacheFile(bytes, lookup.specJson, solver.model.state) };
145+ break;
146+ } catch {
147+ continue; // an unreadable candidate is skipped, not fatal
148+ }
149+ }
150+ if (cancelled()) return { kind: 'abandoned' };
151+
152+ let initial: Record<string, Float32Array>;
153+ if (warm) {
154+ session.loadState(warm.decoded.final);
155+ // loadState resets the clock; put it at the cached run's end so the loop
156+ // below computes only the remainder.
157+ session.steps = Math.round(warm.tEnd / dt);
158+ session.t = warm.tEnd;
159+ // The t = 0 state travels with every file of the chain, so files written
160+ // from this continuation carry the same initial state as the one resumed.
161+ initial = warm.decoded.initial;
162+ } else {
163+ ev.onPhase?.({ kind: 'seeding' });
164+ await session.seed(spec.seed);
165+ if (cancelled()) return { kind: 'abandoned' };
166+ initial = await session.readState();
167+ if (cancelled()) return { kind: 'abandoned' };
168+ }
169+ const startSteps = session.steps;
170+
171+ // Snapshot points: every listed end time strictly between the starting point
172+ // and this run's end. The run passes through each exactly (all are whole
173+ // multiples of every dt choice).
174+ const snapshotAt = new Map<number, number>(); // step index -> tEnd value
175+ for (const T of T_END_CHOICE.values) {
176+ if (T < spec.tEnd && T > (warm?.tEnd ?? 0)) snapshotAt.set(Math.round(T / dt), T);
177+ }
178+ const snapshots: { tEnd: number; state: Record<string, Float32Array> }[] = [];
179+
180+ // Everything a cache file needs exists before the run starts, so a snapshot
181+ // is encoded and uploaded the moment it is captured, overlapping the network
182+ // with the GPU still stepping, rather than queued for the end.
183+ const geometryCoeffs = {
184+ X: session.geometry.X,
185+ Y: session.geometry.Y,
186+ Z: session.geometry.Z,
187+ };
188+ const encode = (t: number, state: Record<string, Float32Array>) =>
189+ encodeCacheFile({
190+ spec: { ...spec, tEnd: t },
191+ grid: session.cfg,
192+ species: solver.model.state,
193+ geometry: geometryCoeffs,
194+ initial,
195+ final: state,
196+ adapter,
197+ });
198+ const uploadedTimes: number[] = [];
199+ const uploadErrors: string[] = [];
200+ let uploadsStarted = 0;
201+ const pendingUploads: Promise<void>[] = [];
202+ /** Encode + upload without the stepping loop waiting. A captured snapshot is
203+ * a complete solution of its own spec, so this stays valid even if the run
204+ * is stopped afterwards. */
205+ const uploadInBackground = (
206+ t: number,
207+ state: Record<string, Float32Array>,
208+ key: string,
209+ preEncoded?: Uint8Array,
210+ ): void => {
211+ uploadsStarted++;
212+ pendingUploads.push(
213+ (async () => {
214+ const bytes = preEncoded ?? (await encode(t, state));
215+ const lookup = await lookupFor({ ...spec, tEnd: t });
216+ await uploadCacheFile(key, lookup.fileName, bytes);
217+ uploadedTimes.push(t);
218+ ev.onUploaded?.(t);
219+ })().catch((e) => {
220+ uploadErrors.push(`t = ${t}: ${e instanceof Error ? e.message : e}`);
221+ }),
222+ );
223+ };
224+
225+ ev.onStepping?.();
226+ const t0 = performance.now();
227+ while (session.steps < steps) {
228+ if (cancelled()) return { kind: 'abandoned' };
229+ if (ev.stopRequested?.()) {
230+ return { kind: 'stopped', t: session.steps * dt, uploaded: [...uploadedTimes] };
231+ }
232+ // One chunk: up to CHUNK_STEPS steps submitted back to back (each
233+ // submission stays under the dispatch budget), then a single sync and at
234+ // most one render. Reading back and drawing after every submission is what
235+ // made the run advance at a fraction of the solver's rate — a readback
236+ // costs several times the 3-4 steps it fenced. The chunk stops exactly at
237+ // snapshot points so those states are still captured exactly.
238+ let target = Math.min(steps, session.steps + CHUNK_STEPS);
239+ for (const s of snapshotAt.keys()) {
240+ if (s > session.steps && s < target) target = s;
241+ }
242+ while (session.steps < target) {
243+ session.step(Math.min(solver.stepsPerSubmit, target - session.steps));
244+ }
245+ // The sync bounds how far the CPU runs ahead of the GPU, and (being a
246+ // promise) yields to the event loop, which is what keeps a Stop button
247+ // clickable.
248+ await session.sync();
249+ if (cancelled()) return { kind: 'abandoned' };
250+ const hit = snapshotAt.get(session.steps);
251+ if (hit !== undefined) {
252+ const state = await session.readState();
253+ if (cancelled()) return { kind: 'abandoned' };
254+ if (!stateIsFinite(state)) return { kind: 'diverged', t: session.steps * dt };
255+ // With a key on hand the snapshot goes straight to the cache; without one
256+ // it is kept, in case a key is entered before the run ends.
257+ const key = apiKey();
258+ if (key) uploadInBackground(hit, state, key);
259+ else snapshots.push({ tEnd: hit, state });
260+ }
261+ ev.onProgress?.({
262+ t: session.steps * dt,
263+ tEnd: spec.tEnd,
264+ steps: session.steps,
265+ totalSteps: steps,
266+ fraction: (session.steps - startSteps) / (steps - startSteps),
267+ rate: (session.steps - startSteps) / ((performance.now() - t0) / 1000),
268+ warmFrom: warm?.tEnd ?? null,
269+ uploadsStarted,
270+ uploadsDone: uploadedTimes.length,
271+ });
272+ await ev.onTick?.();
273+ }
274+ if (cancelled()) return { kind: 'abandoned' };
275+
276+ const final = await session.readState();
277+ if (cancelled()) return { kind: 'abandoned' };
278+ if (!stateIsFinite(final)) return { kind: 'diverged', t: spec.tEnd };
279+ await ev.onFinal?.(spec.tEnd);
280+ const summary: RunSummary = {
281+ tEnd: spec.tEnd,
282+ seconds: (performance.now() - t0) / 1000,
283+ warmFrom: warm?.tEnd ?? null,
284+ };
285+
286+ ev.onPhase?.({ kind: 'encoding', run: summary });
287+ const finalBytes = await encode(spec.tEnd, final);
288+ if (cancelled()) return { kind: 'abandoned' };
289+ const fileName = (await lookupFor(spec)).fileName.split('/').pop()!;
290+ ev.onFile?.(finalBytes, fileName);
291+
292+ // The final solution, plus any snapshots captured before a key was entered.
293+ const key = apiKey();
294+ if (key) {
295+ uploadInBackground(spec.tEnd, final, key, finalBytes);
296+ for (const snap of snapshots) uploadInBackground(snap.tEnd, snap.state, key);
297+ }
298+ if (uploadsStarted > 0) {
299+ ev.onPhase?.({
300+ kind: 'uploading',
301+ run: summary,
302+ started: uploadsStarted,
303+ uploaded: uploadedTimes.length,
304+ });
305+ await Promise.all(pendingUploads);
306+ if (cancelled()) return { kind: 'abandoned' };
307+ }
308+ return {
309+ kind: 'done',
310+ ...summary,
311+ fileName,
312+ bytes: finalBytes,
313+ uploaded: [...uploadedTimes],
314+ uploadsStarted,
315+ uploadErrors,
316+ };
317+}
src/cache/solver.tsadded+113−0View file
@@ -0,0 +1,113 @@
1+/**
2+ * A compiled solver session together with the selection it currently has
3+ * applied.
4+ *
5+ * Which changes are cheap and which are not is a property of the solver, not
6+ * of any front end: parameters are a uniform upload, a geometry change
7+ * re-evaluates the surface, and a model change recompiles everything, since
8+ * the model's step is compiled into the GPU pipelines. Both the page and the
9+ * command line need that distinction — a walk through the parameter space
10+ * spends its whole time on the cheap side of it — so it lives here rather
11+ * than in either of them.
12+ */
13+import { ModelSession } from '../mgpu/session.ts';
14+import { mModelByKey, type MModel, type Params } from '../mgpu/registry.ts';
15+import { mGeometryByKey } from '../geom/registry.ts';
16+import type { CacheSpec } from './spec.ts';
17+
18+/** Cap on GPU dispatches per submission (watchdog safety; see turing-surface). */
19+const DISPATCH_BUDGET = 1000;
20+
21+export interface SolverEvents {
22+ /** A model change costs a recompile — a second or two on a real GPU. */
23+ onCompiling?(model: MModel): void;
24+ /**
25+ * The surface has changed (a new session, or a new geometry in the running
26+ * one), so anything drawing it must be rebuilt. Awaited, so a caller that
27+ * rebuilds a mesh finishes before the session is used.
28+ */
29+ onSurface?(): Promise<void> | void;
30+}
31+
32+export class SolverSession {
33+ session: ModelSession | null = null;
34+ /** The model the session is compiled for. */
35+ model: MModel;
36+ /**
37+ * Steps per GPU submission, sized on every compile so one submission stays
38+ * under the dispatch budget however expensive niter has made a step.
39+ */
40+ stepsPerSubmit = 4;
41+
42+ #modelKey = '';
43+ #geomKey = '';
44+ #geomParams: Params = {};
45+
46+ constructor(
47+ readonly device: GPUDevice,
48+ /** Render grid fineness; 1 (the default) allocates no display plan. */
49+ readonly oversample = 1,
50+ private readonly events: SolverEvents = {},
51+ ) {
52+ this.model = mModelByKey('schnakenberg')!;
53+ }
54+
55+ /** The session, or a thrown error rather than a silent no-op. */
56+ get live(): ModelSession {
57+ if (!this.session) throw new Error('no solver session');
58+ return this.session;
59+ }
60+
61+ /**
62+ * Bring the session in line with a spec, doing the least work that will do:
63+ * a uniform upload for parameters, a surface re-evaluation for a geometry
64+ * change, a full recompile for a model change.
65+ */
66+ async apply(spec: CacheSpec): Promise<void> {
67+ if (!this.session || spec.model !== this.#modelKey) {
68+ await this.#rebuild(spec);
69+ return;
70+ }
71+ this.session.setParams(spec.params);
72+ const geomChanged =
73+ spec.geometry !== this.#geomKey ||
74+ JSON.stringify(spec.geometryParams) !== JSON.stringify(this.#geomParams);
75+ if (!geomChanged) return;
76+ await this.session.setGeometry(mGeometryByKey(spec.geometry)!, spec.geometryParams);
77+ this.#geomKey = spec.geometry;
78+ this.#geomParams = { ...spec.geometryParams };
79+ await this.events.onSurface?.();
80+ }
81+
82+ async #rebuild(spec: CacheSpec): Promise<void> {
83+ const nextModel = mModelByKey(spec.model)!;
84+ this.session?.destroy();
85+ this.session = null;
86+ this.#modelKey = '';
87+ this.events.onCompiling?.(nextModel);
88+ this.session = await ModelSession.create({
89+ device: this.device,
90+ model: nextModel,
91+ params: spec.params,
92+ lmax: spec.lmax,
93+ oversample: this.oversample,
94+ geometry: mGeometryByKey(spec.geometry)!,
95+ geometryParams: spec.geometryParams,
96+ niter: spec.niter,
97+ lam3: spec.lam3,
98+ });
99+ this.model = nextModel;
100+ this.#modelKey = spec.model;
101+ this.#geomKey = spec.geometry;
102+ this.#geomParams = { ...spec.geometryParams };
103+ const opsPerStep = Math.max(1, this.session.describe().step.length);
104+ this.stepsPerSubmit = Math.max(1, Math.floor(DISPATCH_BUDGET / opsPerStep));
105+ await this.events.onSurface?.();
106+ }
107+
108+ destroy(): void {
109+ this.session?.destroy();
110+ this.session = null;
111+ this.#modelKey = '';
112+ }
113+}
src/main.tsmodified+198−362View file
@@ -18,7 +18,7 @@
1818 * this app (options.ts) — fewer knobs, same machinery.
1919 */
2020 import { requestShtDevice, describeAdapter } from './sht/sht.ts';
21-import { ModelSession } from './mgpu/session.ts';
21+import type { ModelSession } from './mgpu/session.ts';
2222 import { mModels, mModelByKey, type MModel, type Params } from './mgpu/registry.ts';
2323 import { formatFailure } from './mgpu/errors.ts';
2424 import {
@@ -53,9 +53,12 @@ import {
5353 type DiscreteChoice,
5454 } from './cache/options.ts';
5555 import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
56-import { lookupFor, fetchCached, uploadCacheFile, type CacheLookup } from './cache/client.ts';
56+import { lookupFor, fetchCached, headCached, type CacheLookup } from './cache/client.ts';
5757 import { autoOrder, type AutoTarget } from './cache/autoWalk.ts';
58-import { encodeCacheFile, decodeCacheFile, type DecodedCacheFile } from './cache/h5file.ts';
58+import { decodeCacheFile } from './cache/h5file.ts';
59+import { SolverSession } from './cache/solver.ts';
60+import { runSpec, type RunEvents, type RunOutcome, type RunSummary } from './cache/runSpec.ts';
61+import { fillWalk } from './cache/fillWalk.ts';
5962
6063 const $ = <T extends HTMLElement>(id: string): T =>
6164 document.getElementById(id) as T;
@@ -106,24 +109,21 @@ const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
106109 const COLORMAP = colormaps.viridis;
107110 /** Render on a 2x finer grid than the solver's; exact interpolation. */
108111 const OVERSAMPLE = 2;
109-/** Cap on GPU dispatches per submission (watchdog safety; see turing-surface). */
110-const 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. */
115-const CHUNK_STEPS = 32;
116112 /** How often the live view renders during a computation. */
117113 const RENDER_EVERY_MS = 250;
114+/** How often the status line is rewritten during a computation. */
115+const STATUS_EVERY_MS = 200;
118116
119117 // ---------------------------------------------------------------- state
120118 let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
121119 let device: GPUDevice | null = null;
122-let session: ModelSession | null = null;
120+/** The compiled solver and what it has applied (src/cache/solver.ts). */
121+let solver: SolverSession | null = null;
123122 let 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. */
126-let stepsPerSubmit = 4;
123+/** The live session, or null before the GPU is up. */
124+function sess(): ModelSession | null {
125+ return solver?.session ?? null;
126+}
127127
128128 /** The discrete selections, always exactly values from options.ts. */
129129 let params: Params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
@@ -139,14 +139,6 @@ let tEnd = T_END_CHOICE.value;
139139 // cached solution). Read once at startup; rewritten on every change.
140140 readUrlState();
141141
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. */
146-let sessionModelKey = '';
147-let sessionGeomKey = '';
148-let sessionGeomParams: Params = {};
149-
150142 let topo: SphereMeshTopology | null = null;
151143 let scenes: SphereScene[] = [];
152144 let colorbars: Colorbar[] = [];
@@ -437,13 +429,7 @@ async function updateCacheNote(): Promise<void> {
437429 } catch {
438430 return;
439431 }
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- }
432+ const present = await headCached(lookup);
447433 if (token !== cacheNoteToken) return;
448434 setCacheNote(present);
449435 }
@@ -473,6 +459,7 @@ function disposeView(): void {
473459 }
474460
475461 function buildView(surface: Float32Array): void {
462+ const session = sess();
476463 if (!session) return;
477464 const view = session.viewSht;
478465 const { nphi } = view.cfg;
@@ -528,6 +515,7 @@ function buildView(surface: Float32Array): void {
528515 }
529516
530517 async function draw(): Promise<void> {
518+ const session = sess();
531519 if (!session || !topo) return;
532520 const gen = generation;
533521 for (let k = 0; k < model.species.length; k++) {
@@ -589,6 +577,7 @@ function resetRanges(): void {
589577 }
590578
591579 function updateStats(): void {
580+ const session = sess();
592581 if (!session) return;
593582 const { nlat, nphi } = session.cfg;
594583 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
@@ -622,6 +611,7 @@ function offerDownload(bytes: Uint8Array, name: string): void {
622611 * the camera. Fresh buffers render black until the first fill, so the bare
623612 * surface is shown; the caller's draw or clearDisplay follows right behind. */
624613 async function rebuildViewFromSession(): Promise<void> {
614+ const session = sess();
625615 if (!session) return;
626616 const surface = await session.renderPositions();
627617 const cam = scenes[0]?.cameraState();
@@ -632,61 +622,15 @@ async function rebuildViewFromSession(): Promise<void> {
632622 }
633623
634624 /**
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.
625+ * Apply a selection to the solver. Which changes are cheap and which pay a
626+ * recompile is the solver's business (src/cache/solver.ts); the page adds the
627+ * compiling status and the rebuilt view through the events it installs in
628+ * boot(), since the panel count follows the model's species (Allen–Cahn has
629+ * one).
640630 */
641-async 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-}
671-
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. */
675631 async 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();
632+ if (!solver) throw new Error('no GPU device');
633+ await solver.apply(spec);
690634 }
691635
692636 /** Decode a fetched cache file and put it on screen. */
@@ -696,6 +640,7 @@ async function displayCached(
696640 spec: CacheSpec,
697641 gen: number,
698642 ): Promise<void> {
643+ const session = sess();
699644 if (!session) return;
700645 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
701646 if (gen !== generation) return;
@@ -814,275 +759,151 @@ async function solve(): Promise<void> {
814759 }
815760
816761 /**
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.
762+ * How the page tells a run in progress: the status line, the live view, and
763+ * when to give up. The same events drive the Compute solution button and the
764+ * auto-fill walk, so the two report a run identically.
765+ *
766+ * `gen` is read afresh at every check rather than captured, so the events a
767+ * walk installs once still speak for whichever target is current.
820768 */
821-const stateIsFinite = (state: Record<string, Float32Array>): boolean =>
822- Object.values(state).every((a) => a.every(Number.isFinite));
823-
824-/** Set by reportDiverged, read by the auto walk so a blown-up combination is
825- * counted as a failure rather than a contribution. */
826-let lastRunDiverged = false;
827-
828-function 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-}
835-
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. */
838-async 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-}
847-
848-async 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;
853-
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;
879-
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;
898-
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> }[] = [];
907-
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- };
951-
952- shownT = null;
953- resetRanges();
954- const t0 = performance.now();
769+function runEvents(gen: () => number): RunEvents {
955770 let lastStatus = 0;
956771 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) {
772+ return {
773+ onPhase(phase) {
774+ if (phase.kind === 'warm-search') {
775+ status('not in the cache — looking for a shorter cached run…');
776+ } else if (phase.kind === 'seeding') {
777+ status('not in the cache — <b>computing locally</b>: seeding…');
778+ } else if (phase.kind === 'encoding') {
779+ status(`${doneLine(phase.run)} Writing the cache file…`);
780+ } else {
781+ status(
782+ `${doneLine(phase.run)} Uploading to the cache ` +
783+ `(${phase.uploaded}/${phase.started})…`,
784+ );
785+ }
786+ },
787+ onProgress(p) {
788+ const now = performance.now();
789+ if (now - lastStatus < STATUS_EVERY_MS) return;
1010790 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`
791+ const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
792+ const up = p.uploadsStarted
793+ ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
1017794 : '';
1018795 status(
1019796 `not in the cache — <b>computing locally</b> (${from}` +
1020- `t = ${t.toFixed(2)} / ${fmtChoice(spec.tEnd)}, ${pct}%, ${rate.toFixed(0)} steps/s${up})`,
797+ `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
798+ `${p.rate.toFixed(0)} steps/s${up})`,
1021799 );
1022- }
1023- }
1024-
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…`);
800+ },
801+ onStepping() {
802+ shownT = null;
803+ resetRanges();
804+ },
805+ async onTick() {
806+ // Rendering is skipped entirely while the page is hidden, and the loop
807+ // never waits on an animation frame there: a backgrounded tab throttles
808+ // or stops requestAnimationFrame, which would stall an unattended run.
809+ // The GPU sync inside the run already yields to the event loop, so Stop
810+ // stays responsive either way.
811+ const now = performance.now();
812+ if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
813+ lastDraw = now;
814+ await draw();
815+ if (gen() !== generation) return;
816+ await nextFrame();
817+ },
818+ async onFinal(tEnd) {
819+ shownT = tEnd;
820+ await draw();
821+ updateStats();
822+ },
823+ onFile(bytes, name) {
824+ offerDownload(bytes, name);
825+ },
826+ cancelled: () => gen() !== generation,
827+ stopRequested: () => stopRequested,
828+ };
829+}
1037830
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()!);
831+/** The first sentence of every finished run's status. */
832+function doneLine(run: RunSummary): string {
833+ return (
834+ `<b>t = ${fmtChoice(run.tEnd)}</b> — computed locally in ${run.seconds.toFixed(1)} s` +
835+ (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
836+ `.`
837+ );
838+}
1042839
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);
840+/** Say how a finished run ended. Returns nothing; the caller counts. */
841+async function reportOutcome(outcome: RunOutcome): Promise<void> {
842+ if (outcome.kind === 'abandoned') return;
843+ if (outcome.kind === 'diverged') {
844+ elErr.textContent =
845+ `the solution went non-finite at t = ${outcome.t.toFixed(2)} — nothing uploaded ` +
846+ `(this combination is unstable at dt = ${fmtChoice(AUTO_DT)})`;
847+ status('diverged.');
848+ return;
1048849 }
1049- if (uploadsStarted === 0) {
1050- status(`${doneLine} Not uploaded (no API key).`);
850+ if (outcome.kind === 'stopped') {
851+ shownT = outcome.t;
852+ await draw();
853+ updateStats();
854+ const n = outcome.uploaded.length;
855+ const up = n ? ` ${n} snapshot${n > 1 ? 's' : ''} already uploaded.` : ' Nothing uploaded.';
856+ status(`stopped at t = ${outcome.t.toFixed(2)}.${up}`);
1051857 return;
1052858 }
1053- status(`${doneLine} Uploading to the cache (${uploadedTimes.length}/${uploadsStarted})…`);
1054- await Promise.all(pendingUploads);
1055- if (gen !== generation) return;
1056-
1057- if (uploadErrors.length) elErr.textContent = `upload: ${uploadErrors.join('; ')}`;
1058- const n = uploadedTimes.length;
859+ const line = doneLine(outcome);
860+ if (outcome.uploadsStarted === 0) {
861+ status(`${line} Not uploaded (no API key).`);
862+ return;
863+ }
864+ if (outcome.uploadErrors.length) {
865+ elErr.textContent = `upload: ${outcome.uploadErrors.join('; ')}`;
866+ }
867+ const n = outcome.uploaded.length;
1059868 if (n > 0) {
1060- const times = [...uploadedTimes].sort((a, b) => a - b).map(fmtChoice).join(', ');
1061- const failed = uploadErrors.length ? ` (${uploadErrors.length} failed)` : '';
869+ const times = [...outcome.uploaded].sort((a, b) => a - b).map(fmtChoice).join(', ');
870+ const failed = outcome.uploadErrors.length
871+ ? ` (${outcome.uploadErrors.length} failed)`
872+ : '';
1062873 status(
1063- `${doneLine} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
874+ `${line} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
1064875 `to the shared cache (t = ${times})${failed}.`,
1065876 );
1066877 } else {
1067- status(`${doneLine} Uploads failed.`);
878+ status(`${line} Uploads failed.`);
1068879 }
1069880 }
1070881
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. */
1075-async function isCached(lookup: CacheLookup): Promise<boolean> {
882+/**
883+ * Run the solver to the spec's end time, watching the pattern form, and
884+ * capture the state at every smaller listed end time on the way
885+ * (src/cache/runSpec.ts). Everything the page adds is in runEvents and
886+ * reportOutcome.
887+ */
888+async function computeLocally(spec: CacheSpec, gen: number): Promise<RunOutcome> {
889+ if (!solver?.session) return { kind: 'abandoned' };
890+ pumping = true;
1076891 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;
892+ const outcome = await runSpec({
893+ solver,
894+ spec,
895+ adapter: adapterName,
896+ apiKey: () => elApiKey.value.trim(),
897+ events: runEvents(() => gen),
898+ });
899+ if (gen === generation) await reportOutcome(outcome);
900+ return outcome;
901+ } finally {
902+ pumping = false;
1083903 }
1084904 }
1085905
906+// ---------------------------------------------------------------- auto-fill
1086907 function autoNote(target: AutoTarget | null): void {
1087908 if (!autoRunning) {
1088909 elAutoNote.textContent = autoComputed || autoSkipped
@@ -1109,16 +930,17 @@ function setAutoUi(on: boolean): void {
1109930 /**
1110931 * Walk the parameter space on this machine, computing and contributing
1111932 * whatever is not cached yet, nearest the defaults first and randomly within
1112- * a distance (src/cache/autoWalk.ts). Runs until stopped.
933+ * a distance (src/cache/autoWalk.ts, src/cache/fillWalk.ts). Runs until
934+ * stopped.
1113935 *
1114936 * Every target is driven through the same selection the user would set by
1115937 * 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.
938+ * the run itself is the ordinary local computation — including its background
939+ * uploads, its warm start from a shorter cached run, and its divergence
940+ * guard.
1119941 */
1120942 async function autoRun(): Promise<void> {
1121- if (!device || busy || autoRunning) return;
943+ if (!device || !solver || busy || autoRunning) return;
1122944 if (!elApiKey.value.trim()) return;
1123945 autoRunning = true;
1124946 autoComputed = autoSkipped = autoFailed = 0;
@@ -1128,39 +950,46 @@ async function autoRun(): Promise<void> {
1128950 // Start from a defined point — which is also the first target, since the
1129951 // defaults are the one combination at distance zero.
1130952 applyDefaults();
1131- const targets = autoOrder();
1132953 autoNote(null);
1133954
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)) {
955+ // The generation of the target being computed, read by the run events.
956+ let walkGen = 0;
957+ await fillWalk({
958+ targets: autoOrder(),
959+ solver,
960+ adapter: adapterName,
961+ apiKey: () => elApiKey.value.trim(),
962+ beforeTarget(target) {
963+ setSelection(target);
964+ autoNote(target);
965+ generation++;
966+ walkGen = generation;
967+ stopRequested = false;
968+ return currentSpec();
969+ },
970+ events: {
971+ ...runEvents(() => walkGen),
972+ onTarget: () => status('checking the cloud cache…'),
973+ onCached: (target) => {
1146974 autoSkipped++;
1147975 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- }
976+ autoNote(target);
977+ },
978+ onComputing: () => setCacheNote(false),
979+ onOutcome: (target, _spec, outcome) => {
980+ if (outcome.kind === 'done') autoComputed++;
981+ else if (outcome.kind === 'diverged') autoFailed++;
982+ autoNote(target);
983+ },
984+ onFailure: (target, spec, e) => {
985+ autoFailed++;
986+ elErr.textContent =
987+ `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
988+ autoNote(target);
989+ },
990+ walkStopped: () => !autoRunning,
991+ },
992+ });
1164993
1165994 autoRunning = false;
1166995 setAutoUi(false);
@@ -1187,7 +1016,7 @@ elResetView.addEventListener('click', () => {
11871016 // The view is not drawn while the page is hidden, so it is stale on return.
11881017 // Not while a run is reading back: every read shares one staging buffer.
11891018 document.addEventListener('visibilitychange', () => {
1190- if (!document.hidden && session && !pumping) void draw();
1019+ if (!document.hidden && sess() && !pumping) void draw();
11911020 });
11921021 elApiKey.addEventListener('change', () => {
11931022 const key = elApiKey.value.trim();
@@ -1215,9 +1044,16 @@ async function boot(): Promise<void> {
12151044 void updateCacheNote();
12161045 try {
12171046 device = await requestShtDevice();
1047+ // Before the adapter is even described, so a selection change during boot
1048+ // finds a solver to apply itself to rather than an error.
1049+ solver = new SolverSession(device, OVERSAMPLE, {
1050+ onCompiling: (m) => status(`compiling ${m.label}…`),
1051+ onSurface: () => rebuildViewFromSession(),
1052+ });
12181053 adapterName = await describeAdapter(device);
12191054 } catch (e) {
12201055 device = null;
1056+ solver = null;
12211057 elErr.textContent =
12221058 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
12231059 `Use a WebGPU-capable browser such as Chrome or Edge.`;
@@ -1230,7 +1066,7 @@ async function boot(): Promise<void> {
12301066 });
12311067
12321068 try {
1233- await rebuildSession(currentSpec());
1069+ await applySelection(currentSpec());
12341070 } catch (e) {
12351071 elErr.textContent = formatFailure(e, model.source);
12361072 status('failed to compile.');