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

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
# binary from `go build ./cmd/spreadlab` # binary from `go build ./cmd/spreadlab`
/spreadlab /spreadlab
# prettier cache (npm run format in web/ walks up here)
/node_modules/

2
web/.prettierignore Normal file
View file

@ -0,0 +1,2 @@
# Generated by tygo from the Go structs; must stay byte-identical to its output (CI drift guard).
src/types/

View file

@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createSimStore, RUN_DEBOUNCE_MS } from '../useSimStore'
import { ApiError, runScenario } from '@/lib/api'
import type { ScenarioRequest, ScenarioResponse } from '@/types/api'
vi.mock('@/lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/api')>()
return { ...actual, runScenario: vi.fn<typeof actual.runScenario>() }
})
const runScenarioMock = vi.mocked(runScenario)
// The fake engine: numReached mirrors numEducated so tests can tell which
// config produced a result, and the no-program panel runs the longest.
function fakeResponse(request: ScenarioRequest): ScenarioResponse {
const { numStudents, numEducated } = request.config
return {
config: request.config,
result: {
strategy: request.strategy,
educated: [],
reachedAtRound: Array.from({ length: numStudents }, (_, node) => (node < 3 ? node : -1)),
numReached: numEducated,
numRounds: request.strategy === 'none' ? 9 : 3,
reachedPct: (numEducated / numStudents) * 100,
},
edges: [
[0, 1],
[1, 2],
],
}
}
// Settles promise chains (Promise.allSettled and friends) without real time.
async function flushAsync() {
for (let i = 0; i < 8; i++) await Promise.resolve()
}
beforeEach(() => {
vi.useFakeTimers()
runScenarioMock.mockReset()
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
window.history.replaceState(null, '', '/')
})
afterEach(() => {
vi.useRealTimers()
})
describe('useSimStore initialization', () => {
it('runs every preset panel in parallel and swaps results in', async () => {
const store = createSimStore()
await store.initialize('')
expect(runScenarioMock).toHaveBeenCalledTimes(3)
expect(store.state.panels).toHaveLength(3)
for (const panel of store.state.panels) {
expect(store.state.resultsByPanelId[panel.id]).toBeDefined()
}
expect(store.state.runState).toBe('idle')
expect(store.state.round).toBe(9) // the global max across panels
// All preset panels share the base graph: one cached edge list.
expect(Object.keys(store.state.edgesByGraphHash)).toHaveLength(1)
})
it('applies state from the opened URL', async () => {
const store = createSimStore()
await store.initialize('?forwardProb=0.5&panel=Custom~random~numEducated:60')
expect(store.state.base.forwardProb).toBe(0.5)
expect(store.state.panels).toHaveLength(1)
expect(store.state.panels[0]!.label).toBe('Custom')
expect(store.state.panels[0]!.overrides).toEqual({ numEducated: 60 })
const request = runScenarioMock.mock.calls[0]![0]
expect(request.config.forwardProb).toBe(0.5)
expect(request.config.numEducated).toBe(60)
expect(request.strategy).toBe('random')
})
it('falls back to the preset on a malformed URL and flags it', async () => {
const store = createSimStore()
await store.initialize('?panel=Bad~telepathy~')
expect(store.state.urlStateInvalid).toBe(true)
expect(store.state.panels).toHaveLength(3)
expect(store.state.runState).toBe('idle')
})
})
describe('useSimStore runs', () => {
it('debounces base changes and reruns every panel with the final value', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockClear()
store.setBaseField('forwardProb', 0.5)
store.setBaseField('forwardProb', 0.6)
expect(runScenarioMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(runScenarioMock).toHaveBeenCalledTimes(3)
for (const call of runScenarioMock.mock.calls) {
expect(call[0].config.forwardProb).toBe(0.6)
}
expect(store.state.runState).toBe('idle')
expect(window.location.search).toBe('?forwardProb=0.6')
})
it('reruns only the edited panel', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockClear()
const secondPanel = store.state.panels[1]!
store.setPanelOverride(secondPanel.id, 'numEducated', 60)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(runScenarioMock).toHaveBeenCalledTimes(1)
expect(runScenarioMock.mock.calls[0]![0].config.numEducated).toBe(60)
expect(store.state.resultsByPanelId[secondPanel.id]!.numReached).toBe(60)
})
it('widens a pending single-panel rerun when a base change follows', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockClear()
store.setPanelOverride(store.state.panels[1]!.id, 'numEducated', 60)
store.setBaseField('forwardProb', 0.6)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(runScenarioMock).toHaveBeenCalledTimes(3)
})
it('keeps the last good results on a network failure and recovers on retry', async () => {
const store = createSimStore()
await store.initialize('')
const goodResults = { ...store.state.resultsByPanelId }
runScenarioMock.mockImplementation(async () => {
throw new TypeError('fetch failed')
})
store.setBaseField('forwardProb', 0.6)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(store.state.runState).toBe('error')
expect(store.state.errorMessage).toBe('fetch failed')
expect(store.state.validationError).toBeNull()
expect(store.state.resultsByPanelId).toEqual(goodResults)
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
await store.retry()
expect(store.state.runState).toBe('idle')
expect(store.state.errorMessage).toBeNull()
})
it('routes a 400 to validationError instead of the banner', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockImplementation(async () => {
throw new ApiError(400, 'forwardProb must be between 0 and 1')
})
store.setBaseField('forwardProb', 7)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(store.state.validationError).toBe('forwardProb must be between 0 and 1')
expect(store.state.errorMessage).toBeNull()
expect(store.state.runState).toBe('error')
})
it('drops a stale response when a newer run supersedes it', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockClear()
const secondPanel = store.state.panels[1]!
let resolveSlowRun!: (response: ScenarioResponse) => void
let slowRequest!: ScenarioRequest
runScenarioMock.mockImplementationOnce((request) => {
slowRequest = request
return new Promise((resolve) => {
resolveSlowRun = resolve
})
})
store.setPanelOverride(secondPanel.id, 'numEducated', 60)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) // slow run in flight
store.setPanelOverride(secondPanel.id, 'numEducated', 70)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(store.state.resultsByPanelId[secondPanel.id]!.numReached).toBe(70)
resolveSlowRun(fakeResponse(slowRequest)) // lands late, must be ignored
await flushAsync()
expect(store.state.resultsByPanelId[secondPanel.id]!.numReached).toBe(70)
})
})
describe('useSimStore panel management', () => {
it('adds panels up to the cap of six and runs each new one', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockClear()
const added = store.addPanel()
expect(added).not.toBeNull()
expect(added!.label).toBe('Scenario 4')
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
await flushAsync()
expect(runScenarioMock).toHaveBeenCalledTimes(1)
expect(store.state.resultsByPanelId[added!.id]).toBeDefined()
expect(store.addPanel()).not.toBeNull()
expect(store.addPanel()).not.toBeNull()
expect(store.state.panels).toHaveLength(6)
expect(store.addPanel()).toBeNull()
})
it('seeds a duplicate from the source result without a rerun', async () => {
const store = createSimStore()
await store.initialize('')
runScenarioMock.mockClear()
const sourcePanel = store.state.panels[0]!
const copy = store.duplicatePanel(sourcePanel.id)
expect(copy).not.toBeNull()
expect(copy!.label).toBe('No program copy')
expect(store.state.resultsByPanelId[copy!.id]).toBe(
store.state.resultsByPanelId[sourcePanel.id],
)
await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS)
expect(runScenarioMock).not.toHaveBeenCalled()
})
it('removes a panel with its result but never the last one', async () => {
const store = createSimStore()
await store.initialize('')
const [first, second, third] = store.state.panels
store.removePanel(first!.id)
store.removePanel(second!.id)
expect(store.state.panels).toHaveLength(1)
expect(store.state.resultsByPanelId[first!.id]).toBeUndefined()
store.removePanel(third!.id)
expect(store.state.panels).toHaveLength(1) // the last panel stays
})
it('reflects focus in the URL and clears it when the panel goes away', async () => {
const store = createSimStore()
await store.initialize('')
const secondPanel = store.state.panels[1]!
store.setFocusPanel(secondPanel.id)
expect(window.location.search).toBe('?focus=1')
store.removePanel(secondPanel.id)
expect(store.state.focusPanelId).toBeNull()
expect(window.location.search).not.toContain('focus=')
})
})

