/ concept-collection / turing-surface-cache
Sign in
concept-collection / turing-surface-cache
turing-surface-cache / src / cache / selection.ts
223 lines · 8.0 KBBlameHistoryRaw
1/**
2 * The selection — one value chosen from every discrete list — and its URL
3 * form.
4 *
5 * The main page keeps its whole state in the URL fragment, every value
6 * written explicitly, so a link keeps meaning the same spec even if a default
7 * changes later. The sweep page carries the same fragment plus one extra
8 * entry (`sweep=<param>`, which model parameter the knob runs over), and the
9 * command line's `sweep <url>` accepts that page's URL as its argument. Three
10 * readers of one serialization is the reason it lives here rather than in any
11 * of them.
12 *
13 * Values are only accepted if they are exactly entries of the discrete lists
14 * (src/cache/options.ts); anything else keeps the default. That is what makes
15 * a fragment safe to hand to the cache: nothing typed or mistyped can name a
16 * spec that the dropdowns could not.
17 */
18import type { Params } from '../mgpu/registry.ts';
19import { DEFAULT_GEOMETRY_KEY } from '../geom/registry.ts';
20import {
21 DEFAULT_MODEL_KEY,
22 GEOMETRY_CHOICES,
23 LAM3,
24 LMAX,
25 MODEL_CHOICES,
26 NITER,
27 SEED_CHOICE,
28 T_END_CHOICE,
29 defaultChoiceParams,
30 fmtChoice,
31 type DiscreteChoice,
32} from './options.ts';
33import { APP_NAME, FORMAT_VERSION, type CacheSpec } from './spec.ts';
35export interface Selection {
36 model: string;
37 params: Params;
38 geometry: string;
39 geometryParams: Params;
40 seed: number;
41 tEnd: number;
44export function defaultSelection(): Selection {
45 return {
46 model: DEFAULT_MODEL_KEY,
47 params: defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]),
48 geometry: DEFAULT_GEOMETRY_KEY,
49 geometryParams: defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]),
50 seed: SEED_CHOICE.value,
51 tEnd: T_END_CHOICE.value,
52 };
55/** The fragment form: `model=…&a=…&…&geometry=…&…&seed=…&tend=…`. The keys
56 * are the choices' own, except tEnd, which the URL spells `tend`. */
57export function selectionToParams(sel: Selection): URLSearchParams {
58 const p = new URLSearchParams();
59 p.set('model', sel.model);
60 for (const c of MODEL_CHOICES[sel.model]) p.set(c.key, fmtChoice(sel.params[c.key]));
61 p.set('geometry', sel.geometry);
62 for (const c of GEOMETRY_CHOICES[sel.geometry]) {
63 p.set(c.key, fmtChoice(sel.geometryParams[c.key]));
64 }
65 p.set('seed', String(sel.seed));
66 p.set('tend', fmtChoice(sel.tEnd));
67 return p;
70/**
71 * A fragment string from those parameters. URLSearchParams percent-encodes
72 * commas, which turns a sweep's value list into `0.7%2C0.9%2C1.1` — readable
73 * to a parser and to nobody else. A fragment is allowed to carry commas
74 * literally (RFC 3986 counts them among the sub-delims), and the parser
75 * reads an unencoded comma back as the same character, so they are put back.
76 */
77export const fragmentFor = (p: URLSearchParams): string =>
78 p.toString().replace(/%2C/g, ',');
80/** Read a selection back from a fragment, defaults standing in for anything
81 * absent or not exactly a listed value. */
82export function readSelection(p: URLSearchParams): Selection {
83 const sel = defaultSelection();
84 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
85 const raw = p.get(name);
86 if (raw === null) return current;
87 const v = Number(raw);
88 return choice.values.includes(v) ? v : current;
89 };
90 const m = p.get('model');
91 if (m && MODEL_CHOICES[m]) {
92 sel.model = m;
93 sel.params = defaultChoiceParams(MODEL_CHOICES[m]);
94 }
95 const g = p.get('geometry');
96 if (g && GEOMETRY_CHOICES[g]) {
97 sel.geometry = g;
98 sel.geometryParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
99 }
100 for (const c of MODEL_CHOICES[sel.model]) sel.params[c.key] = pick(c, sel.params[c.key]);
101 for (const c of GEOMETRY_CHOICES[sel.geometry]) {
102 sel.geometryParams[c.key] = pick(c, sel.geometryParams[c.key]);
103 }
104 sel.seed = pick(SEED_CHOICE, sel.seed);
105 sel.tEnd = pick(T_END_CHOICE, sel.tEnd, 'tend');
106 return sel;
109/** The one solution a selection names. */
110export function specForSelection(sel: Selection): CacheSpec {
111 return {
112 app: APP_NAME,
113 formatVersion: FORMAT_VERSION,
114 model: sel.model,
115 params: { ...sel.params },
116 geometry: sel.geometry,
117 geometryParams: { ...sel.geometryParams },
118 lmax: LMAX,
119 niter: NITER,
120 lam3: LAM3,
121 seed: sel.seed,
122 tEnd: sel.tEnd,
123 };
126// ---------------------------------------------------------------- sweeps
127/**
128 * A sweep: the same selection, with one model parameter designated as the
129 * swept one and a list of values for it. The list defaults to the
130 * parameter's own choices but may be an explicit list the user typed, which
131 * is the one place the app steps outside its dropdown lists. That is safe
132 * for the cache, since a typed value is parsed to a number once and
133 * serialized in canonical shortest form ever after (src/cache/spec.ts), so
134 * that it names one spec as reliably as a listed value does. It merely names
135 * one the main page's dropdowns cannot reach. The selection's own value for
136 * the swept parameter is the knob's current position, so a shared sweep link
137 * opens at the same place.
138 */
139export interface SweepSelection {
140 sel: Selection;
141 /** Which of the model's parameters the knob runs over. */
142 key: string;
143 /** The values it runs over, in knob order. */
144 values: number[];
147/** The swept parameter's underlying choice (its label and default list). */
148export function sweepChoice(sweep: { sel: Selection; key: string }): DiscreteChoice {
149 const choice = MODEL_CHOICES[sweep.sel.model].find((c) => c.key === sweep.key);
150 if (!choice) {
151 throw new Error(`${sweep.sel.model} has no parameter '${sweep.key}'`);
152 }
153 return choice;
156/**
157 * An explicit value list, as typed: numbers separated by commas or spaces.
158 * Anything that is not a finite number is dropped and duplicates collapse,
159 * but the order is kept as given, an explicit list being taken at its word.
160 */
161export function parseValueList(text: string): number[] {
162 return [
163 ...new Set(
164 text
165 .split(/[,\s]+/)
166 .filter((s) => s.length)
167 .map(Number)
168 .filter((v) => Number.isFinite(v)),
169 ),
170 ];
173/** The sweep page's fragment: the selection plus which parameter sweeps and
174 * the values it runs over, every value written explicitly. */
175export function sweepToParams(sweep: SweepSelection): URLSearchParams {
176 const p = selectionToParams(sweep.sel);
177 // The swept parameter's own entry is the knob position, which for a custom
178 // list may be a value selectionToParams could not have written.
179 p.set(sweep.key, fmtChoice(sweep.sel.params[sweep.key]));
180 p.set('sweep', sweep.key);
181 p.set('values', sweep.values.map(fmtChoice).join(','));
182 return p;
185/**
186 * Read a sweep from a fragment. Null when the fragment names no swept
187 * parameter (or one the model does not have): the page falls back to its
188 * default, the command line says the URL is not a sweep link. A missing or
189 * empty `values` entry means the parameter's own list.
190 */
191export function readSweep(p: URLSearchParams): SweepSelection | null {
192 const sel = readSelection(p);
193 const key = p.get('sweep');
194 if (!key || !MODEL_CHOICES[sel.model].some((c) => c.key === key)) return null;
195 const sweep: SweepSelection = { sel, key, values: [] };
196 const listed = p.get('values');
197 const parsed = listed === null ? [] : parseValueList(listed);
198 sweep.values = parsed.length ? parsed : [...sweepChoice(sweep).values];
199 // The knob position: readSelection validated the swept entry against the
200 // dropdown list, which a custom value is deliberately not on, so it is
201 // read again against the sweep's own list.
202 const raw = p.get(key);
203 const v = raw === null ? NaN : Number(raw);
204 sel.params[key] = sweep.values.includes(v)
205 ? v
206 : sweep.values.includes(sel.params[key])
207 ? sel.params[key]
208 : sweep.values[0];
209 return sweep;
212/** The sweep's solutions, one per value, in knob order. */
213export function specsForSweep(
214 sweep: SweepSelection,
215): { value: number; spec: CacheSpec }[] {
216 return sweep.values.map((value) => ({
217 value,
218 spec: specForSelection({
219 ...sweep.sel,
220 params: { ...sweep.sel.params, [sweep.key]: value },
221 }),
222 }));
moveopenescclose