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:
parent
2feb6ea5a3
commit
2f21248c20
13 changed files with 1003 additions and 4 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
2
web/.prettierignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Generated by tygo from the Go structs; must stay byte-identical to its output (CI drift guard).
|
||||||
|
src/types/
|
||||||
270
web/src/composables/__tests__/useSimStore.spec.ts
Normal file
270
web/src/composables/__tests__/useSimStore.spec.ts
Normal 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=')
|
||||||
|
})
|
||||||
|
})
|
||||||
298
web/src/composables/useSimStore.ts
Normal file
298
web/src/composables/useSimStore.ts
Normal 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
|
||||||
|
}
|
||||||
19
web/src/lib/__tests__/format.spec.ts
Normal file
19
web/src/lib/__tests__/format.spec.ts
Normal 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%')
|
||||||
|
})
|
||||||
|
})
|
||||||
121
web/src/lib/__tests__/urlState.spec.ts
Normal file
121
web/src/lib/__tests__/urlState.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
11
web/src/lib/format.ts
Normal 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
7
web/src/lib/graph.ts
Normal 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
153
web/src/lib/urlState.ts
Normal 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 }
|
||||||
|
}
|
||||||
49
web/src/presets/deepfake-school.ts
Normal file
49
web/src/presets/deepfake-school.ts
Normal 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 non‑consensual 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 best‑connected 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
26
web/src/presets/types.ts
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue