From 9a392b1860816d717b8e1d752464f2156fa82af6 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 15:48:48 +0200 Subject: [PATCH] api: POST /api/scenario returns result plus graph topology (M3 slice 1) One panel's whole world in one call: effective config in, echoed config + cascade result + undirected edge list out. Edges are [from, to] pairs with from < to in deterministic node order; Graph.Edges() walks the adjacency once, GraphEdges(config) rebuilds the seeded world (~25us) so Result stays lean and /api/comparison stays untouched. This closes the topology gap the design brief flagged; the frontend's seeded d3-force layout consumes these pairs. Go bits: [][2]int is a slice of fixed-size arrays; [2]int is a value type, comparable, and JSON-marshals to [a, b], exactly the wire shape the spec asks for. tygo regen includes a fix: engine.Strategy now maps to the generated Strategy type instead of decaying to 'any' in ScenarioRequest. Verified live through the dev stack: 7/120 reached, 351 edges. --- docs/m3-implementation-handoff.md | 81 +++++ docs/mockup/data.json | 1 + docs/mockup/index.html | 445 ++++++++++++++++++++++ docs/mockup/rendered.html | 445 ++++++++++++++++++++++ docs/ui-spec.md | 587 ++++++++++++++++++++++++++++++ internal/api/api.go | 42 +++ internal/api/scenario_test.go | 87 +++++ internal/engine/graph.go | 15 + internal/engine/graph_test.go | 53 +++ internal/engine/scenario.go | 12 + tygo.yaml | 3 +- web/src/types/api.ts | 21 +- 12 files changed, 1790 insertions(+), 2 deletions(-) create mode 100644 docs/m3-implementation-handoff.md create mode 100644 docs/mockup/data.json create mode 100644 docs/mockup/index.html create mode 100644 docs/mockup/rendered.html create mode 100644 docs/ui-spec.md create mode 100644 internal/api/scenario_test.go diff --git a/docs/m3-implementation-handoff.md b/docs/m3-implementation-handoff.md new file mode 100644 index 0000000..52a8eef --- /dev/null +++ b/docs/m3-implementation-handoff.md @@ -0,0 +1,81 @@ +# Handoff: milestone 3 implementation (from the UI design session) + +You wrote `docs/ui-design-brief.md` and asked for a design session. It ran on +2026-06-10, every decision made with Justin via AskUserQuestion, and it is +done: nothing UI-shaped is left open. This doc tells you what exists now, +what changed relative to your milestone 3 assumptions, and where to start. + +## What exists now + +- `docs/ui-spec.md` is the normative spec. Page layout with wireframe, + component tree, every interaction with trigger/behavior/states, API + requirements, visual tokens for both themes, a testable accessibility + checklist, 12 ordered implementation slices, and one-line rejected + alternatives. Build from it without re-deciding; if you disagree with + something, raise it with Justin, do not silently deviate. +- `docs/mockup/index.html` is the approved visual reference (v2; Justin + rejected v1 outright). It is a design artifact with stand-in layout and + edges, not app code. To view: bake a live `/api/comparison` response into + it (replace `__DATA__`, see `data.json` + `rendered.html` already there), + open in a browser, `?dark` for the dark theme, narrow window for mobile. + Match its look; do not port its code. + +## Deltas against what you probably assumed for milestone 3 + +- The dashboard is not the fixed three-strategy comparison. Panels are a + dynamic list (cap 6) of scenarios: one shared base `Config` plus sparse + per-panel overrides and a strategy. The classic trio is just the default, + loaded from a study preset module (`web/src/presets/deepfake-school.ts`) + that owns all study copy, so no research question is hardcoded. +- New endpoint needed, and it is slice 1, your Go territory: + `POST /api/scenario` takes `{config, strategy}`, returns + `{config, result, edges}` where `edges` is the deterministic topology as + node-index pairs. This closes the topology gap flagged in the brief. The + engine already has the graph; expose it. Same validation and 400-string + behavior as comparison. tygo regen, determinism test on edges. + `/api/comparison` stays as-is (parity table and external consumers). +- The frontend fires one `/api/scenario` per panel in parallel, debounced + 400 ms, atomic swap, keep-last-good on errors. No batch endpoint + (rejected, one-liner in the spec). +- The optimisation feature (your milestone 5) got its API shape reserved: + `POST /api/optimize` goal-seek, request/response sketched in spec + section 2. M3 ships no optimizer UI, only a layout that can host it. +- In scope for M3 beyond what the brief listed: export (JSON, per-node CSV, + PNG snapshot), both light and dark themes with a toggle, and mobile as a + first-class target (Justin expects first contact by phone). Animated + GIF/WebM export is explicitly post-M3. +- The accessibility criteria your locked decisions called UNDEFINED are now + defined: spec section 8 is the checklist, and slice 12 is the audit. +- Pinia and Vue Router: decided not needed. Module-scope composable store, + modal focus, one page. + +## Calls Justin made explicitly (do not relitigate) + +- Focus view is a modal overlay (recommendation was expand-in-place). +- Sharing is address-bar-only, no Share button, no toast + (`history.replaceState` after every successful run). +- Both themes ship, not light-only. +- Dynamic panels, export, and the optimizer hook all came from him + unprompted; they are wanted, not gold-plating. + +## Method notes + +- Your working agreements stand: teaching-paced TDD on Go (slice 1 gets a + micro-brief, failing test first, small annotated diffs, learning-log + commit message); Vue/TS driven fast and mostly autonomously. +- Justin's design bar is high. His words on the first mockup: "a very weak + mishmash of elements"; he wants a clean, responsive SPA that "looks + professional and feels amazing". The approved mockup is the bar: app + shell, type scale, one card system, real SVG icons, consistent spacing. + Never show him boxes-on-a-page, not even as an intermediate state demo. +- Dev stack runs in the shared tmux session "spreadlab" (`./dev.sh`, API on + :8080, Vite on :5173). Use it, do not spawn background tasks. +- One shared percent formatter everywhere; the 82-vs-83 rounding mismatch + already bit the mockup once (spec section 6). + +## First steps + +Read `docs/ui-spec.md` end to end, view the mockup in both themes and at +phone width, then propose the slice 1 plan (the `/api/scenario` endpoint) +and start the usual loop. Spec section 10 is the full M3 slice order; each +slice leaves the app shippable. diff --git a/docs/mockup/data.json b/docs/mockup/data.json new file mode 100644 index 0000000..cbed859 --- /dev/null +++ b/docs/mockup/data.json @@ -0,0 +1 @@ +{"config":{"numStudents":120,"edgesPerNode":3,"triangleProb":0.45,"forwardProb":0.38,"numEducated":36,"origin":0,"graphSeed":17,"thresholdSeed":2,"educationSeed":1},"results":[{"strategy":"none","educated":[],"reachedAtRound":[0,4,4,2,3,3,5,2,3,4,4,4,1,7,5,1,5,6,5,5,5,2,3,7,3,4,2,4,4,3,4,4,5,6,2,5,1,5,-1,2,6,5,3,4,-1,5,4,5,4,4,3,5,3,-1,6,5,3,-1,4,5,-1,5,-1,5,2,-1,1,4,3,4,3,5,2,4,4,3,3,-1,5,3,-1,5,-1,-1,5,3,3,-1,6,3,5,5,-1,-1,6,2,5,5,8,5,-1,5,6,6,4,7,-1,4,4,-1,-1,5,3,-1,1,-1,3,3,-1,5],"numReached":99,"numRounds":9,"reachedPct":82.5},{"strategy":"random","educated":[2,4,6,7,8,10,21,23,24,27,28,33,37,39,40,43,48,50,53,56,61,62,63,68,74,79,80,85,87,89,90,97,101,102,111,117],"reachedAtRound":[0,4,4,2,3,3,5,2,3,-1,7,4,1,7,5,1,5,6,-1,6,5,2,-1,7,3,7,2,5,5,-1,-1,6,6,6,2,5,1,5,-1,2,6,-1,3,4,-1,-1,4,-1,4,-1,-1,5,-1,-1,6,5,3,-1,-1,-1,-1,-1,-1,-1,2,-1,1,-1,5,4,-1,5,2,-1,-1,-1,3,-1,-1,3,-1,5,-1,-1,6,3,-1,-1,6,3,7,-1,-1,-1,7,2,-1,7,8,5,-1,5,6,-1,-1,7,-1,4,4,-1,-1,5,-1,-1,1,-1,3,-1,-1,-1],"numReached":70,"numRounds":9,"reachedPct":58.333333333333336},{"strategy":"most-connected","educated":[1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,19,20,21,22,23,24,25,26,27,28,30,31,32,33,35,37,38,39,45,47,77],"reachedAtRound":[0,-1,-1,-1,-1,-1,-1,2,-1,-1,-1,-1,1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1],"numReached":7,"numRounds":3,"reachedPct":5.833333333333333}]} \ No newline at end of file diff --git a/docs/mockup/index.html b/docs/mockup/index.html new file mode 100644 index 0000000..9b21301 --- /dev/null +++ b/docs/mockup/index.html @@ -0,0 +1,445 @@ + + + + + +spreadlab mockup v2 (design artifact, not app code) + + + + +
+ + + spreadlab + + Illustrative model, not validated + + + +
+ +
+
+

