A typed, seedable grammar for moral dilemmas. Think faker.js, but for the trolley problem.
Switchyard treats the trolley problem not as a webpage with a lever, but as a composable grammar. Every scenario it produces serializes to two things at once: a clean machine-readable dilemma spec, and a restrained editorial diorama. It's the upstream library other things import — ethics evals, teaching demos, games, quizzes — so nobody has to hand-author moral scenarios ever again.
The trolley problem stopped being a thought experiment the day we started shipping autonomous agents. This is the infrastructure for that.
import { generate, asValid, scoreAll, renderDiorama } from "@switchyard/core";
const d = asValid(generate(42)); // a reproducible dilemma from a seed
console.log(scoreAll(d)); // five ethical frameworks, each a verdict
await writeFile("d.svg", renderDiorama(d)); // ...and a diorama of the same sceneNo build step. Node 22.6+ runs it straight from TypeScript:
node examples/demo.tsMost trolley projects store the answer. Switchyard derives everything that matters, so the spec can't lie and the verdicts aren't hand-waved.
Who-dies dimensions live on the Party noun; how-they-die dimensions live on the Choice. The generator varies count, identity, desert, consent, mechanism, and personal force independently.
This is the heart of the design. switch and footbridge have identical 5-vs-1 counts — the whole philosophical fight is about how the one dies. Switchyard never stores that label; validate() computes it:
| variant | mechanism | derived causation | deontology |
|---|---|---|---|
switch |
reroute (no loop) | side_effect |
permits |
yard |
reroute to a dead-end spur | side_effect |
permits |
loop |
reroute — but the track loops back | means |
forbids |
footbridge |
push a body onto the track | means |
forbids |
transplant |
harvest organs | means |
forbids |
The loop is the tell: it's a lever, just like the switch — but because the side track rejoins the main line, the victim's body is what stops the trolley, so the grammar derives means from topology alone. You cannot author a footbridge that pretends to be a switch.
asValid(d) returns a branded ValidDilemma or throws with every violation listed. Only valid specs can be scored or rendered — incoherent dilemmas ("divert a static surgeon", a means that rescues no one) can't reach a consumer.
utilitarian, deontological, virtue, contractualist, care — each a pure (Dilemma) => FrameworkResult. The interesting dilemmas are the contested ones, where the five disagree:
dilemma contested? utilitarian / deontological / virtue / contractualist / care
switch — act act act act act
footbridge yes act wait act wait act
loop yes act wait act wait act
transplant yes act wait act wait act
Register your own framework with the same shape.
interface Party { count; identity; desert; consent; agency } // WHO is at stake
interface Hazard { kind; stoppableBy; motion } // WHAT does the killing
interface Choice { mechanism; force; proximity; reliability; fates } // HOW you may act
interface Dilemma { hazard; parties; baselineVictimIds; choices; topology? }fatesis a total map — every choice accounts for every party, which is what makes triage (a partition, not a single victim) first-class and lets verdicts be derived.baselineVictimIdsis the fixed anchor for doing vs. allowing.topologyis render-only geometry. Scorers never read it; renderers never read the moral fields.domainis a free "skin" —rail,hospital,lifeboat,datacenter. The footbridge and the transplant ward are the same logical shape, different skin.
See spec/dilemma-spec.md for the full format and DESIGN.md for why it's shaped this way.
All ten ship as fixtures in src/canon.ts — the proof that the primitives regenerate what philosophers actually argue over: switch, footbridge, loop, fat-villain, yard, consenting, transplant, tunnel (probabilistic), triage (partition), alignment (an agent's directive).
Because every dilemma is a parametric program, you can do the one thing a hand-authored scenario can't: hold the moral structure fixed, change only morally-irrelevant details — reorder the options, rename the victims, switch to passive voice, swap a neutral verb for a loaded one, spell "5" as "five" — run a model across all of them, and measure where its verdict flips.
Stable values, or just vibes? A coherent agent answers the same dilemma the same way however you word it. A flip under cosmetic reframing is, objectively, an inconsistency — which is why the metric is defensible where "did it pick the right answer" is not.
node scripts/probe.ts # offline — 3 simulated personalities
ANTHROPIC_API_KEY=sk-... node scripts/probe.ts claude-opus-4-8 claude-haiku-4-5 # live, multi-modelIt writes a per-model consistency strip per dilemma, a out/probe.compare.svg ranking the models, and a out/probe-report.md flip-rate table. This is a consistency probe, not a correctness benchmark — consistency is necessary, not sufficient. See PROBE.md for the reframing taxonomy, the metric, and an honest limitations section.
renderDiorama(d, { style }) emits the same dilemma in any of four visual languages — each a pure function of the spec, so the count drives the crowd/ink-weight, the derived causation drives the "used as a means" stamp, and the mechanism picks a push gesture vs. a diverting fork.
renderDiorama(d, { style: "risograph" }); // constructivist agitprop poster (the flagship)
renderDiorama(d, { style: "inkwash" }); // 水墨 — ink weight encodes the death toll
renderDiorama(d, { style: "animated" }); // SMIL loop: the trolley rolls, the tally ticks
renderDiorama(d, { style: "editorial" }); // the clean rail schematic (default)node examples/demo.ts renders the whole canon in every style to out/styles/. Because each style reads the spec rather than a fixed scene, a footbridge (means) and a switch (side_effect) come out visibly different — and seed 9,999 new dilemmas, get 9,999 coherent posters.
gallery/ is the storefront. A GitHub Action (daily.yml) seeds one dilemma from each date, renders every style, commits the frame, and rebuilds a contact-sheet — so the repo grows its own art, one dilemma a day, with zero effort. Every frame is reproducible from its date:
node scripts/gallery.ts # today
node scripts/gallery.ts 2026-06-24 # any day, deterministic- Five Hands — a renderer where each ethical framework draws the same dilemma its own way (utilitarian sizes by headcount, deontology draws inviolable boundaries). Consumes this spec.
@switchyard/py— a Python port of the spec + scorers, for the eval crowd.Consistency fuzzer— shipped. SeePROBE.md: dial one morally-irrelevant variable and re-run to find where a model's verdict silently flips. (The one benchmark wedge the labs haven't taken.)
MIT