View file

@ -0,0 +1,298 @@
import { reactive } from 'vue'
import { ApiError, runScenario } from '@/lib/api'
import { graphKey } from '@/lib/graph'
import { parseUrlState, serializeUrlState } from '@/lib/urlState'
import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types'
import type { Config, Result, Strategy } from '@/types/engine'
import { StrategyNone } from '@/types/engine'
// The one store (spec section 4): a plain reactive module singleton, no
// Pinia. All scenario runs flow through here: input changes mutate state
// immediately, runs are debounced 400 ms, requests go out in parallel, and
// results swap in atomically only when every request succeeded. On failure
// the last good results stay on screen.
export const RUN_DEBOUNCE_MS = 400
export type RunState = 'idle' | 'running' | 'error'
export type PlaybackSpeed = 0.5 | 1 | 2
export interface SimState {
base: Config
panels: PanelSpec[]
resultsByPanelId: Record<string, Result>
edgesByGraphHash: Record<string, number[][]>
round: number
playing: boolean
speed: PlaybackSpeed
focusPanelId: string | null
hoveredNode: number | null
runState: RunState
errorMessage: string | null // network/server failure, shown in the ErrorBanner
validationError: string | null // 400 from the engine, shown inline under the controls
urlStateInvalid: boolean // the opened link held malformed state; preset shown instead
}
function createPanelId(): string {
return crypto.randomUUID()
}
function clonePanel(panel: PanelSpec): PanelSpec {
return { ...panel, overrides: { ...panel.overrides } }
}
export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
const state: SimState = reactive({
base: { ...preset.base },
panels: preset.panels.map(clonePanel),
resultsByPanelId: {},
edgesByGraphHash: {},
round: 0,
playing: false,
speed: 1,
focusPanelId: null,
hoveredNode: null,
runState: 'idle',
errorMessage: null,
validationError: null,
urlStateInvalid: false,
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// null accumulates to "all panels"; a set collects single-panel reruns.
let pendingPanelIds: Set<string> | null = new Set()
let runCounter = 0
// A panel's result may only be written by the latest run that requested
// it; older in-flight responses for the same panel are dropped.
const latestRunIdByPanelId = new Map<string, number>()
function effectiveConfig(panel: PanelSpec): Config {
return { ...state.base, ...panel.overrides }
}
function panelById(panelId: string): PanelSpec | undefined {
return state.panels.find((panel) => panel.id === panelId)
}
function maxRounds(): number {
return state.panels.reduce((roundsSoFar, panel) => {
const result = state.resultsByPanelId[panel.id]
return result ? Math.max(roundsSoFar, result.numRounds) : roundsSoFar
}, 0)
}
function scheduleRun(panelId?: string) {
if (panelId === undefined) {
pendingPanelIds = null
} else if (pendingPanelIds !== null) {
pendingPanelIds.add(panelId)
}
if (debounceTimer !== null) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
const panelIds = pendingPanelIds
pendingPanelIds = new Set()
void runPanels(panelIds)
}, RUN_DEBOUNCE_MS)
}
async function runPanels(panelIds: Set<string> | null): Promise<void> {
const targets =
panelIds === null ? [...state.panels] : state.panels.filter((panel) => panelIds.has(panel.id))
if (targets.length === 0) return
const runId = ++runCounter
for (const panel of targets) latestRunIdByPanelId.set(panel.id, runId)
state.runState = 'running'
const settled = await Promise.allSettled(
targets.map((panel) =>
runScenario({ config: effectiveConfig(panel), strategy: panel.strategy }),
),
)
const isLatestRun = runId === runCounter
const firstFailure = settled.find(
(outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected',
)
if (firstFailure) {
// Keep the last good results untouched; only the latest run may
// surface its error.
if (!isLatestRun) return
const reason: unknown = firstFailure.reason
state.runState = 'error'
if (reason instanceof ApiError && reason.status === 400) {
state.validationError = reason.message
state.errorMessage = null
} else {
state.errorMessage = reason instanceof Error ? reason.message : String(reason)
state.validationError = null
}
return
}
targets.forEach((panel, index) => {
if (latestRunIdByPanelId.get(panel.id) !== runId) return
const outcome = settled[index]
if (outcome?.status !== 'fulfilled') return
state.resultsByPanelId[panel.id] = outcome.value.result
state.edgesByGraphHash[graphKey(outcome.value.config)] = outcome.value.edges
})
if (!isLatestRun) return
state.runState = 'idle'
state.errorMessage = null
state.validationError = null
state.round = maxRounds()
syncToUrl()
}
function setBaseField<FieldName extends keyof Config>(
field: FieldName,
value: Config[FieldName],
) {
state.base[field] = value
scheduleRun()
}
function addPanel(): PanelSpec | null {
if (state.panels.length >= MAX_PANELS) return null
const panel: PanelSpec = {
id: createPanelId(),
label: `Scenario ${state.panels.length + 1}`,
strategy: StrategyNone,
overrides: {},
}
state.panels.push(panel)
scheduleRun(panel.id)
return panel
}
function duplicatePanel(panelId: string): PanelSpec | null {
if (state.panels.length >= MAX_PANELS) return null
const source = panelById(panelId)
if (source === undefined) return null
const copy: PanelSpec = {
...clonePanel(source),
id: createPanelId(),
label: `${source.label} copy`,
}
state.panels.push(copy)
const sourceResult = state.resultsByPanelId[source.id]
if (sourceResult) {
// Identical config, identical result: seed the copy instead of
// re-running the same scenario.
state.resultsByPanelId[copy.id] = sourceResult
syncToUrl()
} else {
scheduleRun(copy.id)
}
return copy
}
function removePanel(panelId: string) {
if (state.panels.length <= 1) return // the last panel cannot be removed
const index = state.panels.findIndex((panel) => panel.id === panelId)
if (index < 0) return
state.panels.splice(index, 1)
delete state.resultsByPanelId[panelId]
if (state.focusPanelId === panelId) state.focusPanelId = null
syncToUrl()
}
function renamePanel(panelId: string, label: string) {
const panel = panelById(panelId)
if (panel === undefined) return
panel.label = label
syncToUrl()
}
function setPanelStrategy(panelId: string, strategy: Strategy) {
const panel = panelById(panelId)
if (panel === undefined || panel.strategy === strategy) return
panel.strategy = strategy
scheduleRun(panelId)
}
function setPanelOverride<FieldName extends keyof Config>(
panelId: string,
field: FieldName,
value: Config[FieldName],
) {
const panel = panelById(panelId)
if (panel === undefined) return
if (value === state.base[field]) {
// Typing the base value back is "no override" (spec 5.7).
delete panel.overrides[field]
} else {
panel.overrides[field] = value
}
scheduleRun(panelId)
}
function clearPanelOverride(panelId: string, field: keyof Config) {
const panel = panelById(panelId)
if (panel === undefined || !(field in panel.overrides)) return
delete panel.overrides[field]
scheduleRun(panelId)
}
function setFocusPanel(panelId: string | null) {
state.focusPanelId = panelId
syncToUrl()
}
function syncToUrl() {
if (typeof window === 'undefined') return
const query = serializeUrlState(state.base, state.panels, state.focusPanelId, preset)
window.history.replaceState(null, '', `${window.location.pathname}${query}`)
}
// Parse the opened URL (or fall back to the preset) and run every panel.
// The initial run is not debounced: nothing is on screen yet.
function initialize(
search: string = typeof window === 'undefined' ? '' : window.location.search,
): Promise<void> {
const parsed = parseUrlState(search, preset, createPanelId)
if (parsed === null) {
state.urlStateInvalid = true // preset defaults are already loaded
} else {
state.base = parsed.base
state.panels = parsed.panels
state.focusPanelId =
parsed.focusIndex !== null ? (parsed.panels[parsed.focusIndex]?.id ?? null) : null
}
return runPanels(null)
}
function retry(): Promise<void> {
return runPanels(null)
}
return {
state,
preset,
effectiveConfig,
maxRounds,
initialize,
retry,
setBaseField,
addPanel,
duplicatePanel,
removePanel,
renamePanel,
setPanelStrategy,
setPanelOverride,
clearPanelOverride,
setFocusPanel,
}
}
export type SimStore = ReturnType<typeof createSimStore>
let activeStore: SimStore | null = null
export function useSimStore(): SimStore {
activeStore ??= createSimStore()
return activeStore
}

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { formatPct, roundPct } from '../format'
describe('formatPct', () => {
it('rounds 99/120 to 83%, the class of mismatch Go printf produced', () => {
const reachedPct = (99 / 120) * 100 // 82.5; Go's %.0f said 82
expect(formatPct(reachedPct)).toBe('83%')
expect(roundPct(reachedPct)).toBe(83)
})
it('rounds down below the midpoint', () => {
expect(formatPct((7 / 120) * 100)).toBe('6%')
})
it('keeps whole numbers untouched', () => {
expect(formatPct(0)).toBe('0%')
expect(formatPct(100)).toBe('100%')
})
})

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { parseUrlState, serializeUrlState } from '../urlState'
import type { PanelSpec, StudyPreset } from '@/presets/types'
// A small hand-made preset so these tests do not move when the study
// preset's copy or defaults change.
const preset: StudyPreset = {
headline: 'h',
narrative: 'n',
disclaimerShort: 'short',
disclaimerLong: 'long',
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-a', label: 'No program', strategy: 'none', overrides: {} },
{ id: 'preset-b', label: 'Educate at random', strategy: 'random', overrides: {} },
],
}
function idFactory(): () => string {
let nextId = 0
return () => `test-id-${nextId++}`
}
describe('serializeUrlState', () => {
it('serializes the default state to an empty string', () => {
expect(serializeUrlState(preset.base, preset.panels, null, preset)).toBe('')
})
it('includes only base fields that differ from the preset', () => {
const base = { ...preset.base, forwardProb: 0.5, graphSeed: 99 }
expect(serializeUrlState(base, preset.panels, null, preset)).toBe(
'?forwardProb=0.5&graphSeed=99',
)
})
it('emits one encoded panel param per panel once the set differs', () => {
const panels: PanelSpec[] = [
{ id: 'a', label: 'No program', strategy: 'none', overrides: {} },
{ id: 'b', label: 'Big budget', strategy: 'random', overrides: { numEducated: 60 } },
]
expect(serializeUrlState(preset.base, panels, null, preset)).toBe(
'?panel=No%20program~none~&panel=Big%20budget~random~numEducated:60',
)
})
it('serializes focus as the panel index', () => {
expect(serializeUrlState(preset.base, preset.panels, 'preset-b', preset)).toBe('?focus=1')
})
})
describe('parseUrlState', () => {
it('returns the preset with fresh panel ids when the URL is empty', () => {
const parsed = parseUrlState('', preset, idFactory())
expect(parsed).not.toBeNull()
expect(parsed!.base).toEqual(preset.base)
expect(parsed!.panels.map((panel) => panel.label)).toEqual(['No program', 'Educate at random'])
expect(parsed!.panels.map((panel) => panel.id)).toEqual(['test-id-0', 'test-id-1'])
expect(parsed!.focusIndex).toBeNull()
})
it('ignores unknown params', () => {
const parsed = parseUrlState('?utm_source=x&flag', preset, idFactory())
expect(parsed).not.toBeNull()
expect(parsed!.base).toEqual(preset.base)
})
it('round-trips a customized state', () => {
const base = { ...preset.base, forwardProb: 0.5 }
const panels: PanelSpec[] = [
{ id: 'a', label: 'Wave ~ one, two: go', strategy: 'most-connected', overrides: {} },
{
id: 'b',
label: 'Big budget',
strategy: 'random',
overrides: { numEducated: 60, graphSeed: 7 },
},
]
const query = serializeUrlState(base, panels, 'b', preset)
const parsed = parseUrlState(query, preset, idFactory())
expect(parsed).not.toBeNull()
expect(parsed!.base).toEqual(base)
expect(
parsed!.panels.map(({ label, strategy, overrides }) => ({ label, strategy, overrides })),
).toEqual([
{ label: 'Wave ~ one, two: go', strategy: 'most-connected', overrides: {} },
{ label: 'Big budget', strategy: 'random', overrides: { numEducated: 60, graphSeed: 7 } },
])
expect(parsed!.focusIndex).toBe(1)
})
it.each([
['a non-numeric base field', '?forwardProb=fast'],
['an empty base field', '?forwardProb='],
['an unknown strategy', '?panel=X~telepathy~'],
['a malformed panel param', '?panel=onlylabel'],
['an empty panel label', '?panel=~none~'],
['an unknown override field', '?panel=X~none~bogus:1'],
['a non-numeric override value', '?panel=X~none~numEducated:lots'],
['an override without a value', '?panel=X~none~numEducated'],
['a focus index out of range', '?focus=2'],
['a non-integer focus', '?focus=1.5'],
['a negative focus', '?focus=-1'],
[
'more than six panels',
`?${Array.from({ length: 7 }, (_, index) => `panel=P${index}~none~`).join('&')}`,
],
])('rejects %s so the app falls back to the preset', (_description, search) => {
expect(parseUrlState(search, preset, idFactory())).toBeNull()
})
})