How a non‑consensual deepfake spreads through a school

+

One simulated year group, 120 students. Educate the same 30% of them, but change who: + the fake reaches 82% of the school with no program, + 58% educating at random, and + 6% educating the best‑connected students.

+ Illustrative model, not validated +
+ +
+ +
+

Scenarios

+ + + +
+ +
+ +
+ Forwarded the fake + Educated to refuse + Not reached + Origin +
+ +
+ + + + +
+ Round 9 of 9 + +
+ +
+
+

Reach over time

+ +
+
+

World

+
+ +
+ 38% +
+
+ +
+ 36 · 30% +
+
+ Advanced +
+ Students120 + Friends per student3 + Clique tendency0.45 + First poster#0 + Friendship network17 + Who resists2 +
+
+
+
+ +
+ Illustrative (not validated output) · an agent‑based toy model, parameters chosen for clarity + spreadlab · AGPL +
+
+ + + + diff --git a/docs/mockup/rendered.html b/docs/mockup/rendered.html new file mode 100644 index 0000000..30a1420 --- /dev/null +++ b/docs/mockup/rendered.html @@ -0,0 +1,445 @@ + + + + + +spreadlab mockup v2 (design artifact, not app code) + + + + +
+ + + spreadlab + + Illustrative model, not validated + + + +
+ +
+
+

How a non‑consensual deepfake spreads through a school

+

One simulated year group, 120 students. Educate the same 30% of them, but change who: + the fake reaches 82% of the school with no program, + 58% educating at random, and + 6% educating the best‑connected students.

+ Illustrative model, not validated +
+ +
+ +
+

Scenarios

+ + + +
+ +
+ +
+ Forwarded the fake + Educated to refuse + Not reached + Origin +
+ +
+ + + + +
+ Round 9 of 9 + +
+ +
+
+

Reach over time

+ +
+
+

World

