Add auto-fill: work through the parameter space on an idle machine
Offered only when an upload API key is present. The walk orders the ~8,400
combinations by how many knobs differ from the defaults -- every one-knob
deviation before any two-knob one -- because a visitor starts at the defaults
and changes one dropdown at a time, so demand falls off steeply with that
distance. Within a distance the order is random, and that is the whole
coordination mechanism between machines: each skips what it finds already
cached, so several idle browsers rarely duplicate each other and need no
coordinator.
The seed and dt are pinned rather than surveyed (1 and 0.05), which multiplies
the work by nothing and matches what a visitor arriving at the defaults asks
for; dt 0.05 is now every model's default, verified stable for Brusselator and
Allen-Cahn against dt 0.02 on desktop Dawn. A run whose state goes non-finite
is discarded rather than uploaded, and the compute loop no longer waits on an
animation frame or draws while the page is hidden, so a backgrounded tab keeps
computing at full speed.
6 changed files+381−7
README.mdmodified+43−0View file
@@ -79,6 +79,49 @@ to know what it belongs to; the hash input includes the version string, so a
7979 format change moves every object rather than silently colliding with the old
8080 ones.
8181
82+## Filling the cache
83+
84+A cache only pays off once it holds what people ask for, and nobody wants to
85+sit through the first computation of every combination. **Auto-fill the
86+cache** — offered only when an upload API key is present — turns an otherwise
87+idle machine into a contributor: it works through the parameter space,
88+skipping whatever is already cached and computing and uploading the rest, and
89+runs until stopped.
90+
91+Two decisions make that practical. The first is the order. About 8,400
92+combinations exist (228 model-parameter sets × 37 geometries, with the seed
93+and dt pinned), which is roughly three GPU-weeks at this repo's ~180 steps/s —
94+exhaustible in principle, but only if the useful part comes first. Since a
95+visitor starts at the defaults and changes one dropdown at a time, the chance
96+that a combination is ever requested falls off steeply with the number of
97+knobs that differ from the defaults, so the walk proceeds by that distance:
98+every one-knob deviation before any two-knob one. One machine overnight covers
99+every one- and two-knob deviation from every model's defaults, which is most
100+of what anyone will ever click; the long tail can take as long as it likes.
101+
102+The second is that within a distance the order is **random**, and that is the
103+entire coordination mechanism. Several idle browsers walking the same tiers in
104+different orders, each skipping what it finds already cached, rarely duplicate
105+each other and need no coordinator, no work queue, and no knowledge of one
106+another. A skip costs one `HEAD` request, so a machine joining a
107+well-filled region catches up in seconds.
108+
109+The seed and dt are pinned rather than surveyed (seed 1, dt 0.05): a seed
110+picks a draw and means nothing on its own, and dt is a numerical knob rather
111+than a property of the problem, so surveying either would multiply the work
112+without adding a solution anyone asked for. Both are the default of every
113+model, so an auto-filled entry is exactly what a visitor arriving at the
114+defaults requests. Two smaller points: the walk skips the ellipsoid with all
115+axes 1, since that *is* the unit sphere and the sphere geometry already
116+covers it, and any run whose state goes non-finite is reported and discarded
117+rather than uploaded — an unattended walk must not publish wreckage under a
118+hash someone later trusts.
119+
120+Because it is meant to run unattended, the compute loop never waits on an
121+animation frame and skips rendering entirely while the page is hidden, so a
122+minimized window or a background tab keeps computing at full speed rather
123+than being throttled to a crawl.
124+
82125 Uploads go through the [tmpbucket](https://github.com/scratchrealm/tmpbucket)
83126 Worker: the client presents the API key and a file name, receives a presigned
84127 R2 PUT URL, and uploads directly. Only holders of the key can write; everyone
index.htmlmodified+5−0View file
@@ -143,6 +143,11 @@
143143 </label>
144144 <span id="uploadnote"></span>
145145 </div>
146+ <div class="controls" id="autobar" hidden>
147+ <button id="auto"
148+ title="Work through the parameter space on this machine, nearest the defaults first, computing and uploading whatever is not cached yet. Runs until stopped.">Auto-fill the cache</button>
149+ <span id="autonote"></span>
150+ </div>
146151 </div>
147152 <p id="err"></p>
148153 </main>
scripts/check-app.mjsmodified+1−0View file
@@ -280,6 +280,7 @@ print('h5py check ok; species', list(f.attrs['species']), '; adapter:', f.attrs.
280280 if (e4) problems.push(`model: err: ${e4}`);
281281 console.log(`pass 5: allencahn ${acPanels} panel, brusselator ${brPanels} panels`);
282282 await page4.close();
283+
283284 } catch (e) {
284285 problems.push(`fatal: ${e.message}`);
285286 } finally {
src/cache/autoWalk.tsadded+124−0View file
@@ -0,0 +1,124 @@
1+/**
2+ * The auto-fill walk: which solutions to compute on an idle machine, and in
3+ * what order.
4+ *
5+ * The whole space is about 8,000 runs — roughly three GPU-weeks — so it is
6+ * exhaustible in principle, and the question is only what to do first.
7+ * Demand for it is nothing like uniform: a visitor starts at the defaults and
8+ * changes one dropdown at a time, so the chance that a combination is ever
9+ * requested falls off steeply with the number of knobs that differ from the
10+ * defaults. The walk therefore proceeds by that distance — every one-knob
11+ * deviation before any two-knob one — which fills the region people actually
12+ * ask for within a day rather than a month, and still covers everything in
13+ * the limit.
14+ *
15+ * Within a distance the order is random, and that is the whole coordination
16+ * mechanism between machines: several idle browsers walking the same tiers in
17+ * different orders, each skipping what it finds already cached, rarely
18+ * duplicate each other's work and need no coordinator, no queue and no
19+ * knowledge of one another.
20+ *
21+ * The seed and dt are pinned rather than surveyed (see AUTO_SEED / AUTO_DT).
22+ */
23+import type { Params } from '../mgpu/registry.ts';
24+import {
25+ MODEL_CHOICES,
26+ DEFAULT_MODEL_KEY,
27+ GEOMETRY_CHOICES,
28+ AUTO_DT,
29+ type DiscreteChoice,
30+} from './options.ts';
31+import { DEFAULT_GEOMETRY_KEY } from '../geom/registry.ts';
32+
33+export interface AutoTarget {
34+ model: string;
35+ params: Params;
36+ geometry: string;
37+ geometryParams: Params;
38+ /** How many knobs differ from the app's defaults. */
39+ distance: number;
40+}
41+
42+/**
43+ * Every combination of a choice list, each with the number of entries that
44+ * differ from their default. A key present in `pinned` takes that value in
45+ * every combination and never counts toward the distance.
46+ */
47+function combos(
48+ choices: DiscreteChoice[],
49+ pinned: Params = {},
50+): { values: Params; distance: number }[] {
51+ let out = [{ values: { ...pinned }, distance: 0 }];
52+ for (const c of choices) {
53+ if (c.key in pinned) continue;
54+ const next: typeof out = [];
55+ for (const acc of out) {
56+ for (const v of c.values) {
57+ next.push({
58+ values: { ...acc.values, [c.key]: v },
59+ distance: acc.distance + (v === c.value ? 0 : 1),
60+ });
61+ }
62+ }
63+ out = next;
64+ }
65+ return out;
66+}
67+
68+/** The surfaces to survey, each with its distance from the default shape:
69+ * one for being a different geometry, one more per non-default parameter. */
70+function geometryOptions(): { geometry: string; params: Params; distance: number }[] {
71+ const out: { geometry: string; params: Params; distance: number }[] = [];
72+ for (const [key, choices] of Object.entries(GEOMETRY_CHOICES)) {
73+ for (const c of combos(choices)) {
74+ // The ellipsoid with all axes 1 *is* the unit sphere, which the sphere
75+ // geometry already covers. Computing it would fill a second hash with
76+ // the same problem, so it is left out — 228 runs saved.
77+ if (key === 'ellipsoid' && c.values.ax === 1 && c.values.ay === 1 && c.values.az === 1) {
78+ continue;
79+ }
80+ out.push({
81+ geometry: key,
82+ params: c.values,
83+ distance: (key === DEFAULT_GEOMETRY_KEY ? 0 : 1) + c.distance,
84+ });
85+ }
86+ }
87+ return out;
88+}
89+
90+/** Every solution the walk will ever compute, unordered. */
91+export function enumerateTargets(): AutoTarget[] {
92+ const geometries = geometryOptions();
93+ const out: AutoTarget[] = [];
94+ for (const [modelKey, choices] of Object.entries(MODEL_CHOICES)) {
95+ const modelDistance = modelKey === DEFAULT_MODEL_KEY ? 0 : 1;
96+ for (const p of combos(choices, { dt: AUTO_DT })) {
97+ for (const g of geometries) {
98+ out.push({
99+ model: modelKey,
100+ params: p.values,
101+ geometry: g.geometry,
102+ geometryParams: g.params,
103+ distance: modelDistance + p.distance + g.distance,
104+ });
105+ }
106+ }
107+ }
108+ return out;
109+}
110+
111+/**
112+ * The walk order: by distance, randomly within each distance. Shuffling the
113+ * whole list and then sorting by distance gives exactly that, since Array's
114+ * sort is stable — the shuffle survives as the within-distance order.
115+ */
116+export function autoOrder(rand: () => number = Math.random): AutoTarget[] {
117+ const all = enumerateTargets();
118+ for (let i = all.length - 1; i > 0; i--) {
119+ const j = Math.floor(rand() * (i + 1));
120+ [all[i], all[j]] = [all[j], all[i]];
121+ }
122+ all.sort((a, b) => a.distance - b.distance);
123+ return all;
124+}
src/cache/options.tsmodified+15−2View file
@@ -39,11 +39,11 @@ export const MODEL_CHOICES: Record<string, DiscreteChoice[]> = {
3939 { key: 'B', label: 'B', values: [7, 9, 11], value: 9 },
4040 { key: 'D1', label: 'D₁', values: [1.7e-3, 3.33e-3, 6.7e-3], value: 3.33e-3 },
4141 { key: 'D2', label: 'D₂', values: [8.3e-3, 1.67e-2, 3.3e-2], value: 1.67e-2 },
42- { key: 'dt', label: 'dt', values: [0.01, 0.02, 0.05], value: 0.02 },
42+ { key: 'dt', label: 'dt', values: [0.01, 0.02, 0.05], value: 0.05 },
4343 ],
4444 allencahn: [
4545 { key: 'eps2', label: 'ε²', values: [5e-4, 1e-3, 2e-3], value: 1e-3 },
46- { key: 'dt', label: 'dt', values: [0.01, 0.02, 0.05], value: 0.02 },
46+ { key: 'dt', label: 'dt', values: [0.01, 0.02, 0.05], value: 0.05 },
4747 ],
4848 };
4949
@@ -88,6 +88,19 @@ export const LMAX = 63;
8888 export const NITER = 8;
8989 export const LAM3 = 0.5;
9090
91+/**
92+ * What the auto-fill walk pins rather than surveys (src/cache/autoWalk.ts).
93+ * The seed picks a draw and means nothing on its own, and dt is a numerical
94+ * knob rather than a property of the problem, so surveying either would
95+ * multiply the work without adding a solution anyone asked for. Both values
96+ * are the default of every model, so an auto-filled entry is exactly what a
97+ * visitor arriving at the defaults requests. dt = 0.05 is stable for all
98+ * three models: at t = 100 Brusselator saturates to u in [0.35, 8.32] and
99+ * Allen-Cahn to the +/-1 wells, matching dt = 0.02 to two digits.
100+ */
101+export const AUTO_SEED = 1;
102+export const AUTO_DT = 0.05;
103+
91104 export const defaultChoiceParams = (choices: DiscreteChoice[]): Params =>
92105 Object.fromEntries(choices.map((c) => [c.key, c.value]));
93106
src/main.tsmodified+193−5View file
@@ -46,12 +46,15 @@ import {
4646 LMAX,
4747 NITER,
4848 LAM3,
49+ AUTO_SEED,
50+ AUTO_DT,
4951 defaultChoiceParams,
5052 fmtChoice,
5153 type DiscreteChoice,
5254 } from './cache/options.ts';
5355 import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
5456 import { lookupFor, fetchCached, uploadCacheFile, type CacheLookup } from './cache/client.ts';
57+import { autoOrder, type AutoTarget } from './cache/autoWalk.ts';
5558 import { encodeCacheFile, decodeCacheFile, type DecodedCacheFile } from './cache/h5file.ts';
5659
5760 const $ = <T extends HTMLElement>(id: string): T =>
@@ -74,6 +77,9 @@ const elDownload = $<HTMLAnchorElement>('download');
7477 const elStats = $('stats');
7578 const elApiKey = $<HTMLInputElement>('apikey');
7679 const elUploadNote = $('uploadnote');
80+const elAutoBar = $('autobar');
81+const elAuto = $<HTMLButtonElement>('auto');
82+const elAutoNote = $('autonote');
7783 const elErr = $('err');
7884
7985 /**
@@ -160,6 +166,11 @@ let busy = false;
160166 * loop before issuing reads of its own. */
161167 let pumping = false;
162168 let stopRequested = false;
169+/** Set while the auto-fill walk owns the page (see autoRun). */
170+let autoRunning = false;
171+let autoComputed = 0;
172+let autoSkipped = 0;
173+let autoFailed = 0;
163174 /** Simulation time of the state on display (loadState resets session.t). */
164175 let shownT: number | null = null;
165176 let downloadUrl: string | null = null;
@@ -237,8 +248,12 @@ function writeUrlState(): void {
237248 const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
238249
239250 function syncSelects(): void {
240- for (const b of boundSelects) {
251+ // Pruned as it goes: auto mode rebuilds the parameter controls once per
252+ // target, so entries for replaced selects would otherwise pile up.
253+ for (let i = boundSelects.length - 1; i >= 0; i--) {
254+ const b = boundSelects[i];
241255 if (b.el.isConnected) b.el.value = String(b.get());
256+ else boundSelects.splice(i, 1);
242257 }
243258 }
244259
@@ -266,8 +281,8 @@ function makeSelect(
266281 return label;
267282 }
268283
269-/** Put every selection back to its default and refresh. */
270-function resetDefaults(): void {
284+/** Put every selection back to its default, without refreshing the display. */
285+function applyDefaults(): void {
271286 model = mModelByKey(DEFAULT_MODEL_KEY)!;
272287 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
273288 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
@@ -281,9 +296,41 @@ function resetDefaults(): void {
281296 elSeed.value = String(seed);
282297 elTend.value = String(tEnd);
283298 syncSelects();
299+ writeUrlState();
300+}
301+
302+/** The Reset button: back to the defaults, and show what is there. */
303+function resetDefaults(): void {
304+ applyDefaults();
284305 onSelectionChange();
285306 }
286307
308+/** Point every control at one walk target (auto mode drives the same
309+ * selection the user otherwise would, so the URL and the dropdowns always
310+ * say what is being computed). */
311+function setSelection(t: AutoTarget): void {
312+ const nextModel = mModelByKey(t.model)!;
313+ if (nextModel !== model) {
314+ model = nextModel;
315+ elModel.value = model.key;
316+ buildModelParamControls();
317+ }
318+ params = { ...t.params };
319+ const nextGeom = mGeometryByKey(t.geometry)!;
320+ if (nextGeom !== geometry) {
321+ geometry = nextGeom;
322+ elGeometry.value = geometry.key;
323+ buildGeomParamControls();
324+ }
325+ geomParams = { ...t.geometryParams };
326+ seed = AUTO_SEED;
327+ elSeed.value = String(seed);
328+ tEnd = Math.max(...T_END_CHOICE.values);
329+ elTend.value = String(tEnd);
330+ syncSelects();
331+ writeUrlState();
332+}
333+
287334 function buildModelParamControls(): void {
288335 elParams.replaceChildren();
289336 for (const choice of MODEL_CHOICES[model.key]) {
@@ -766,6 +813,26 @@ async function solve(): Promise<void> {
766813 }
767814 }
768815
816+/**
817+ * Nothing that is not a number gets uploaded. A combination whose timestep is
818+ * too large for its reaction blows up rather than failing, and an unattended
819+ * walk would happily publish the wreckage under a hash someone later trusts.
820+ */
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+
769836 /** Run the solver to the spec's end time, watching the pattern form, and
770837 * capture the state at every smaller listed end time on the way. */
771838 async function computeLocally(spec: CacheSpec, gen: number): Promise<void> {
@@ -780,6 +847,7 @@ async function computeLocally(spec: CacheSpec, gen: number): Promise<void> {
780847
781848 async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void> {
782849 if (!session) return;
850+ lastRunDiverged = false;
783851 const steps = stepsFor(spec);
784852 const dt = spec.params.dt;
785853
@@ -919,6 +987,7 @@ async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void>
919987 if (hit !== undefined) {
920988 const state = await session.readState();
921989 if (gen !== generation) return;
990+ if (!stateIsFinite(state)) return void reportDiverged(session.steps * dt);
922991 // With a key on hand the snapshot goes straight to the cache; without
923992 // one it is kept, in case a key is entered before the run ends.
924993 const apiKey = elApiKey.value.trim();
@@ -926,7 +995,12 @@ async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void>
926995 else snapshots.push({ tEnd: hit, state });
927996 }
928997 const now = performance.now();
929- if (now - lastDraw > RENDER_EVERY_MS || session.steps >= steps) {
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)) {
9301004 lastDraw = now;
9311005 await draw();
9321006 if (gen !== generation) return;
@@ -950,6 +1024,7 @@ async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void>
9501024
9511025 const final = await session.readState();
9521026 if (gen !== generation) return;
1027+ if (!stateIsFinite(final)) return void reportDiverged(spec.tEnd);
9531028 shownT = spec.tEnd;
9541029 await draw();
9551030 updateStats();
@@ -993,18 +1068,127 @@ async function computeLocallyInner(spec: CacheSpec, gen: number): Promise<void>
9931068 }
9941069 }
9951070
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> {
1076+ try {
1077+ const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
1078+ return res.ok;
1079+ } catch {
1080+ // A network hiccup is not evidence of absence, but computing anyway only
1081+ // costs time and ends in an upload that overwrites an identical object.
1082+ return false;
1083+ }
1084+}
1085+
1086+function autoNote(target: AutoTarget | null): void {
1087+ if (!autoRunning) {
1088+ elAutoNote.textContent = autoComputed || autoSkipped
1089+ ? `stopped — computed ${autoComputed}, skipped ${autoSkipped} already cached` +
1090+ (autoFailed ? `, ${autoFailed} failed` : '')
1091+ : '';
1092+ return;
1093+ }
1094+ const where = target
1095+ ? `${mModelByKey(target.model)!.label} on ${target.geometry}, ${target.distance} ` +
1096+ `knob${target.distance === 1 ? '' : 's'} from the defaults`
1097+ : '';
1098+ elAutoNote.textContent =
1099+ `auto-filling — computed ${autoComputed}, skipped ${autoSkipped}` +
1100+ (autoFailed ? `, ${autoFailed} failed` : '') + (where ? ` · ${where}` : '');
1101+}
1102+
1103+function setAutoUi(on: boolean): void {
1104+ elAuto.textContent = on ? 'Auto-filling…' : 'Auto-fill the cache';
1105+ elAuto.disabled = on;
1106+ elReset.disabled = on;
1107+}
1108+
1109+/**
1110+ * Walk the parameter space on this machine, computing and contributing
1111+ * whatever is not cached yet, nearest the defaults first and randomly within
1112+ * a distance (src/cache/autoWalk.ts). Runs until stopped.
1113+ *
1114+ * Every target is driven through the same selection the user would set by
1115+ * hand, so the dropdowns and the URL always say what is being computed, and
1116+ * the run itself is the ordinary local computation — including its
1117+ * background uploads, its warm start from a shorter cached run, and its
1118+ * divergence guard.
1119+ */
1120+async function autoRun(): Promise<void> {
1121+ if (!device || busy || autoRunning) return;
1122+ if (!elApiKey.value.trim()) return;
1123+ autoRunning = true;
1124+ autoComputed = autoSkipped = autoFailed = 0;
1125+ setAutoUi(true);
1126+ setBusy(true);
1127+ elErr.textContent = '';
1128+ // Start from a defined point — which is also the first target, since the
1129+ // defaults are the one combination at distance zero.
1130+ applyDefaults();
1131+ const targets = autoOrder();
1132+ autoNote(null);
1133+
1134+ for (const target of targets) {
1135+ if (!autoRunning) break;
1136+ setSelection(target);
1137+ autoNote(target);
1138+ generation++;
1139+ const gen = generation;
1140+ stopRequested = false;
1141+ const spec = currentSpec();
1142+ try {
1143+ const lookup = await lookupFor(spec);
1144+ status(`checking the cloud cache…`);
1145+ if (await isCached(lookup)) {
1146+ autoSkipped++;
1147+ setCacheNote(true);
1148+ continue;
1149+ }
1150+ if (!autoRunning) break;
1151+ setCacheNote(false);
1152+ await applySelection(spec);
1153+ if (gen !== generation) break;
1154+ await computeLocally(spec, gen);
1155+ if (gen !== generation) break;
1156+ if (lastRunDiverged) autoFailed++;
1157+ else if (!stopRequested) autoComputed++;
1158+ } catch (e) {
1159+ // One bad combination must not end the walk: report it and move on.
1160+ autoFailed++;
1161+ elErr.textContent = `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
1162+ }
1163+ }
1164+
1165+ autoRunning = false;
1166+ setAutoUi(false);
1167+ setBusy(false);
1168+ autoNote(null);
1169+}
1170+
9961171 // ---------------------------------------------------------------- boot
1172+elAuto.addEventListener('click', () => {
1173+ flowChain = flowChain.then(() => autoRun()).catch(() => undefined);
1174+});
9971175 elSolve.addEventListener('click', () => {
9981176 flowChain = flowChain.then(() => solve()).catch(() => undefined);
9991177 });
10001178 elStop.addEventListener('click', () => {
10011179 stopRequested = true;
1180+ autoRunning = false;
10021181 setBusy(false);
10031182 });
10041183 elReset.addEventListener('click', () => resetDefaults());
10051184 elResetView.addEventListener('click', () => {
10061185 for (const s of scenes) s.resetCamera();
10071186 });
1187+// The view is not drawn while the page is hidden, so it is stale on return.
1188+// Not while a run is reading back: every read shares one staging buffer.
1189+document.addEventListener('visibilitychange', () => {
1190+ if (!document.hidden && session && !pumping) void draw();
1191+});
10081192 elApiKey.addEventListener('change', () => {
10091193 const key = elApiKey.value.trim();
10101194 if (key) localStorage.setItem(API_KEY_STORAGE, key);
@@ -1013,9 +1197,13 @@ elApiKey.addEventListener('change', () => {
10131197 });
10141198
10151199 function updateUploadNote(): void {
1016- elUploadNote.textContent = elApiKey.value.trim()
1200+ const hasKey = elApiKey.value.trim().length > 0;
1201+ elUploadNote.textContent = hasKey
10171202 ? 'uploads enabled — locally computed solutions will be contributed'
10181203 : '';
1204+ // Auto-fill exists to contribute, so it is offered only to those who can.
1205+ elAutoBar.hidden = !hasKey;
1206+ if (!hasKey && autoRunning) autoRunning = false;
10191207 }
10201208
10211209 async function boot(): Promise<void> {