View file

@ -1,15 +1,45 @@
import type { Config } from '@/types/engine' import type { Config } from '@/types/engine'
import type { ComparisonResponse } from '@/types/api' import type { ComparisonResponse, ScenarioRequest, ScenarioResponse } from '@/types/api'
// Thin typed wrappers around the JSON API. The types come from // Thin typed wrappers around the JSON API. The types come from
// src/types/, which is generated from the Go structs (single source of // src/types/, which is generated from the Go structs (single source of
// truth); nothing here redefines a shape. // truth); nothing here redefines a shape.
// The API answers invalid input with 400 and {"error": "..."}; carrying the
// status lets the store route engine validation errors to the inline spot
// under the controls instead of the error banner.
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
function apiErrorMessage(response: Response, body: string): string {
try {
const parsed: unknown = JSON.parse(body)
if (
parsed !== null &&
typeof parsed === 'object' &&
'error' in parsed &&
typeof parsed.error === 'string'
) {
return parsed.error
}
} catch {
// not JSON; fall through to the status line
}
return `${response.status} ${response.statusText}${body ? `: ${body}` : ''}`
}
async function requestJSON<T>(input: string, init?: RequestInit): Promise<T> { async function requestJSON<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init) const response = await fetch(input, init)
if (!response.ok) { if (!response.ok) {
const body = await response.text() const body = await response.text()
throw new Error(`${response.status} ${response.statusText}: ${body}`) throw new ApiError(response.status, apiErrorMessage(response, body))
} }
return response.json() as Promise<T> return response.json() as Promise<T>
} }
@ -25,3 +55,11 @@ export function runComparison(config: Config): Promise<ComparisonResponse> {
body: JSON.stringify(config), body: JSON.stringify(config),
}) })
} }
export function runScenario(request: ScenarioRequest): Promise<ScenarioResponse> {
return requestJSON<ScenarioResponse>('/api/scenario', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
}

11
web/src/lib/format.ts Normal file
View file

@ -0,0 +1,11 @@
// The single rounding rule for percentages (spec section 6): round to a
// whole percent. Every surface (hero, panels, strip, chart labels, table)
// goes through here so 99/120 reads as 83% everywhere, never 82%.
export function roundPct(percent: number): number {
return Math.round(percent)
}
export function formatPct(percent: number): string {
return `${roundPct(percent)}%`
}

7
web/src/lib/graph.ts Normal file
View file

@ -0,0 +1,7 @@
import type { Config } from '@/types/engine'
// Panels that agree on these four fields get the same topology from the
// engine, so the frontend caches edges (and later, layouts) per key.
export function graphKey(config: Config): string {
return `${config.numStudents}|${config.edgesPerNode}|${config.triangleProb}|${config.graphSeed}`
}

153
web/src/lib/urlState.ts Normal file
View file

@ -0,0 +1,153 @@
import type { Config, Strategy } from '@/types/engine'
import { StrategyMostConnected, StrategyNone, StrategyRandom } from '@/types/engine'
import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types'
// URL state scheme (spec section 7): human-readable query parameters.
// Base fields appear by JSON name only when they differ from the preset;
// each panel is one repeated `panel=label~strategy~overrides` param,
// omitted entirely while the panel set equals the preset's; `focus=` holds
// the focused panel index. Unknown params are ignored; anything malformed
// makes the whole parse fail so the app falls back to the preset.
const KNOWN_STRATEGIES: readonly Strategy[] = [StrategyNone, StrategyRandom, StrategyMostConnected]
export interface ParsedUrlState {
base: Config
panels: PanelSpec[]
focusIndex: number | null
}
// The runtime base config carries exactly the generated Config fields, so
// deriving names from it stays in sync with the Go structs automatically.
function configFieldNames(base: Config): (keyof Config)[] {
return Object.keys(base) as (keyof Config)[]
}
function isConfigField(base: Config, name: string): name is keyof Config {
return Object.hasOwn(base, name)
}
function encodePanelLabel(label: string): string {
// encodeURIComponent leaves ~ alone, but ~ is our segment separator.
return encodeURIComponent(label).replaceAll('~', '%7E')
}
function encodePanel(panel: PanelSpec): string {
const overridePairs = Object.entries(panel.overrides).map(([field, value]) => `${field}:${value}`)
return `${encodePanelLabel(panel.label)}~${panel.strategy}~${overridePairs.join(',')}`
}
function overridesEqual(a: Partial<Config>, b: Partial<Config>): boolean {
const aFields = Object.keys(a) as (keyof Config)[]
return aFields.length === Object.keys(b).length && aFields.every((field) => a[field] === b[field])
}
function panelsMatchPreset(panels: PanelSpec[], preset: StudyPreset): boolean {
return (
panels.length === preset.panels.length &&
panels.every((panel, index) => {
const presetPanel = preset.panels[index]
return (
presetPanel !== undefined &&
panel.label === presetPanel.label &&
panel.strategy === presetPanel.strategy &&
overridesEqual(panel.overrides, presetPanel.overrides)
)
})
)
}
export function serializeUrlState(
base: Config,
panels: PanelSpec[],
focusPanelId: string | null,
preset: StudyPreset,
): string {
const parts: string[] = []
for (const field of configFieldNames(base)) {
if (base[field] !== preset.base[field]) parts.push(`${field}=${base[field]}`)
}
if (!panelsMatchPreset(panels, preset)) {
for (const panel of panels) parts.push(`panel=${encodePanel(panel)}`)
}
if (focusPanelId !== null) {
const focusIndex = panels.findIndex((panel) => panel.id === focusPanelId)
if (focusIndex >= 0) parts.push(`focus=${focusIndex}`)
}
return parts.length > 0 ? `?${parts.join('&')}` : ''
}
function decodePanel(
base: Config,
rawValue: string,
createPanelId: () => string,
): PanelSpec | null {
const segments = rawValue.split('~')
if (segments.length !== 3) return null
const [rawLabel = '', strategy = '', rawOverrides = ''] = segments
let label: string
try {
label = decodeURIComponent(rawLabel)
} catch {
return null
}
if (label === '') return null
if (!KNOWN_STRATEGIES.includes(strategy)) return null
const overrides: Partial<Config> = {}
if (rawOverrides !== '') {
for (const pair of rawOverrides.split(',')) {
const colonAt = pair.indexOf(':')
if (colonAt < 0) return null
const field = pair.slice(0, colonAt)
const value = Number(pair.slice(colonAt + 1))
if (!isConfigField(base, field) || !Number.isFinite(value)) return null
overrides[field] = value
}
}
return { id: createPanelId(), label, strategy, overrides }
}
export function parseUrlState(
search: string,
preset: StudyPreset,
createPanelId: () => string,
): ParsedUrlState | null {
const query = search.startsWith('?') ? search.slice(1) : search
const base: Config = { ...preset.base }
const urlPanels: PanelSpec[] = []
let sawPanelParam = false
let focusIndex: number | null = null
for (const rawParam of query.split('&')) {
if (rawParam === '') continue
const equalsAt = rawParam.indexOf('=')
if (equalsAt < 0) continue // bare keys are ignored like unknown params
const key = rawParam.slice(0, equalsAt)
const rawValue = rawParam.slice(equalsAt + 1)
if (key === 'panel') {
sawPanelParam = true
const panel = decodePanel(base, rawValue, createPanelId)
if (panel === null) return null
urlPanels.push(panel)
} else if (key === 'focus') {
const parsedIndex = Number(rawValue)
if (rawValue === '' || !Number.isInteger(parsedIndex) || parsedIndex < 0) return null
focusIndex = parsedIndex
} else if (isConfigField(base, key)) {
const value = Number(rawValue)
if (rawValue === '' || !Number.isFinite(value)) return null
base[key] = value
}
}
if (sawPanelParam && urlPanels.length > MAX_PANELS) return null
const panels = sawPanelParam
? urlPanels
: preset.panels.map((panel) => ({
...panel,
id: createPanelId(),
overrides: { ...panel.overrides },
}))
if (focusIndex !== null && focusIndex >= panels.length) return null
return { base, panels, focusIndex }
}

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

View file

@ -8,8 +8,10 @@
"compilerOptions": { "compilerOptions": {
// Vitest runs in a different environment than the application code. // Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly. // Adjust lib and types accordingly. The language lib stays ES2022 (as in
"lib": [], // the app config) so app code imported by tests type-checks the same;
// only the DOM globals come from jsdom instead.
"lib": ["ES2022"],
"types": ["node", "jsdom"], "types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.