+
+ +
+ 38% +
+
+ +
+ 36 · 30% +
+
+ Advanced +
+ Students120 + Friends per student3 + Clique tendency0.45 + First poster#0 + Friendship network17 + Who resists2 +
+
+
+
+ +
+ Illustrative (not validated output) · an agent‑based toy model, parameters chosen for clarity + spreadlab · AGPL +
+
+ + + + diff --git a/docs/ui-spec.md b/docs/ui-spec.md new file mode 100644 index 0000000..055df3a --- /dev/null +++ b/docs/ui-spec.md @@ -0,0 +1,587 @@ +# spreadlab UI specification (milestone 3) + +Status: decided. This spec is the output of the design session run from +`docs/ui-design-brief.md`. An implementation session should be able to build +the dashboard from this document without re-deciding anything. Rejected +alternatives are listed at the end, one line each. + +The normative visual reference is the approved mockup in `docs/mockup/` +(`index.html` is the source; build `rendered.html` by replacing `__DATA__` +with a live `/api/comparison` response). The mockup is a design artifact: +its network layout and edges are stand-ins, its components are static. Match +its look (spacing, type scale, card system, both themes), not its code. + +--- + +## 1. Product shape + +A single-page Vue 3 + TypeScript dashboard. One page, no routes. The page +shows a dynamic list of simulation scenarios ("panels") that share a base +config; each panel may override any config field. The default experience is +the grant story: three panels (no program, educate 30% at random, educate +the best-connected 30%) over the default config, animated once on load. + +Audience order: grant-pitch audience and graders (message must land in +seconds, without interaction), curious public on the hosted demo (often on +a phone), the author. + +### Study presets, no hardcoded research question + +All study-specific copy and the initial scenario set live in a preset +module, not in components: `web/src/presets/deepfake-school.ts`, typed as +`StudyPreset`: + +```ts +interface StudyPreset { + headline: string // hero h1 + narrative: string // hero paragraph, may contain {0} {1} {2}... + // placeholders, replaced by each panel's + // reached% (same formatter as the panels) + disclaimerShort: string // badge text: "Illustrative model, not validated" + disclaimerLong: string // About popover body (2-4 sentences) + toneThresholdPct: number // reached% <= threshold renders "good" (teal), + // above renders "bad" (rose); 30 for this study + base: Config // from generated types, the default world + panels: PanelSpec[] // initial panels (labels + overrides) +} +``` + +The app imports exactly one preset. A different research question is a new +preset file plus a changed import, zero component changes. `StudyPreset` +and `PanelSpec` are frontend-only types (they describe presentation, not +simulation data, so they do not violate the no-shape-redefinition rule; +`Config`, `Result`, `Strategy` stay imported from the generated types). + +### Panel model + +```ts +interface PanelSpec { + id: string // stable client id (nanoid-style) + label: string // user editable + strategy: Strategy // from generated types + overrides: Partial // sparse diff against base +} +``` + +Effective config for a panel = `{ ...base, ...overrides }`. Panels that do +not override `numStudents`, `edgesPerNode`, `triangleProb`, or `graphSeed` +share the base graph and therefore the identical layout; differences then +read as the experiment, not as noise. Maximum 6 panels; the Add button +disables at the cap with a tooltip "Maximum 6 scenarios". + +--- + +## 2. Data requirements from the API + +The API currently exposes `GET /api/config/default` and +`POST /api/comparison`. Neither returns graph topology, and comparison is +locked to the three fixed strategies of one config. Milestone 3 needs one +new endpoint; the implementation session adds it Go-side and regenerates +types with tygo. + +### New: `POST /api/scenario` + +Request body (Go struct, tygo-generated for the frontend): + +```jsonc +{ "config": { /* Config */ }, "strategy": "random" } +``` + +Response: + +```jsonc +{ + "config": { /* echoed effective Config */ }, + "result": { /* Result, as today */ }, + "edges": [[0, 5], [0, 12], ...] // undirected, node-index pairs, + // deterministic from the config's + // graph fields +} +``` + +- Validation and 400-with-error-string behavior identical to comparison. +- `edges` is small (roughly `numStudents * edgesPerNode` pairs) and is + always included; the frontend deduplicates layout work by hashing the + graph-shaping fields (`numStudents`, `edgesPerNode`, `triangleProb`, + `graphSeed`), so panels sharing a graph compute one layout. +- The frontend issues one request per panel, in parallel + (`Promise.all`), debounced 400 ms after the last input change, and swaps + all results in atomically when every request has settled. +- `GET /api/config/default` keeps seeding the preset's `base` fallback. +- `POST /api/comparison` remains for the parity table and API consumers. + +### Reserved: `POST /api/optimize` (post-M3, design hook only) + +Goal-seek endpoint: body `{ base: Config, strategy, goal: { maxReachedPct, +optimize: "numEducated" } }`, response: best config plus the candidate +sweep (config + reachedPct per candidate) so the UI can show its work as a +curve. Milestone 3 ships no UI for this; the controls card layout simply +leaves room for one extra button row, nothing more. + +--- + +## 3. Page layout + +Desktop wireframe (mobile differences in section 9): + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ◆ spreadlab (ⓘ Illustrative model, not validated) ☾ ⌥src │ app bar, sticky +├──────────────────────────────────────────────────────────────────────┤ +│ How a non-consensual deepfake spreads through a school │ hero (from preset) +│ One simulated year group, 120 students. Educate the same 30%, │ +│ but change who: 83% ... 58% ... 6% ... │ live numbers +│ │ +│ SCENARIOS [↓ Export] [+ Add] │ toolbar +│ ┌──────────────────┬──────────────────┬──────────────────┐ │ +│ │ ▪ No program ⋮ │ ▪ Educate rand ⋮ │ ▪ Educate conn ⋮ │ │ panel cards +│ │ 83% │ 58% │ 6% │ │ +│ │ 99/120 · 9r · 0e │ 70/120 · 9r · 36e│ 7/120 · 3r · 36e │ │ +│ │ [override chips] │ [override chips] │ [override chips] │ │ +│ │ (network SVG) │ (network SVG) │ (network SVG) │ │ +│ └──────────────────┴──────────────────┴──────────────────┘ │ +│ ● Forwarded ◎ Educated · Not reached ◉ Origin │ legend +│ │ +│ ( ↻ ⏮ ▶ ⏭ ──●──·──·──·── Round 9 of 9 1× ) │ player pill, centered +│ │ +│ ┌─ REACH OVER TIME ────────────────┐ ┌─ WORLD ───────────────────┐ │ +│ │ (overlaid curves, playhead) │ │ Chance to forward ──● │ │ +│ │ │ │ Education budget ──● │ │ +│ │ │ │ ▾ Advanced (6 fields) │ │ +│ └──────────────────────────────────┘ └───────────────────────────┘ │ +│ ▸ Data table (collapsed) │ +│ Illustrative (not validated output) · toy model spreadlab · │ footer +└──────────────────────────────────────────────────────────────────────┘ +``` + +Above the fold at 1366x768: app bar, hero, toolbar, the full panel row, +legend, player. Chart and controls may start below the fold; the story +must not. + +--- + +## 4. Component tree + +``` +App +├── AppBar +│ ├── Wordmark (logo glyph + "spreadlab") +│ ├── DisclaimerBadge (opens AboutPopover; hidden on mobile) +│ ├── ThemeToggle +│ └── SourceLink (repo icon link) +├── HeroHeadline (preset headline + narrative with live %) +│ └── DisclaimerNote (mobile only: same popover trigger) +├── MobileScoreStrip (mobile only, sticky) +├── ScenarioToolbar ("Scenarios", ExportMenu, AddScenarioButton) +├── ErrorBanner (failed refresh / API unreachable) +├── PanelGrid +│ └── ScenarioPanel (xN) +│ ├── PanelHeader (accent swatch, label, kebab PanelMenu) +│ ├── PanelStats (big %, meta row, override chips) +│ └── NetworkView (SVG: edges, nodes; emits hover/tap) +├── LegendRow +├── PlayerBar (replay, step back, play/pause, step fwd, +│ RoundScrubber, round counter, speed pill) +├── ReachChart (overlaid cumulative curves + playhead) +├── ControlsCard +│ ├── LeverSlider (x2) (forwardProb, numEducated) +│ └── AdvancedFields (collapsible: students, edges/node, +│ clique tendency, origin, 3 seeds w/ dice) +├── ResultsTable (collapsible; accessible data table, +│ evolved from existing ComparisonTable) +├── FocusModal (enlarged ScenarioPanel + PlayerBar dock) +├── PanelEditorPopover (per-panel config diff editor) +├── NodeTooltip (single instance, positioned per hover) +└── FooterDisclaimer + +composables (module-scope singletons, no Pinia): +├── useSimStore state: base, panels[], resultsByPanelId, edgesByGraphHash, +│ round, playing, speed, focusPanelId, hoveredNode, +│ runState (idle|running|error), errorMessage +│ actions: setBaseField, add/update/remove/duplicatePanel, +│ run (debounced), syncToUrl/fromUrl +├── useLayout seeded d3-force layout per graph hash (cached) +└── usePlayback rAF round timer honoring speed + reduced motion +``` + +State lives in `useSimStore` (a plain `reactive` module singleton). No +Pinia, no Vue Router: one page, one store, modal focus is store state. + +--- + +## 5. Interactions, one by one + +Every interaction lists trigger, behavior, and states. + +### 5.1 Initial load +- Trigger: page open. +- Behavior: parse URL state (section 7); if absent, use the preset. Fetch + `/api/scenario` per panel. While loading, panels render as skeleton + cards (surface block, shimmer, fixed height) so layout does not jump. + When all results land: compute layouts, render round 0, then autoplay + (5.4). Hero percentages fill in with the results. +- States: success (autoplay), API unreachable (centered message in the + panel area: "Could not reach the spreadlab API at /api", retry + button, nothing else dimmed since nothing rendered yet), invalid URL + state (fall back to preset, show ErrorBanner "Link contained an invalid + configuration, showing defaults"). + +### 5.2 Changing a base lever (sliders, advanced fields) +- Trigger: input on any ControlsCard field. +- Behavior: update store immediately (slider thumb moves), debounce 400 ms + after the last change, then re-run all panels in parallel. While + running: panels dim to 60% opacity with a small spinner in the toolbar; + interaction stays enabled. On success: results swap in atomically, + animation resets to the final round (no autoplay re-trigger), URL + updates via `history.replaceState`. Hero numbers update. +- States: success; network failure (keep last good results undimmed, + ErrorBanner "Could not update: " with a Retry button); 400 + invalid config (keep last good results, attach the API error string + inline under the controls card header in rose, mark the offending field + if identifiable by name match, no banner). + +### 5.3 Seed rerolls +- Trigger: dice button next to a seed field (graph seed "Friendship + network", threshold seed "Who resists", education seed "Random picks"). +- Behavior: set that seed to a fresh random uint32, then as 5.2. Rerolling + the graph seed invalidates the layout cache for affected panels; the new + layout fades in (200 ms opacity crossfade, skipped under reduced + motion). A text button "Reroll world" rerolls all three seeds at once. +- States: as 5.2. + +### 5.4 Playback +- Trigger: autoplay once after first successful load; afterwards only via + player controls. +- Behavior: rounds advance every 700 ms at 1x (speed pill cycles 0.5x, + 1x, 2x). Within a round, nodes reached that round transition from their + resting state to rose: scale 0.6 to 1.0 plus fade, 250 ms ease-out. + Educated rings are visible from round 0 (they exist before the spread). + Panels whose run ended before the global max round hold their final + state. At the end, playback stops on the final round (no loop), play + button becomes replay affordance. +- Player controls: replay (jump to round 0 and play), step back, play / + pause toggle, step forward, scrubber (one tick per round, drag or click + to seek; seeking pauses), round counter "Round 4 of 9" (global max), + speed pill. +- Keyboard (when focus is inside the player, plus global except in text + inputs): Space toggles play/pause, ArrowLeft/ArrowRight step one round, + Home/End jump to first/final round. The scrubber is a native + `input[type=range]` so arrow keys work on it natively; + `aria-valuetext="Round 4 of 9"`. +- Reduced motion (`prefers-reduced-motion: reduce`): no autoplay, page + loads at the final round; play still works but rounds swap discretely + (no tween, no scale); the 700 ms cadence becomes 1000 ms at 1x. +- States: round state is global and drives every panel, the chart + playhead, and the modal simultaneously. Round is not stored in the URL. + +### 5.5 Node hover / tap +- Trigger: pointerenter on a node (desktop), tap (touch). SVG circles get + a transparent 12 px-radius hit halo so 4 px dots are tappable. +- Behavior: NodeTooltip appears near the pointer: "Student 17", state in + this panel ("Forwarded in round 3" / "Educated, refused" / "Not + reached"; "Origin, posted it" for the origin), and "5 friends" (degree, + computed from edges). The same student gets a 2 px ink outline ring in + every panel simultaneously (the cross-panel echo: the same kid, reached + in one world, safe in another). +- Dismiss: pointerleave, tap elsewhere, Esc. No click-to-pin in M3. +- States: tooltips are pointer-only; the equivalent information is + available non-visually through the ResultsTable (per-panel aggregate) + and is acceptable per the accessibility decision (8). + +### 5.6 Add scenario +- Trigger: "+ Add scenario" button in the toolbar. +- Behavior: append a panel cloned from base (`strategy: "none"`, empty + overrides, label "Scenario N", next free accent color), run it, and + open its PanelEditorPopover anchored to the new panel. At 6 panels the + button disables with tooltip "Maximum 6 scenarios". +- States: while its first run is in flight the new panel shows the + skeleton card. + +### 5.7 Panel menu and editor +- Trigger: kebab menu on a panel header: Edit, Rename, Duplicate, Remove. +- Behavior: + - Edit opens PanelEditorPopover: strategy select (none / random / + most-connected) plus every Config field as a row showing the base + value; typing a different value creates an override (row highlights, + a reset icon appears per overridden field). Overrides render as chips + on the panel card ("numEducated · 60"). Apply follows the same + debounced-run path as 5.2 but re-runs only that panel. + - Rename: inline edit of the label in the popover header. + - Duplicate: copy panel with " copy" suffix, next accent. + - Remove: deletes immediately (no confirm; the action is cheap to + redo and the URL history keeps the old state one paste away). The + last remaining panel cannot be removed (menu item disabled). +- States: editor validation mirrors the API (400 text shown inline in the + popover); a panel whose run failed shows its last good result plus a + small rose dot on the kebab. + +### 5.8 Focus modal +- Trigger: click anywhere on a panel's network area (cursor: zoom-in), or + "Focus" in the panel menu. +- Behavior: modal overlay (scrim `rgba(15,23,42,.5)`), panel rendered + large (max 90 vw / 85 vh), same SVG and round state, with the PlayerBar + docked at the modal footer (same store state, both bars stay in sync; + the background one is inert under the scrim). Close via X button, Esc, + or scrim click. `focusPanelId` is part of URL state, so a shared link + can open pre-focused. +- States: focus trap inside the modal; on close, focus returns to the + panel that opened it. `aria-modal="true"`, labelled by the panel label. + +### 5.9 Export +- Trigger: "Export" menu in the toolbar: "Data (JSON)", "Per-node CSV", + "Picture (PNG)". +- Behavior: + - JSON: downloads `spreadlab-export.json`: `{ base, panels, results }` + exactly as held in the store (configs echoed from the API). + - CSV: `spreadlab-nodes.csv`, columns `panel,label,node,educated, + reachedAtRound` (one row per panel x node; -1 kept as -1). + - PNG: renders the current view at the current round to a canvas + (serialize each panel SVG, draw side by side with panel labels and + percentages, current theme colors, legend row, disclaimer footer + line) and downloads `spreadlab.png` at 2x resolution. +- States: menu items disable while a run is in flight (export captures + settled state only). Animated WebM/GIF export is post-M3 (rejected + list). + +### 5.10 Sharing +- Trigger: none. The address bar is the share UI. +- Behavior: after every successful run and on panel/focus changes, the + full state serializes into the URL query via `history.replaceState` + (never `pushState`, no history spam). Copying the URL at any time + reproduces the exact view: same seeds, same layout (seeded force), same + panels, same focus. No Share button, no toast. + +### 5.11 Theme +- Trigger: ThemeToggle in the app bar. +- Behavior: default follows `prefers-color-scheme`; toggle overrides and + persists to `localStorage` (`spreadlab-theme`). Theme is not URL state. + Both themes use the token table in section 6; node semantics are + identical in both. + +### 5.12 About / disclaimer +- Trigger: DisclaimerBadge (app bar, desktop) or DisclaimerNote line under + the hero (mobile). +- Behavior: small popover, `disclaimerLong` from the preset: what the + model is (a seeded agent-based toy simulation with parameters chosen + for illustration), what it is not (a validated prediction of real + schools), and a link to the proposal/repo. The italic footer line + ("Illustrative (not validated output)") is always present and is + included in PNG exports. The headline itself carries no caveat. + +--- + +## 6. Visual tokens + +Inherited from the prototype figure (`grant-proposal/pitch/figure/ +spread_figure.py`) and extended for dark mode. Implement as CSS custom +properties on `:root` / `.dark`. + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--bg` | `#F8FAFC` | `#0B1120` | page background | +| `--surface` | `#FFFFFF` | `#111A2E` | cards, app bar | +| `--border` | `#E2E8F0` | `#1E293B` | card borders | +| `--border-soft` | `#EEF2F7` | `#1A2438` | inner dividers, chart grid | +| `--edge` | `#E6E9EF` | `#243349` | network edges | +| `--unreached` | `#D8DFE9` | `#3B4A61` | unreached node fill | +| `--spread` | `#F43F5E` | `#FB7185` | forwarded nodes, bad % | +| `--edu` | `#14B8A6` | `#2DD4BF` | educated rings, good %, slider accents | +| `--ink` | `#0F172A` | `#F1F5F9` | headings, primary buttons | +| `--ink-2` | `#334155` | `#CBD5E1` | body, labels | +| `--ink-3` | `#64748B` | `#94A3B8` | secondary text (min for informative text) | +| `--ink-4` | `#94A3B8` | `#64748B` | decorative/duplicated text only (fails AA for body sizes; never sole carrier of information) | + +Panel accent palette (categorical, deliberately distinct from the rose and +teal node semantics; used for the header swatch, chart line, and mobile +chip): `#6366F1`, `#F59E0B`, `#0EA5E9`, `#8B5CF6`, `#EC4899`, `#84CC16`, +assigned by panel position, recycled on remove. + +Node encodings (shape carries meaning, color reinforces it; colorblind-safe +by shape alone): +- Forwarded: filled disc, `--spread`, r 4. +- Educated, not reached: donut, `--surface` fill, 2.2 px `--edu` stroke. +- Educated but reached (origin educated edge case): rose fill inside the + teal donut. +- Not reached: small disc, `--unreached`, r 3. +- Origin: rose disc with a 1.4 px rose halo ring at r 7.5. + +Type: `Inter, system-ui, sans-serif` (self-host Inter via fontsource; +system-ui fallback). Hero h1 30/700/-0.022em (22 on mobile). Panel +percentage 40/700 tabular. Section headings 13/600 uppercase tracked. +Body 15, secondary 13. All numerals `font-variant-numeric: tabular-nums`. + +Surfaces: radius 16 (cards) / 10 (buttons, fields) / 999 (pills, player). +Shadows: resting `0 1px 2px rgba(15,23,42,.05), 0 4px 16px -8px +rgba(15,23,42,.08)`; lifted (hover, modal, mobile player) deeper per +mockup. Spacing on an 8 px-ish scale as in the mockup; page max-width +1240 px, padding 28 px. + +Number formatting: one shared `formatPct` (round to whole percent) used by +hero, panels, strip, chart labels, and table. No other rounding anywhere. + +Network rendering: inline SVG (not canvas). Layout via d3-force +(`forceLink`, `forceManyBody`, `forceCenter`, collision), run +synchronously for a fixed 300 ticks with `simulation.randomSource( +mulberry32(graphSeed))` and seeded initial positions, so identical configs +give identical pictures on every machine (the shared-URL guarantee). +Layout results cached per graph hash. + +--- + +## 7. URL state scheme + +Query parameters, human-readable, no encoded blob: + +- Base config: each field by its JSON name, only when it differs from the + preset base: `?forwardProb=0.5&graphSeed=99`. +- Panels: one `panel=` param per panel (repeated params), value is + `label~strategy~overrides` with overrides as comma-separated + `field:value` pairs, URL-encoded: + `panel=No%20program~none~` + `panel=Big%20budget~random~numEducated:60`. + When the panel set equals the preset's, all `panel=` params are omitted. +- Focus: `focus=` when the modal is open. +- Unknown params are ignored; malformed state falls back to the preset + with the ErrorBanner notice (5.1). + +--- + +## 8. Accessibility acceptance criteria + +Testable checklist; "both themes" means verified in light and dark. + +- [ ] Every interactive control (toolbar buttons, kebab menus, popover + fields, sliders, player controls, scrubber, collapsibles, modal, + theme toggle) is reachable and operable with keyboard only, in a + logical DOM order, with no trap outside the modal. +- [ ] Visible focus indicator on every focusable element: 2 px outline in + `--ink` with 2 px offset, both themes. +- [ ] Player keyboard map works as specced (Space, ArrowLeft/Right, + Home/End); scrubber is a native range input announcing + "Round x of y" via `aria-valuetext`. +- [ ] `prefers-reduced-motion: reduce` disables autoplay, tweening, hover + lift, and crossfades; stepping and scrubbing remain fully usable. +- [ ] All informative text meets WCAG 2.1 AA contrast (4.5:1, or 3:1 at + 18 px+/bold) against its background, both themes; `--ink-4` is never + the sole carrier of information. +- [ ] Node state is never encoded by color alone (shape encodings of + section 6 verified with a deuteranopia simulator on both themes). +- [ ] Network SVGs are `aria-hidden="true"`; each panel is a `region` + whose accessible name is its label and whose text content (big + percentage + meta row) carries the result. +- [ ] ResultsTable renders the live per-panel numbers as a real `` + with `
` headers (evolved from the existing + ComparisonTable), collapsed behind a disclosure reachable by + keyboard. +- [ ] One `aria-live="polite"` region announces results after each + successful run ("Updated: No program 83 percent, ..."), on pause, + on scrub release, and at playback end ("Final: ..."); it does not + announce every round during playback. +- [ ] FocusModal: `role="dialog"` `aria-modal="true"`, labelled by the + panel name, focus moves in on open, is trapped, Esc closes, focus + returns to the opener. +- [ ] Popovers (About, panel editor, export menu) follow the same + open/trap/restore rules as menus or dialogs per their role. +- [ ] ErrorBanner and inline 400 errors use `role="alert"`. +- [ ] Touch targets are at least 44x44 px on mobile (player buttons, + kebabs, chips, strip items); node hit halos at least 24 px. +- [ ] ``, landmarks: `header` (app bar), `main`, `footer`. +- [ ] Zoom to 200% at 1366 px wide loses no content or functionality + (panels reflow to the mobile stack). + +--- + +## 9. Responsive behavior + +Breakpoint: 760 px (one breakpoint only). + +Mobile (< 760 px): +- App bar keeps wordmark + theme + source; DisclaimerBadge hides, replaced + by the DisclaimerNote line under the hero. +- MobileScoreStrip appears: sticky under the app bar, horizontally + scrollable pill chips, one per panel (short label + colored %); tapping + a chip smooth-scrolls to its panel (instant under reduced motion). +- PanelGrid stacks single-column; toolbar stays one row (Export collapses + to an icon button if needed). +- PlayerBar fixes to the bottom edge (12 px inset, lifted shadow); step + buttons and the speed pill hide, leaving replay, play/pause, scrubber, + round counter. Page bottom padding clears it. +- Lower grid stacks: chart, then controls. Control rows go single-column + (label above slider, value right-aligned). +- FocusModal becomes a full-screen sheet. +- No horizontal scrolling at 320 px width; the strip is the only + horizontally scrollable element. + +Desktop >= 760 px matches the wireframe; the panel grid is +`repeat(3, 1fr)` for up to 3 panels, wraps to two rows of up to 3 beyond. + +--- + +## 10. Implementation slices (ordered) + +Each slice leaves the app shippable. + +1. **API topology**: add `POST /api/scenario` (config + strategy in, + result + edges out) in Go, tygo regen, tests for edge determinism. +2. **Store + URL**: `useSimStore` with preset loading, URL parse/serialize, + debounced parallel runs, atomic swap, keep-last-good error states. +3. **Static dashboard**: app bar, hero with live numbers, theme system and + tokens, panel cards with seeded d3-force NetworkView at final state, + legend, footer. (The page now beats the parity page; ComparisonTable + becomes the collapsed ResultsTable.) +4. **Playback**: usePlayback, PlayerBar, round-state rendering, autoplay + once, reduced-motion path, keyboard map. +5. **Reach chart**: overlaid cumulative curves, accents, playhead synced + to round. +6. **Controls**: lever sliders, advanced fields, seed rerolls, inline 400 + handling, ErrorBanner. +7. **Panel management**: add/duplicate/rename/remove, PanelEditorPopover + with override chips, 6-panel cap. +8. **Focus modal** with docked player and URL focus state. +9. **Node tooltip** with cross-panel echo and touch hit halos. +10. **Mobile pass**: score strip, fixed player, stacking, touch targets. +11. **Export**: JSON, CSV, PNG snapshot. +12. **Accessibility audit**: walk the section 8 checklist, fix, record + results in the PR description. + +Post-M3 (specced hooks, not built): `POST /api/optimize` goal-seek UI, +animated WebM/GIF export, additional study presets. + +--- + +## 11. Rejected alternatives + +- Single large network with strategy tabs: hides the comparison behind + clicks; the side-by-side comparison is the message. +- Fixed three-strategy panels only: cannot express budget or world + variations the dynamic-panel model needs. +- Fully independent config per panel: breaks layout comparability and + bloats URL and editor; overrides express the same power against a shared + base. +- Hardcoded study copy: blocks reuse for other research questions; preset + file chosen instead. +- Expand-in-place or routed focus view: modal chosen by user; routing + would pull in Vue Router for one interaction. +- Share button with clipboard toast: user chose address-bar-only sharing. +- Explicit Run button (and hybrid auto/manual): kills the + cause-and-effect immediacy that makes the lever visceral. +- Looping autoplay: hostile motion while reading; play-once chosen. +- Per-panel players: panels drift out of sync and the comparison dies. +- Per-panel sparklines and numbers-only metrics: hard to compare; one + overlaid chart chosen. +- Budget sweep / optimizer in M3: doubles scope; reserved as API hook. +- Animated GIF/WebM export in M3: as much work as the whole animation + feature; PNG + live share URL cover the need. +- Dark-only or light-only theme: both ship, prototype renders exist for + both and the toggle is cheap. +- Canvas/WebGL network rendering: SVG is ample at 120 nodes x 6 panels + and is easier to theme, hit-test, and export. +- Batch `/api/scenarios` endpoint: per-panel `POST /api/scenario` with + `Promise.all` is simpler and lets panels rerun independently. +- Base64 state blob in URL: readable repeated params chosen for a FOSS + demo; blob survives only as a fallback idea if URLs ever exceed limits. +- Fully focusable network nodes for screen readers: 120 tab stops x N + panels is worse than the table; aria-hidden SVG + live region + table + chosen. +- Pinia and Vue Router: not required by this design; module-scope + composable store and modal focus suffice. diff --git a/internal/api/api.go b/internal/api/api.go index 11d7107..080bd64 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -19,6 +19,23 @@ type ComparisonResponse struct { Results []engine.Result `json:"results"` } +// ScenarioRequest is the body of POST /api/scenario: one panel's effective +// config plus its strategy. +type ScenarioRequest struct { + Config engine.Config `json:"config"` + Strategy engine.Strategy `json:"strategy"` +} + +// ScenarioResponse carries everything one dashboard panel needs: the +// echoed config, the cascade result, and the network topology as +// [from, to] node-index pairs (deterministic from the config's graph +// fields) for the frontend's force layout. +type ScenarioResponse struct { + Config engine.Config `json:"config"` + Result engine.Result `json:"result"` + Edges [][2]int `json:"edges"` +} + // errorResponse is the JSON shape of every non-2xx body. type errorResponse struct { Error string `json:"error"` @@ -31,6 +48,7 @@ func NewServer() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/config/default", handleDefaultConfig) mux.HandleFunc("POST /api/comparison", handleComparison) + mux.HandleFunc("POST /api/scenario", handleScenario) return mux } @@ -62,6 +80,30 @@ func handleComparison(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, ComparisonResponse{Config: config, Results: results}) } +func handleScenario(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var request ScenarioRequest + if err := decoder.Decode(&request); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request: %w", err)) + return + } + + result, err := engine.RunScenario(request.Config, request.Strategy) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + // Building the graph again for its edges costs ~25us (seeded, so it is + // the identical world the scenario ran in). + edges, err := engine.GraphEdges(request.Config) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, ScenarioResponse{Config: request.Config, Result: result, Edges: edges}) +} + // writeJSON marshals first and writes after, so an encoding failure can // still become a clean 500 instead of a half-written body. func writeJSON(w http.ResponseWriter, status int, payload any) { diff --git a/internal/api/scenario_test.go b/internal/api/scenario_test.go new file mode 100644 index 0000000..546ab5d --- /dev/null +++ b/internal/api/scenario_test.go @@ -0,0 +1,87 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/JustinZeus/spreadlab/internal/engine" +) + +func postScenario(t *testing.T, request ScenarioRequest) *json.Decoder { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + recorder := serve(t, http.MethodPost, "/api/scenario", body) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body) + } + return json.NewDecoder(recorder.Body) +} + +func TestScenarioEndpointReturnsResultAndTopology(t *testing.T) { + request := ScenarioRequest{Config: engine.DefaultConfig(), Strategy: engine.StrategyMostConnected} + + var response ScenarioResponse + if err := postScenario(t, request).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Result.NumReached != 7 { // the pinned golden value + t.Errorf("NumReached = %d, want 7", response.Result.NumReached) + } + if response.Config != request.Config { + t.Errorf("config not echoed: got %+v", response.Config) + } + if len(response.Edges) == 0 { + t.Fatal("no edges returned") + } + for _, edge := range response.Edges { + if edge[0] >= edge[1] || edge[1] >= request.Config.NumStudents { + t.Fatalf("invalid edge %v", edge) + } + } + + // Determinism across requests is the shared-URL guarantee. + var repeat ScenarioResponse + if err := postScenario(t, request).Decode(&repeat); err != nil { + t.Fatal(err) + } + if len(repeat.Edges) != len(response.Edges) || repeat.Edges[0] != response.Edges[0] { + t.Error("edges differ across identical requests") + } +} + +func TestScenarioEndpointRejectsBadRequests(t *testing.T) { + valid := engine.DefaultConfig() + invalid := valid + invalid.ForwardProb = 2.0 + + encode := func(request ScenarioRequest) []byte { + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + return body + } + + tests := []struct { + name string + body []byte + }{ + {name: "not json", body: []byte("not json")}, + {name: "unknown field", body: []byte(`{"config":{},"strategy":"none","extra":1}`)}, + {name: "unknown strategy", body: encode(ScenarioRequest{Config: valid, Strategy: "telepathy"})}, + {name: "invalid config values", body: encode(ScenarioRequest{Config: invalid, Strategy: engine.StrategyNone})}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + recorder := serve(t, http.MethodPost, "/api/scenario", testCase.body) + if recorder.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body: %s", recorder.Code, http.StatusBadRequest, recorder.Body) + } + }) + } +} diff --git a/internal/engine/graph.go b/internal/engine/graph.go index e6b57e7..a2c6cf8 100644 --- a/internal/engine/graph.go +++ b/internal/engine/graph.go @@ -48,3 +48,18 @@ func (g *Graph) Degree(u int) int { return len(g.adj[u]) } // Neighbors returns u's neighbours in insertion order. The slice is the // graph's own storage: callers must not modify it. func (g *Graph) Neighbors(u int) []int { return g.adj[u] } + +// Edges returns every undirected edge exactly once as a [from, to] pair +// with from < to, in deterministic node order. The slice is freshly +// allocated; callers may keep it. +func (g *Graph) Edges() [][2]int { + edges := make([][2]int, 0, g.edges) + for node := range g.NumNodes() { + for _, neighbor := range g.adj[node] { + if node < neighbor { + edges = append(edges, [2]int{node, neighbor}) + } + } + } + return edges +} diff --git a/internal/engine/graph_test.go b/internal/engine/graph_test.go index 56124b3..2e14acb 100644 --- a/internal/engine/graph_test.go +++ b/internal/engine/graph_test.go @@ -74,3 +74,56 @@ func TestGraphNeighborsAndDegree(t *testing.T) { t.Error("HasEdge(1, 2) = true, want false") } } + +func TestGraphEdgesListsEveryEdgeOnce(t *testing.T) { + graph := NewGraph(4) + graph.AddEdge(2, 1) // insertion order must not matter; pairs come out from < to + graph.AddEdge(0, 1) + graph.AddEdge(3, 0) + + want := [][2]int{{0, 1}, {0, 3}, {1, 2}} + if got := graph.Edges(); !slices.Equal(got, want) { + t.Errorf("Edges() = %v, want %v", got, want) + } +} + +func TestGraphEdgesMatchesGeneratedGraph(t *testing.T) { + graph, err := HolmeKim(60, 3, 0.45, newRand(17)) + if err != nil { + t.Fatal(err) + } + + edges := graph.Edges() + if len(edges) != graph.NumEdges() { + t.Fatalf("len(Edges()) = %d, want NumEdges() = %d", len(edges), graph.NumEdges()) + } + for _, edge := range edges { + from, to := edge[0], edge[1] + if from >= to { + t.Errorf("edge %v: want from < to", edge) + } + if to >= graph.NumNodes() { + t.Errorf("edge %v: endpoint out of range", edge) + } + if !graph.HasEdge(from, to) { + t.Errorf("edge %v not present in adjacency", edge) + } + } +} + +func TestGraphEdgesDeterministicFromConfig(t *testing.T) { + first, err := GraphEdges(DefaultConfig()) + if err != nil { + t.Fatal(err) + } + second, err := GraphEdges(DefaultConfig()) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(first, second) { + t.Error("GraphEdges() differs across identical configs") + } + if len(first) == 0 { + t.Error("GraphEdges() returned no edges") + } +} diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go index 89262a4..37d2e59 100644 --- a/internal/engine/scenario.go +++ b/internal/engine/scenario.go @@ -60,6 +60,18 @@ type Result struct { ReachedPct float64 `json:"reachedPct"` } +// GraphEdges builds the world's social network from the config's graph +// fields and returns its undirected edge list. The same config always +// yields the same edges (seeded generator), so the API can expose +// topology separately without every Result carrying it. +func GraphEdges(config Config) ([][2]int, error) { + graph, err := HolmeKim(config.NumStudents, config.EdgesPerNode, config.TriangleProb, newRand(config.GraphSeed)) + if err != nil { + return nil, err + } + return graph.Edges(), nil +} + // RunScenario builds the world the config describes (network plus edge // thresholds), picks the educated students per strategy, and runs the // cascade. Scenarios with the same config share the same world, so diff --git a/tygo.yaml b/tygo.yaml index 7db3727..024b496 100644 --- a/tygo.yaml +++ b/tygo.yaml @@ -11,7 +11,8 @@ packages: include_files: - "api.go" frontmatter: | - import type { Config, Result } from "./engine"; + import type { Config, Result, Strategy } from "./engine"; type_mappings: engine.Config: "Config" engine.Result: "Result" + engine.Strategy: "Strategy" diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 81f10f3..34811ec 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,5 @@ // Code generated by tygo. DO NOT EDIT. -import type { Config, Result } from "./engine"; +import type { Config, Result, Strategy } from "./engine"; ////////// // source: api.go @@ -18,3 +18,22 @@ export interface ComparisonResponse { config: Config; results: Result[]; } +/** + * ScenarioRequest is the body of POST /api/scenario: one panel's effective + * config plus its strategy. + */ +export interface ScenarioRequest { + config: Config; + strategy: Strategy; +} +/** + * ScenarioResponse carries everything one dashboard panel needs: the + * echoed config, the cascade result, and the network topology as + * [from, to] node-index pairs (deterministic from the config's graph + * fields) for the frontend's force layout. + */ +export interface ScenarioResponse { + config: Config; + result: Result; + edges: number /* int */[][]; +}