web: sim store, URL state scheme, debounced parallel runs (M3 slice 2)

useSimStore is the one reactive module singleton from the spec: panels
over a shared base config, runs debounced 400 ms, one POST /api/scenario
per panel in parallel, results swapped in atomically only when every
request succeeds. Failures keep the last good results; engine 400s are
routed to an inline validationError, everything else to the banner
message. Stale in-flight responses lose to newer runs per panel.

URL state follows spec section 7 (readable params, repeated panel=,
focus index, full fallback to the preset on any malformed part) and is
covered by round-trip and rejection tests. The study copy and initial
panels live in the deepfake-school preset module; formatPct is the
single percent-rounding rule.

The vitest tsconfig now keeps lib at ES2022 like the app config, and
prettier skips the tygo-generated src/types (drift guard stays green).
This commit is contained in:
Justin Visser 2026-06-10 16:36:26 +02:00
parent 2feb6ea5a3
commit 2f21248c20
13 changed files with 1003 additions and 4 deletions

View file

@ -0,0 +1,49 @@
import { StrategyMostConnected, StrategyNone, StrategyRandom } from '@/types/engine'
import type { StudyPreset } from './types'
// The one study this deployment tells. A different research question is a
// new preset file plus a changed import in the app; no component changes.
export const deepfakeSchoolPreset: StudyPreset = {
headline: 'How a nonconsensual deepfake spreads through a school',
narrative:
'One simulated year group, 120 students. Educate the same 30% of them, ' +
'but change who: the fake reaches {0} of the school with no program, ' +
'{1} educating at random, and {2} educating the bestconnected students.',
disclaimerShort: 'Illustrative model, not validated',
disclaimerLong:
'spreadlab runs a seeded agent-based toy simulation: a synthetic ' +
'friendship network of one school year group, a fixed chance to forward ' +
'the fake along each friendship per round, and an education program that ' +
'teaches some students to refuse. Its parameters are chosen for ' +
'illustration, not fitted to data, so it is not a validated prediction ' +
'of any real school. Use it to build intuition about who to educate, ' +
'not to forecast outcomes.',
toneThresholdPct: 30,
base: {
numStudents: 120,
edgesPerNode: 3,
triangleProb: 0.45,
forwardProb: 0.38,
numEducated: 36,
origin: 0,
graphSeed: 17,
thresholdSeed: 2,
educationSeed: 1,
},
panels: [
{ id: 'preset-no-program', label: 'No program', strategy: StrategyNone, overrides: {} },
{
id: 'preset-random',
label: 'Educate 30% at random',
strategy: StrategyRandom,
overrides: {},
},
{
id: 'preset-most-connected',
label: 'Educate best-connected 30%',
strategy: StrategyMostConnected,
overrides: {},
},
],
}

26
web/src/presets/types.ts Normal file
View file

@ -0,0 +1,26 @@
import type { Config, Strategy } from '@/types/engine'
// Frontend-only presentation types (spec section 1). They describe study
// copy and the initial scenario set, not simulation data: Config, Result
// and Strategy stay imported from the generated types.
export interface PanelSpec {
id: string // stable client id
label: string // user editable
strategy: Strategy
overrides: Partial<Config> // sparse diff against the base config
}
export interface StudyPreset {
headline: string // hero h1
// Hero paragraph; {0} {1} {2}... placeholders are replaced by each
// panel's reached%, formatted by the shared formatPct.
narrative: string
disclaimerShort: string // badge text
disclaimerLong: string // About popover body
toneThresholdPct: number // reached% <= threshold renders "good" (teal)
base: Config // the default world
panels: PanelSpec[] // initial panels
}
export const MAX_PANELS = 6