Compare commits

4 Commits

Author SHA1 Message Date
Julien Calixte
0c8a0c294f feat(editor): browse samples in a categorized modal gallery
Replace the cramped Samples dropdown with a modal that lays the gallery
out as a card grid, sectioned by tier, so the growing set stays scannable
and the learning order is visible at once.
2026-06-22 23:47:16 +02:00
Julien Calixte
37e3a1e89f feat(samples): tag each sample with a pedagogical category 2026-06-22 23:47:10 +02:00
Julien Calixte
914cf01763 refactor(samples): lay the cobra effect out left-to-right
Move Farmed cobras to the left so the story reads in order (farm engine
-> release bridge -> wild -> cull) and the release bridge no longer
doubles back across the canvas. Positions only; dynamics unchanged.
2026-06-22 23:33:14 +02:00
Julien Calixte
fe17bc8fae feat(samples): add "Success to the successful" competitive-exclusion trap
Two equal researchers and one conserved prize (the field's attention):
a single gap-driven Flow tilts it to whoever is a hair ahead, so a 51-49
near-tie locks in to ~100-0. Escalation's zero-sum twin -- the same
two-Stock all-Reinforcing structure, concentrating instead of exploding.
2026-06-22 23:33:06 +02:00
3 changed files with 260 additions and 53 deletions

View File

@@ -29,7 +29,7 @@ import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback" import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io" import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection" import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples" import type { Sample } from "@/model/samples"
import { canConnect } from "@/model/validation" import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model" import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd" import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
@@ -38,6 +38,7 @@ import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue" import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue" import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue" import ResultsPanel from "./ResultsPanel.vue"
import SampleBrowser from "./SampleBrowser.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue" import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue" import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue" import CloudNode from "./nodes/CloudNode.vue"
@@ -55,6 +56,9 @@ const edges = computed(() => graph.value.edges)
// the Model and recomputes reactively while open. // the Model and recomputes reactively while open.
const showResults = ref(false) const showResults = ref(false)
// The sample-browser modal (opened from the header "Samples" button).
const browserOpen = ref(false)
// The playback clock that advances the playhead while a run is playing (mounted // The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives). // once here; the simulation store holds the state it drives).
usePlayback() usePlayback()
@@ -270,12 +274,12 @@ function onDragOver(event: DragEvent): void {
*/ */
function loadSample(sample: Sample): void { function loadSample(sample: Sample): void {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) { if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
// Cancelled: leave the model untouched and the browser open to pick again.
return return
} }
fitAfterInit = true fitAfterInit = true
store.setModel(sample.build()) store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus). browserOpen.value = false
;(document.activeElement as HTMLElement | null)?.blur()
} }
const fileInput = useTemplateRef<HTMLInputElement>("fileInput") const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
@@ -397,20 +401,7 @@ onBeforeUnmount(() => {
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }} {{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span> </span>
<div class="ml-auto flex items-center gap-1"> <div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end"> <button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-40 mt-1 max-h-80 w-72 flex-nowrap gap-1 overflow-y-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</li>
</ul>
</div>
<button <button
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }" :class="{ 'btn-active': showResults }"
@@ -504,5 +495,7 @@ onBeforeUnmount(() => {
the canvas to the space above, where the diagram re-fits (see the the canvas to the space above, where the diagram re-fits (see the
`dimensions` watcher in <script>). --> `dimensions` watcher in <script>). -->
<ResultsPanel v-if="showResults" @close="showResults = false" /> <ResultsPanel v-if="showResults" @close="showResults = false" />
<SampleBrowser v-model:open="browserOpen" @select="loadSample" />
</div> </div>
</template> </template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
/**
* Sample browser — a modal gallery of the curated example models, laid out as a
* card grid sectioned by pedagogical tier (Primer → … → Mechanics & capstone).
* As the gallery grows this beats the old narrow dropdown: the whole set stays
* scannable in one scroll and the tiers show how the samples relate.
*
* Presentational only — it never touches the Model store. Picking a card emits
* `select`; the Editor owns the confirm/replace/fit logic (one place for it).
* Built on the native <dialog> so Esc, the backdrop click and focus-trapping come
* for free; `open` drives showModal()/close() and the native close event keeps the
* parent's state in sync.
*/
import { computed, useTemplateRef, watch } from "vue"
import { type Sample, SAMPLE_CATEGORIES, SAMPLES } from "@/model/samples"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ "update:open": [boolean]; select: [Sample] }>()
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
// Mirror the `open` prop onto the native dialog (showModal gives us the focus trap
// and inert backdrop a plain div can't).
watch(
() => props.open,
(open) => {
const el = dialog.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
},
)
// Fired by Esc, the backdrop form, the ✕, or our own close() — fold them all into
// one update:open so the parent's state can't drift from the dialog's.
function onClose(): void {
if (props.open) emit("update:open", false)
}
// The categories in display order, each with its samples (SAMPLES is already in
// pedagogical order, so first-appearance order is preserved). Empty tiers drop out.
const sections = computed(() =>
SAMPLE_CATEGORIES.map((category) => ({
category,
samples: SAMPLES.filter((sample) => sample.category === category),
})).filter((section) => section.samples.length > 0),
)
</script>
<template>
<dialog ref="dialog" class="modal" @close="onClose">
<div class="modal-box w-11/12 max-w-3xl">
<div class="mb-4 flex items-center gap-2">
<h3 class="text-base font-semibold">Browse samples</h3>
<span class="text-xs text-base-content/50">{{ SAMPLES.length }} models</span>
<form method="dialog" class="ml-auto">
<button class="btn btn-circle btn-ghost btn-sm" aria-label="Close"></button>
</form>
</div>
<section v-for="section in sections" :key="section.category" class="mb-5 last:mb-0">
<h4 class="mb-2 text-xs font-semibold tracking-wide text-base-content/50 uppercase">
{{ section.category }}
</h4>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="sample in section.samples"
:key="sample.title"
type="button"
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-3 text-left transition hover:border-primary hover:shadow-md"
@click="emit('select', sample)"
>
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</div>
</section>
</div>
<!-- Click outside the box to dismiss (submits the dialog fires @close). -->
<form method="dialog" class="modal-backdrop">
<button aria-label="Close">close</button>
</form>
</dialog>
</template>

View File

@@ -21,27 +21,31 @@
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate. * 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds. * 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
* *
* Last, four of Donella Meadows' system *traps* — structures that reliably misbehave * Last, five of Donella Meadows' system *traps* — structures that reliably misbehave
* (Thinking in Systems, ch. 5), to contrast the healthy dynamics above — and, paired * (Thinking in Systems, ch. 5), to contrast the healthy dynamics above — and, paired
* with the first, the cure that escapes it: * with the first, the cure that escapes it:
* *
* 8. Tragedy of the commons — two Reinforcing herds overgraze a *renewable* shared * 8. Tragedy of the commons — two Reinforcing herds overgraze a *renewable* shared
* Stock to bare dirt, then starve with it: all ruined. * Stock to bare dirt, then starve with it: all ruined.
* 9. …commons, fixed — the same renewable commons, but stocking is regulated * 9. …commons, fixed — the same renewable commons, but stocking is regulated
* against an agreed reserve: it holds, and the herds end * against an agreed reserve: it holds, and the herds end
* *larger* than the trap's boom leaves alive. * *larger* than the trap's boom leaves alive.
* 10. Escalation — a single Reinforcing loop spanning two Stocks, with * 10. Escalation — a single Reinforcing loop spanning two Stocks, with
* no brake in the structure: an arms race. * no brake in the structure: an arms race.
* 11. Fixes that fail — a fix drains the symptom Stock (B) while its side * 11. Success to the successful — two equal rivals split one conserved prize (the field's
* effect refills it (R): the cure feeds the disease. * attention); a gap-driven Flow tilts it to whoever leads,
* 12. Drift to low performance — a goal that erodes toward actual performance, so the * so a 5149 near-tie locks in to ~1000. Escalation's
* effort it drives never overcomes a steady decay: a * zero-sum twin — it concentrates instead of exploding.
* Reinforcing loop ratchets both downward. * 12. Fixes that fail — a fix drains the symptom Stock (B) while its side
* effect refills it (R): the cure feeds the disease.
* 13. Drift to low performance — a goal that erodes toward actual performance, so the
* effort it drives never overcomes a steady decay: a
* Reinforcing loop ratchets both downward.
* *
* Next, the dynamic the book is named for, and the one the gallery has saved until a * Next, the dynamic the book is named for, and the one the gallery has saved until a
* reader knows every piece it needs: * reader knows every piece it needs:
* *
* 13. Overshoot and collapse — a Reinforcing harvester on a *renewable* Resource with * 14. Overshoot and collapse — a Reinforcing harvester on a *renewable* Resource with
* an extinction threshold (an Allee floor): a fleet * an extinction threshold (an Allee floor): a fleet
* overshoots the renewal rate and pushes the fishery past * overshoots the renewal rate and pushes the fishery past
* the point of no return. The dark twin of "Limits to * the point of no return. The dark twin of "Limits to
@@ -50,14 +54,14 @@
* Last, the language pointed at a live debate — a classic trap (Shifting the burden to * Last, the language pointed at a live debate — a classic trap (Shifting the burden to
* the intervenor, ch. 5) wearing today's clothes: * the intervenor, ch. 5) wearing today's clothes:
* *
* 14. AI deskilling spiral — handing the burden of code quality to AI atrophies the * 15. AI deskilling spiral — handing the burden of code quality to AI atrophies the
* Expertise that holds quality up, so the team leans on AI * Expertise that holds quality up, so the team leans on AI
* harder and Technical debt spirals: addiction, not a fix. * harder and Technical debt spirals: addiction, not a fix.
* *
* And a mechanical coda — the gallery's one *hard* ceiling, to contrast the emergent * And a mechanical coda — the gallery's one *hard* ceiling, to contrast the emergent
* ones (above all "Limits to growth"): * ones (above all "Limits to growth"):
* *
* 15. Bathtub with an overflow — the tap runs flat out (a Constant inflow that never * 16. Bathtub with an overflow — the tap runs flat out (a Constant inflow that never
* reads the level) and a spillway carries whatever rises * reads the level) and a spillway carries whatever rises
* past the brim into a second Stock, the floor. The only * past the brim into a second Stock, the floor. The only
* model on the `overflow` rule — a one-sided Gap, the * model on the `overflow` rule — a one-sided Gap, the
@@ -67,7 +71,7 @@
* Then one last trap, held back until the overflow rule existed to carry it — the * Then one last trap, held back until the overflow rule existed to carry it — the
* language pointed at the most-told cautionary tale in systems thinking: * language pointed at the most-told cautionary tale in systems thinking:
* *
* 16. The cobra effect — a bounty on dead cobras (a Balancing fix) quietly funds a * 17. The cobra effect — a bounty on dead cobras (a Balancing fix) quietly funds a
* cobra *farm* (a Reinforcing engine); breeding outruns what * cobra *farm* (a Reinforcing engine); breeding outruns what
* the bounty can absorb, the farm gluts, and its now-worthless * the bounty can absorb, the farm gluts, and its now-worthless
* surplus spills through an `overflow` gate into the wild — * surplus spills through an `overflow` gate into the wild —
@@ -77,7 +81,7 @@
* *
* And the capstone — every piece at once, at the scale the whole gallery points toward: * And the capstone — every piece at once, at the scale the whole gallery points toward:
* *
* 17. World on a warming planet — the Club of Rome's World3 (Meadows et al., *The Limits * 18. World on a warming planet — the Club of Rome's World3 (Meadows et al., *The Limits
* to Growth*) in miniature, its pollution sector reframed * to Growth*) in miniature, its pollution sector reframed
* as a climate channel: four coupled Stocks where two * as a climate channel: four coupled Stocks where two
* Reinforcing engines (capital, population) overshoot a * Reinforcing engines (capital, population) overshoot a
@@ -104,10 +108,31 @@ import {
type SimSpec, type SimSpec,
} from "./types" } from "./types"
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */ /**
* The pedagogical tier a sample belongs to. Tiers run simplest-first and double as
* the section order in the sample browser (see SAMPLE_CATEGORIES).
*/
export type SampleCategory =
| "Primer"
| "Classics"
| "System traps"
| "Dynamic edge"
| "Mechanics & capstone"
/** The categories in display order, simplest tier first. */
export const SAMPLE_CATEGORIES: SampleCategory[] = [
"Primer",
"Classics",
"System traps",
"Dynamic edge",
"Mechanics & capstone",
]
/** A loadable example: a title, one-line blurb and tier for the browser, plus a builder. */
export interface Sample { export interface Sample {
title: string title: string
blurb: string blurb: string
category: SampleCategory
build: () => Model build: () => Model
} }
@@ -943,6 +968,82 @@ function escalation(): Model {
) )
} }
/**
* Success to the successful — Donella Meadows' competitive-exclusion trap (Thinking
* in Systems, ch. 5), known in the sociology of science as the *Matthew effect*
* (Robert Merton, after Matthew 25:29: "unto every one that hath shall be given").
* Two researchers of equal ability compete for one finite thing — the field's
* attention — and the only difference between them is that A begins a single point
* ahead (Renown 51 vs 49 of a fixed 100).
*
* The community's attention is a *conserved* budget: a citation, an invitation, a
* grant that goes to one does not go to the other, so Renown A + Renown B never
* leaves 100. The marginal piece of it drifts to whoever is already better known —
* `attention = factor × (Renown A Renown B)`, a single Stock→Stock Flow draining
* the lesser name into the greater. There are no clouds: nothing enters or leaves
* the system, it only *redistributes*. That one valve carries *two* Reinforcing
* loops — Renown A → [+] → attention → (fills A) → Renown A (more renown wins more
* attention), and Renown B → [] → attention → (drains B) → Renown B (the falling
* name is forgotten ever faster). Neither loop has a surviving ``, so both are
* Reinforcing: nothing brakes the gap, and it can only widen.
*
* A tie is therefore an *unstable* equilibrium — the knife-edge this trap balances
* on. A's one-point lead is enough: attention tilts to A, the gap grows, attention
* tilts harder, and the field locks in. Renown A climbs 51 → 100 and Renown B fades
* 49 → 0 by t≈42 — a near-total monopoly of acclaim, though the two were equally
* able. The cruel lesson Meadows draws: where the prize for winning is more power to
* win, it is *initial conditions*, not merit, that decide the outcome.
*
* Contrast "Escalation", the gallery's other two-Stock all-Reinforcing trap: there
* the cross-loop has no minus and *both* arsenals blow up together (positive-sum,
* unbounded); here the single loop is zero-sum — A rises only as fast as B is drained
* — so the same Reinforcing structure *concentrates* instead of exploding. (The cure
* Meadows prescribes is to break the coupling — level the field, diversify the prize,
* or handicap the leader — none of which the structure here contains: that is the trap.)
*/
function successToTheSuccessful(): Model {
// Two Stocks on one line, the lone attention valve nudged above their midpoint so
// the pipe (Renown B → attention → Renown A) and the two info links (each Stock
// into the valve) read apart instead of overlapping. Hand-placed, not at midpoint.
const renownB = makeStock({ x: -260, y: 80 }, "Renown B")
renownB.initialValue = 49
renownB.description =
"Researcher B's standing — equal in ability to A, and a single point behind at the start. The lesser name the attention drains away from, fading to nothing though B never did anything wrong."
const renownA = makeStock({ x: 260, y: 80 }, "Renown A")
renownA.initialValue = 51
renownA.description =
"Researcher A's standing — one point ahead at the start, and that hair's lead is all it takes. Attention concentrates here until A holds nearly the whole field's acclaim."
// attention = factor × (Renown A Renown B): a Gap whose `+` level is A and ``
// target is B. Source B, target A, so a positive rate drains the lesser name into
// the greater — and since draining B only widens the gap, the rate never reverses.
const attention = makeFlow({ x: 0, y: -120 }, "attention", renownB.id, renownA.id)
attention.rule = { kind: "gap", factor: 0.05 }
attention.description =
"The field's finite attention drifting to the better-known name, ∝ the lead (Renown A Renown B). A conserved transfer: what A gains, B loses — the one valve through which both Reinforcing loops run."
return model(
"Success to the successful",
[renownB, renownA, attention],
[
link(
renownA,
attention,
"+",
"Renown A is the level in the gap: the further ahead A is, the faster attention flows its way. With the inflow it fills, this closes A's Reinforcing loop — the rich getting richer.",
),
link(
renownB,
attention,
"-",
"Renown B is the target the gap is measured against (the input). As B fades the gap only widens, so it is drained faster still — a second Reinforcing loop, the same minus on both the link and the outflow.",
),
],
// From a 5149 near-tie the gap runs away: Renown A climbs 51 → ~100 and Renown B
// fades 49 → ~0 by t≈42, then both hold — a near-total monopoly of acclaim won on a
// one-point head start. The unstable 5050 knife-edge a hair's difference tips.
{ start: 0, stop: 50, dt: 1 },
)
}
/** /**
* Fixes that fail — the archetype where a quick fix relieves a symptom but feeds it * Fixes that fail — the archetype where a quick fix relieves a symptom but feeds it
* through a side effect, so the symptom returns and the fix is reapplied for ever. * through a side effect, so the symptom returns and the fix is reapplied for ever.
@@ -1485,23 +1586,23 @@ function bathtubOverflow(): Model {
* is a Stock the reward built, the brake is the overflow gate, and the damage is permanent. * is a Stock the reward built, the brake is the overflow gate, and the damage is permanent.
*/ */
function cobraEffect(): Model { function cobraEffect(): Model {
// The policy lever (bounty) sits up top, centre, wired into three flows at once: the // Laid out left→right: the farm engine on the left (a Source breeds Farmed cobras,
// intended cull of Wild cobras on the left, and on the right the farm's breeding and // harvest draining up to a Sink), the release bridge in the centre spilling the glut
// cash-out. The release bridge dips below, carrying the farm's glut back across to the // rightward into Wild cobras, and the bounty-driven cull running off to the right. The
// wild, with glut hanging beneath it. Valves are hand-placed, not at midpoints, so // policy lever (bounty) sits up top, wired down into all three flows — cull, breeding,
// every Information Link lands in open space. // cash-out — with glut beneath the bridge it gates. Hand-placed valves keep links clear.
const bounty = makeConverter({ x: 0, y: -300 }, "bounty") const bounty = makeConverter({ x: -80, y: -340 }, "bounty")
bounty.rule = { kind: "constant", value: 1 } bounty.rule = { kind: "constant", value: 1 }
bounty.description = bounty.description =
"The reward paid per dead cobra — held at 1, a normalised policy lever, so every rate here scales with it (a bigger bounty would only run the whole story faster). One lever wired into three flows: the cull, the breeding, and the cash-out." "The reward paid per dead cobra — held at 1, a normalised policy lever, so every rate here scales with it (a bigger bounty would only run the whole story faster). One lever wired into three flows: the cull, the breeding, and the cash-out."
// The intended fix: a bounty-driven cull empties the streets of wild cobras. // The intended fix: a bounty-driven cull empties the streets of wild cobras.
const wild = makeStock({ x: -360, y: -40 }, "Wild cobras") const wild = makeStock({ x: 380, y: -40 }, "Wild cobras")
wild.initialValue = 100 wild.initialValue = 100
wild.description = wild.description =
"The actual problem the bounty targets — hunted down at first, then overrun once the farm's surplus is loosed on it. It has no inflow of its own, so any rebound can only be the farm's doing." "The actual problem the bounty targets — hunted down at first, then overrun once the farm's surplus is loosed on it. It has no inflow of its own, so any rebound can only be the farm's doing."
const cullSink = makeCloud({ x: -680, y: -40 }) const cullSink = makeCloud({ x: 920, y: -40 })
const culling = makeFlow({ x: -520, y: -40 }, "culling", wild.id, cullSink.id) const culling = makeFlow({ x: 660, y: -40 }, "culling", wild.id, cullSink.id)
// culling = 0.16 × Wild × bounty: the Balancing drain, ∝ the stock. Fast enough to // culling = 0.16 × Wild × bounty: the Balancing drain, ∝ the stock. Fast enough to
// crash the wild population by t≈20 — the policy's whole visible success. // crash the wild population by t≈20 — the policy's whole visible success.
culling.rule = { kind: "proportional", factor: 0.16 } culling.rule = { kind: "proportional", factor: 0.16 }
@@ -1509,19 +1610,19 @@ function cobraEffect(): Model {
"Wild cobras killed for the bounty, ∝ Wild cobras × bounty — the intended Balancing fix that empties the streets at first." "Wild cobras killed for the bounty, ∝ Wild cobras × bounty — the intended Balancing fix that empties the streets at first."
// The perverse stock: a farm bred to cash in on the bounty. // The perverse stock: a farm bred to cash in on the bounty.
const farm = makeStock({ x: 360, y: -40 }, "Farmed cobras") const farm = makeStock({ x: -520, y: -40 }, "Farmed cobras")
farm.initialValue = 5 farm.initialValue = 5
farm.description = farm.description =
"Cobras bred to cash in on the bounty — the Stock the reward calls into being. It compounds unseen while the streets look clear, then spills its surplus into the wild." "Cobras bred to cash in on the bounty — the Stock the reward calls into being. It compounds unseen while the streets look clear, then spills its surplus into the wild."
const breedSrc = makeCloud({ x: 680, y: -40 }) const breedSrc = makeCloud({ x: -980, y: -200 })
const breeding = makeFlow({ x: 520, y: -40 }, "breeding", breedSrc.id, farm.id) const breeding = makeFlow({ x: -760, y: -200 }, "breeding", breedSrc.id, farm.id)
// breeding = 0.31 × Farm × bounty: Farm → breeding → Farm, no `` → the Reinforcing // breeding = 0.31 × Farm × bounty: Farm → breeding → Farm, no `` → the Reinforcing
// engine the bounty funds without meaning to. // engine the bounty funds without meaning to.
breeding.rule = { kind: "proportional", factor: 0.31 } breeding.rule = { kind: "proportional", factor: 0.31 }
breeding.description = breeding.description =
"The farm breeding more cobras, ∝ Farmed cobras × bounty — the Reinforcing engine the bounty funds without intending to: more breeding stock, more bred." "The farm breeding more cobras, ∝ Farmed cobras × bounty — the Reinforcing engine the bounty funds without intending to: more breeding stock, more bred."
const harvestSink = makeCloud({ x: 360, y: -260 }) const harvestSink = makeCloud({ x: 600, y: -220 })
const harvest = makeFlow({ x: 360, y: -140 }, "harvest", farm.id, harvestSink.id) const harvest = makeFlow({ x: 400, y: -220 }, "harvest", farm.id, harvestSink.id)
// harvest = 0.10 × Farm × bounty: farmed cobras killed and turned in — the rule beating, // harvest = 0.10 × Farm × bounty: farmed cobras killed and turned in — the rule beating,
// and a Balancing drain on the farm. // and a Balancing drain on the farm.
harvest.rule = { kind: "proportional", factor: 0.1 } harvest.rule = { kind: "proportional", factor: 0.1 }
@@ -1531,11 +1632,11 @@ function cobraEffect(): Model {
// The backfire bridge: once the farm gluts, its worthless surplus is dumped into the // The backfire bridge: once the farm gluts, its worthless surplus is dumped into the
// wild. A one-sided overflow gate (shut below the glut) — no scripted policy reversal // wild. A one-sided overflow gate (shut below the glut) — no scripted policy reversal
// needed; the release falls out of the farm outgrowing its own worth. // needed; the release falls out of the farm outgrowing its own worth.
const glut = makeConverter({ x: 0, y: 300 }, "glut") const glut = makeConverter({ x: -260, y: 260 }, "glut")
glut.rule = { kind: "constant", value: 200 } glut.rule = { kind: "constant", value: 200 }
glut.description = glut.description =
"The farm size past which cobras lose their worth (a fixed 200) — the threshold the spill opens above. A structural stand-in for the day the bounty was scrapped and the snakes became worthless." "The farm size past which cobras lose their worth (a fixed 200) — the threshold the spill opens above. A structural stand-in for the day the bounty was scrapped and the snakes became worthless."
const releases = makeFlow({ x: 0, y: 120 }, "releases", farm.id, wild.id) const releases = makeFlow({ x: -40, y: 60 }, "releases", farm.id, wild.id)
// releases = max(0, 0.6 × (Farm glut)): shut while the farm is worth keeping, then it // releases = max(0, 0.6 × (Farm glut)): shut while the farm is worth keeping, then it
// carries off the surplus. The overflow that overruns the wild — and, draining the farm // carries off the surplus. The overflow that overruns the wild — and, draining the farm
// above the glut, the Balancing brake that caps it. // above the glut, the Balancing brake that caps it.
@@ -1862,88 +1963,116 @@ function worldOnAWarmingPlanet(): Model {
/** The gallery, ordered simplest first. */ /** The gallery, ordered simplest first. */
export const SAMPLES: Sample[] = [ export const SAMPLES: Sample[] = [
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub }, {
title: "Bathtub",
blurb: "A stock filled and drained — no feedback yet.",
category: "Primer",
build: bathtub,
},
{ {
title: "Savings account", title: "Savings account",
blurb: "Interest on a balance: a Reinforcing loop.", blurb: "Interest on a balance: a Reinforcing loop.",
category: "Primer",
build: savings, build: savings,
}, },
{ {
title: "Coffee cooling", title: "Coffee cooling",
blurb: "Settling toward room temperature: a Balancing loop.", blurb: "Settling toward room temperature: a Balancing loop.",
category: "Primer",
build: coffee, build: coffee,
}, },
{ {
title: "Population", title: "Population",
blurb: "Births and deaths: Reinforcing and Balancing together.", blurb: "Births and deaths: Reinforcing and Balancing together.",
category: "Primer",
build: population, build: population,
}, },
{ {
title: "Limits to growth", title: "Limits to growth",
blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.", blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.",
category: "Classics",
build: limitsToGrowth, build: limitsToGrowth,
}, },
{ {
title: "Predator and prey", title: "Predator and prey",
blurb: "Two coupled Stocks whose loops make them oscillate.", blurb: "Two coupled Stocks whose loops make them oscillate.",
category: "Classics",
build: predatorPrey, build: predatorPrey,
}, },
{ {
title: "Epidemic", title: "Epidemic",
blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.", blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.",
category: "Classics",
build: epidemic, build: epidemic,
}, },
{ {
title: "Tragedy of the commons", title: "Tragedy of the commons",
blurb: "Two Reinforcing herds overgraze a renewable Stock, then starve with it: a system trap.", blurb: "Two Reinforcing herds overgraze a renewable Stock, then starve with it: a system trap.",
category: "System traps",
build: tragedyOfTheCommons, build: tragedyOfTheCommons,
}, },
{ {
title: "Tragedy of the commons, fixed", title: "Tragedy of the commons, fixed",
blurb: "Regulate the same renewable commons with a reserve: it holds, and the herds last.", blurb: "Regulate the same renewable commons with a reserve: it holds, and the herds last.",
category: "System traps",
build: tragedyOfTheCommonsFixed, build: tragedyOfTheCommonsFixed,
}, },
{ {
title: "Escalation", title: "Escalation",
blurb: "An arms race: one Reinforcing loop spanning two Stocks.", blurb: "An arms race: one Reinforcing loop spanning two Stocks.",
category: "System traps",
build: escalation, build: escalation,
}, },
{
title: "Success to the successful",
blurb:
"Two equal researchers, one a hair ahead: the field's attention locks onto the leader and the rival fades — a winner-take-all trap.",
category: "System traps",
build: successToTheSuccessful,
},
{ {
title: "Fixes that fail", title: "Fixes that fail",
blurb: "Road building eases congestion (B) but induces the traffic that refills it (R).", blurb: "Road building eases congestion (B) but induces the traffic that refills it (R).",
category: "System traps",
build: fixesThatFail, build: fixesThatFail,
}, },
{ {
title: "Drift to low performance", title: "Drift to low performance",
blurb: "An eroding goal leaves steady decay no floor: both slide downhill.", blurb: "An eroding goal leaves steady decay no floor: both slide downhill.",
category: "System traps",
build: driftToLowPerformance, build: driftToLowPerformance,
}, },
{ {
title: "Overshoot and collapse", title: "Overshoot and collapse",
blurb: "A fleet overfishes past the point of no return: the stock collapses for good.", blurb: "A fleet overfishes past the point of no return: the stock collapses for good.",
category: "Dynamic edge",
build: overshootAndCollapse, build: overshootAndCollapse,
}, },
{ {
title: "AI deskilling spiral", title: "AI deskilling spiral",
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.", blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
category: "Dynamic edge",
build: aiDeskillingSpiral, build: aiDeskillingSpiral,
}, },
{ {
title: "Bathtub with an overflow", title: "Bathtub with an overflow",
blurb: blurb:
"A flat-out tap and a spillway: the excess spills to a second Stock and the level holds.", "A flat-out tap and a spillway: the excess spills to a second Stock and the level holds.",
category: "Mechanics & capstone",
build: bathtubOverflow, build: bathtubOverflow,
}, },
{ {
title: "The cobra effect", title: "The cobra effect",
blurb: blurb:
"A bounty on dead cobras breeds a cobra farm; its glut spills into the wild, leaving four times the snakes: a perverse incentive.", "A bounty on dead cobras breeds a cobra farm; its glut spills into the wild, leaving four times the snakes: a perverse incentive.",
category: "Mechanics & capstone",
build: cobraEffect, build: cobraEffect,
}, },
{ {
title: "World on a warming planet", title: "World on a warming planet",
blurb: blurb:
"The Club of Rome's World3 in miniature, with a climate channel: growth overshoots a finite planet and the carbon it burns locks in the heat that finishes it.", "The Club of Rome's World3 in miniature, with a climate channel: growth overshoots a finite planet and the carbon it burns locks in the heat that finishes it.",
category: "Mechanics & capstone",
build: worldOnAWarmingPlanet, build: worldOnAWarmingPlanet,
}, },
] ]