Compare commits

11 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
Julien Calixte
34bc8fa70b style: longer description 2026-06-22 23:19:01 +02:00
Julien Calixte
81425f8f3d style(adr-0004): realign the rate-rules table columns 2026-06-22 23:01:33 +02:00
Julien Calixte
d36f58977e feat(samples): add "The cobra effect" perverse-incentive trap
A bounty on dead cobras (a Balancing cull) funds a Reinforcing cobra
farm; once the farm gluts, the overflow rule spills its worthless
surplus into the wild, overshooting to 4x the starting population.

The engine has no time-triggered events, so the historical bounty
cancellation is modelled emergently: breeding outruns the bounty, the
farm gluts, and the one-sided overflow gate dumps the surplus. Sits
after "Bathtub with an overflow" since it reuses that rule.
2026-06-22 23:00:56 +02:00
Julien Calixte
62f6c5cda1 feat(samples): add "World on a warming planet" capstone
A miniature of the Club of Rome's World3 with its pollution sector
reframed as a climate channel: four coupled Stocks where Reinforcing
capital and population overshoot a finite Resource and the carbon they
burn locks in the warming that finishes them. A qualitative tribute to
the overshoot-and-collapse shape on the existing four rules, not a port
of the equations.
2026-06-21 20:06:35 +02:00
Julien Calixte
9ddff65072 docs(adr-0004): record the overflow rule in the vocabulary
The additive growth the ADR anticipated, now realised: the rule table, why
Overflow is a deliberate threshold exception to "the shape emerges", and why it
is a separate kind rather than a clamp on the (bidirectional) Gap rule.
2026-06-21 18:40:51 +02:00
Julien Calixte
faf8500fa5 feat(samples): add "Bathtub with an overflow" companion
The bathtub given a hard ceiling: a flat-out constant tap and an overflow spillway
that carries whatever rises past the brim into a second Stock, the floor. Water
climbs in a straight line then plateaus just above capacity while the floor fills —
an equilibrium from the spill, not from the inflow easing off. The gallery's one
declared ceiling, a counterpoint to the emergent one in "Limits to growth".
2026-06-21 18:31:39 +02:00
Julien Calixte
f691b99ca5 feat(simulation): add the overflow rule, a one-sided gap for hard ceilings
A fourth Rule kind: overflow = max(0, factor × (level − threshold)). It stays
shut until its `+` level passes its `−` threshold, so an outflow on it spills only
the excess — a spillway / hard ceiling the smooth rules can't draw.

Gap can't stand in for it: it's signed and bidirectional (coffee cooling relies on
that), so below the threshold a gap outflow runs backwards. Clamping at zero is the
whole point, and it can't be applied globally without breaking gap. This is the
additive vocabulary growth ADR-0004 anticipates — a new kind in the union plus a
case in the evaluator — alongside touch-ups to the rule validator and the inspector.
2026-06-21 18:31:39 +02:00
8 changed files with 822 additions and 44 deletions

View File

@@ -21,21 +21,34 @@ loops you _run_. They can never disagree.
typed-in formula.** Each instantaneous element picks one rule and a plain number typed-in formula.** Each instantaneous element picks one rule and a plain number
or two — never an expression: or two — never an expression:
| Rule | Value | Reads (via Information Links) | Emergent behaviour | | Rule | Value | Reads (via Information Links) | Emergent behaviour |
| ---------------- | --------------------------- | ----------------------------------------- | -------------------------- | | ---------------- | -------------------------------------- | -------------------------------------------- | -------------------------- |
| **Constant** | a fixed number | nothing | linear Stock change | | **Constant** | a fixed number | nothing | linear Stock change |
| **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay | | **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay |
| **Gap** | `factor × (level target)` | the `+` input is _level_, `` is _target_ | goal-seeking / asymptotic | | **Gap** | `factor × (level target)` | the `+` input is _level_, `` is _target_ | goal-seeking / asymptotic |
| **Overflow** | `max(0, factor × (level threshold))` | the `+` input is _level_, `` is _threshold_ | a spillway / hard ceiling |
The famous curves are _compositions_ of these over the structure — a logistic The famous curves are _compositions_ of these over the structure — a logistic
S-curve is Proportional growth meeting a Gap-driven ceiling (limits-to-growth); S-curve is Proportional growth meeting a Gap-driven ceiling (limits-to-growth);
goal-seeking decay is a lone Gap (coffee cooling). The user sets up a local rule; goal-seeking decay is a lone Gap (coffee cooling). The user sets up a local rule;
the global shape **emerges**. That emergence _is_ the lesson. the global shape **emerges**. That emergence _is_ the lesson.
**Overflow is the one _declared_ limit.** Constant/Proportional/Gap are smooth and
their ceilings _emerge_ (the S-curve is two of them meeting); Overflow instead is a
threshold — `max(0, …)` that stays shut until a level crosses it, then spills the
excess down an outflow (a bathtub brimming onto the floor). It earns a rule of its
own because Gap _cannot_ stand in: Gap is signed and bidirectional (coffee re-warms
if it drops below the room), so a Gap outflow runs _backwards_ below its target, and
that clamp can't be applied to Gap globally without breaking the goal-seekers. So
Overflow is a deliberate exception to "the shape emerges" — a hard ceiling you
_declare_, for the real limit that is a wall, not a slope (and the rate-side sibling
of the non-negative-Stock floor the integrator already enforces).
**Polarity does double duty.** The `+`/`` already captured for loop **Polarity does double duty.** The `+`/`` already captured for loop
classification (ADR-0001) also selects each operand's role: Proportional reads classification (ADR-0001) also selects each operand's role: Proportional reads
its `+` inputs; Gap reads its `+` input as the level and its `` input as the its `+` inputs; Gap reads its `+` input as the level and its `` input as the
target. One gesture, two payoffs — no new per-link data. target, and Overflow the same with its `` input as the threshold. One gesture,
two payoffs — no new per-link data.
## Considered Options ## Considered Options
@@ -70,5 +83,7 @@ target. One gesture, two payoffs — no new per-link data.
- The vocabulary starts deliberately small (Constant / Proportional / Gap — - The vocabulary starts deliberately small (Constant / Proportional / Gap —
enough for linear, exponential, and goal-seeking, and for the coffee and enough for linear, exponential, and goal-seeking, and for the coffee and
savings samples). Growing it is additive: a new `kind` in the union plus a case savings samples). Growing it is additive: a new `kind` in the union plus a case
in the evaluator. Multi-input products (e.g. `Population × fertility`) are a in the evaluator — as **Overflow** later bore out (one union member, one
later increment, not a phase-2 blocker. evaluator case, plus matching touch-ups to the rule validator and the
inspector). Multi-input products (e.g. `Population × fertility`) are a later
increment, not a phase-2 blocker.

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

@@ -115,6 +115,8 @@ const RULE_HINT: Record<Rule["kind"], string> = {
constant: "A fixed number — no inputs.", constant: "A fixed number — no inputs.",
proportional: "rate = factor × its “+” inputs.", proportional: "rate = factor × its “+” inputs.",
gap: "rate = factor × (level target): the “+” input is the level, the “−” the target.", gap: "rate = factor × (level target): the “+” input is the level, the “−” the target.",
overflow:
"rate = max(0, factor × (level threshold)): spills only once the “+” level passes the “−” threshold.",
} }
</script> </script>
@@ -167,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<option value="constant">Constant</option> <option value="constant">Constant</option>
<option value="proportional">Proportional</option> <option value="proportional">Proportional</option>
<option value="gap">Gap</option> <option value="gap">Gap</option>
<option value="overflow">Overflow</option>
</select> </select>
</label> </label>
@@ -204,7 +207,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<label class="mt-2 block"> <label class="mt-2 block">
<span class="text-xs text-base-content/60">Description</span> <span class="text-xs text-base-content/60">Description</span>
<textarea <textarea
rows="3" rows="6"
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug" class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
:value="(element ?? link)?.description ?? ''" :value="(element ?? link)?.description ?? ''"
placeholder="Why this element is here…" placeholder="Why this element is here…"

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

@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
function isRule(value: unknown): value is Rule { function isRule(value: unknown): value is Rule {
if (!isObject(value)) return false if (!isObject(value)) return false
if (value.kind === "constant") return isFiniteNumber(value.value) if (value.kind === "constant") return isFiniteNumber(value.value)
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor) if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
return isFiniteNumber(value.factor)
return false return false
} }

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,10 +54,46 @@
* 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
* ones (above all "Limits to growth"):
*
* 16. Bathtub with an overflow — the tap runs flat out (a Constant inflow that never
* reads the level) and a spillway carries whatever rises
* past the brim into a second Stock, the floor. The only
* model on the `overflow` rule — a one-sided Gap, the
* threshold the smooth rules can't draw — so here the
* ceiling is *declared*, not emergent.
*
* 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:
*
* 17. The cobra effect — a bounty on dead cobras (a Balancing fix) quietly funds a
* cobra *farm* (a Reinforcing engine); breeding outruns what
* the bounty can absorb, the farm gluts, and its now-worthless
* surplus spills through an `overflow` gate into the wild —
* leaving four times the snakes there were to begin with. The
* perverse incentive: rewarding the proxy (dead cobras) over
* the goal (fewer cobras), and the overflow rule's dark payoff.
*
* And the capstone — every piece at once, at the scale the whole gallery points toward:
*
* 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
* as a climate channel: four coupled Stocks where two
* Reinforcing engines (capital, population) overshoot a
* finite Resource and the carbon they burn locks in the
* warming that finishes them. The gallery's largest model,
* it composes the whole vocabulary — a Stock→Stock bridge
* ("Epidemic"), logistic-style limits ("Limits to growth"),
* a renewable-resource overshoot ("Overshoot and collapse"),
* and the bathtub's slow sink ("Bathtub with an overflow")
* — into one system. A qualitative tribute to the shape, not
* a port of the equations.
*
* These are plain data built from the same tested constructors the store uses * These are plain data built from the same tested constructors the store uses
* (factory.ts), so every sample is a valid Model by construction. `build()` * (factory.ts), so every sample is a valid Model by construction. `build()`
* mints fresh ids on each call, so loading a sample twice never collides. * mints fresh ids on each call, so loading a sample twice never collides.
@@ -68,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
} }
@@ -907,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.
@@ -1343,72 +1480,599 @@ function aiDeskillingSpiral(): Model {
) )
} }
/**
* Bathtub with an overflow — the bathtub given a hard ceiling the honest way. The tap
* keeps running flat out (filling stays a Constant; it never reads the level), and a
* spillway carries off whatever rises past the brim: overflow = max(0, factor ×
* (Water capacity)). That `overflow` rule is a one-sided Gap — shut until Water passes
* the `` threshold (capacity), then draining the excess into a second Stock, the Floor.
* The loop Water → overflow → Water carries one `` (the outflow) → Balancing, so Water
* climbs in a straight line, then plateaus just above the brim — a weir needs a little
* head of water to discharge — while the Floor goes on filling. The equilibrium comes
* from the *spill*, not from the inflow easing off: the counterpoint to a float valve,
* which throttles the inflow instead, and to "Limits to growth", whose ceiling emerges.
*/
function bathtubOverflow(): Model {
const source = makeCloud({ x: -280, y: 0 })
const water = makeStock({ x: 0, y: 0 }, "Water")
water.initialValue = 20
water.unit = "L"
water.description =
"The water level — the tap fills it in a straight line, then it holds just above the brim as the overflow carries off everything extra."
const filling = makeFlow(
midpoint(source.position, water.position),
"filling",
source.id,
water.id,
)
// A constant tap — it never reads the level, so it keeps pouring even at the brim.
// The ceiling comes from the overflow below, not from the inflow easing off.
filling.rule = { kind: "constant", value: 5 }
filling.description =
"A constant 5 L/step from the tap (the Source cloud), running flat out. It never throttles — the ceiling is the overflow's doing, not the tap's."
const floor = makeStock({ x: 0, y: 240 }, "Floor")
floor.initialValue = 0
floor.unit = "L"
floor.description =
"Water spilled onto the floor — the second Stock the overflow collects in. Empty until the tub brims, then it fills at the spill rate."
const overflow = makeFlow(
midpoint(water.position, floor.position),
"overflow",
water.id,
floor.id,
)
// overflow = max(0, 1 × (Water capacity)): shut below the brim, spilling the excess
// above it. factor 1 at dt 1 settles in a step without oscillating; the level rides a
// touch over capacity — the head a spillway needs to discharge its inflow.
overflow.rule = { kind: "overflow", factor: 1 }
overflow.description =
"The spillway, max(0, Water capacity): nothing while the tub is below the brim, then it carries off the excess — the Balancing drain that holds the level."
const capacity = makeConverter({ x: 240, y: 120 }, "capacity")
capacity.rule = { kind: "constant", value: 100 }
capacity.description =
"The tub's brim (a fixed 100 L) — the threshold the overflow opens above; the level holds just over it."
return model(
"Bathtub with an overflow",
[source, water, filling, floor, overflow, capacity],
[
link(
water,
overflow,
"+",
"Water is the level in the overflow rule: the higher it rises past the brim, the harder it spills. With the outflow, this closes the Balancing loop that holds the level.",
),
link(
capacity,
overflow,
"-",
"Capacity is the threshold the spill opens above (the input): a higher brim → less spill at the same level.",
),
],
{ start: 0, stop: 40, dt: 1 },
)
}
/**
* The cobra effect — the perverse-incentive trap, and Meadows' "rule beating"
* (Thinking in Systems, ch. 5) in its most-told form. Colonial Delhi has too many
* cobras, so the British put a bounty on them: cash for every dead snake. The intent
* is a Balancing fix — more cobras → more killed for the reward → fewer cobras (Wild
* cobras → [+] → culling, an outflow draining the Stock). And at first it works: the
* streets empty of snakes.
*
* But the bounty pays for *dead cobras delivered*, not for *fewer wild cobras* — it
* rewards the proxy, not the goal. So the same reward funds a second thing the policy
* never intended: people breed cobras to cash in. That farm is a Reinforcing engine
* (Farmed cobras → [+] → breeding → Farmed cobras: more breeding stock, more bred), and
* the snakes raised on it are killed and turned in for the bounty too (Farmed cobras →
* [+] → harvest — the rule beating: producing dead cobras to the letter of the policy
* while making its goal worse). One lever, the bounty, wired into all three flows.
*
* The engine has no scripted policy reversal — no "the bounty is cancelled at year X" —
* so the famous release is *emergent*. Breeding (Reinforcing) outruns what the bounty
* can absorb; the farm gluts, and a glutted farm crashes the cobra's worth. Past that
* glut threshold the now-worthless surplus is dumped into the wild: releases =
* max(0, factor × (Farmed cobras glut)), the `overflow` rule from "Bathtub with an
* overflow", here a one-sided gate shut until the farm overflows. Wild cobras have *no
* inflow of their own* (no natural breeding is modelled), so the only thing that can
* refill the wild is the farm — which leaves no doubt what brings the snakes roaring back.
*
* The shape (start 0, stop 70, dt 1): Wild cobras crash 100 → ~3 by t≈20 — the bounty
* looks like a triumph — while the unseen farm booms 5 → past the glut (200). Then the
* overflow opens, the wild population climbs back above its start by t≈24 and overshoots
* to ~404 — four times where it began — settling there for good as the farm levels at
* ~308. The fix didn't fail quietly; it left the System far worse than it found it. Kin
* to "Fixes that fail" (a fix whose own side effect defeats it), but here the side effect
* is a Stock the reward built, the brake is the overflow gate, and the damage is permanent.
*/
function cobraEffect(): Model {
// Laid out left→right: the farm engine on the left (a Source breeds Farmed cobras,
// harvest draining up to a Sink), the release bridge in the centre spilling the glut
// rightward into Wild cobras, and the bounty-driven cull running off to the right. The
// policy lever (bounty) sits up top, wired down into all three flows — cull, breeding,
// cash-out — with glut beneath the bridge it gates. Hand-placed valves keep links clear.
const bounty = makeConverter({ x: -80, y: -340 }, "bounty")
bounty.rule = { kind: "constant", value: 1 }
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 intended fix: a bounty-driven cull empties the streets of wild cobras.
const wild = makeStock({ x: 380, y: -40 }, "Wild cobras")
wild.initialValue = 100
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."
const cullSink = makeCloud({ x: 920, y: -40 })
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
// crash the wild population by t≈20 — the policy's whole visible success.
culling.rule = { kind: "proportional", factor: 0.16 }
culling.description =
"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.
const farm = makeStock({ x: -520, y: -40 }, "Farmed cobras")
farm.initialValue = 5
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."
const breedSrc = makeCloud({ x: -980, y: -200 })
const breeding = makeFlow({ x: -760, y: -200 }, "breeding", breedSrc.id, farm.id)
// breeding = 0.31 × Farm × bounty: Farm → breeding → Farm, no `` → the Reinforcing
// engine the bounty funds without meaning to.
breeding.rule = { kind: "proportional", factor: 0.31 }
breeding.description =
"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: 600, y: -220 })
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,
// and a Balancing drain on the farm.
harvest.rule = { kind: "proportional", factor: 0.1 }
harvest.description =
"Farmed cobras killed and turned in for the bounty, ∝ Farmed cobras × bounty — the rule beating: dead cobras to the letter of the policy, while its goal slips away."
// 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
// needed; the release falls out of the farm outgrowing its own worth.
const glut = makeConverter({ x: -260, y: 260 }, "glut")
glut.rule = { kind: "constant", value: 200 }
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."
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
// carries off the surplus. The overflow that overruns the wild — and, draining the farm
// above the glut, the Balancing brake that caps it.
releases.rule = { kind: "overflow", factor: 0.6 }
releases.description =
"The farm's glut spilling into the wild, max(0, 60% of the excess above the glut each step): nothing while the farm stays under it, then the worthless surplus is dumped — the overflow that overruns the wild, and the Balancing drain that caps the farm."
return model(
"The cobra effect",
[
bounty,
wild,
cullSink,
culling,
farm,
breedSrc,
breeding,
harvestSink,
harvest,
glut,
releases,
],
[
link(
wild,
culling,
"+",
"More wild cobras → more killed for the bounty: the + that makes the cull a Balancing fix.",
),
link(
bounty,
culling,
"+",
"A bigger bounty → harder hunting: the policy driving its intended effect.",
),
link(
farm,
breeding,
"+",
"More farmed cobras → more breeding stock → more bred: the Reinforcing engine.",
),
link(
bounty,
breeding,
"+",
"A bigger bounty → more worth breeding for: the reward funding the farm it never intended.",
),
link(farm, harvest, "+", "More farmed cobras → more turned in for cash."),
link(
bounty,
harvest,
"+",
"A bigger bounty → more worth cashing in: the reward the rule beating chases.",
),
link(
farm,
releases,
"+",
"Farmed cobras is the level in the overflow rule: only the surplus past the glut spills. With the outflow, this closes the Balancing loop that caps the farm.",
),
link(
glut,
releases,
"-",
"The glut is the threshold the spill opens above (the input): below it the farm is worth keeping, and nothing is released.",
),
],
// The bounty crashes Wild cobras 100 → ~3 by t≈20 (it looks like a triumph) while the
// unseen farm booms 5 → past the glut (200). Then the overflow opens: the wild climbs
// back above its start by t≈24 and overshoots to ~404 — four times where it began —
// settling there as the farm levels at ~308. The fix left the System far worse, for good.
{ start: 0, stop: 70, dt: 1 },
)
}
/**
* World on a warming planet — the gallery's capstone: the Club of Rome's World3
* (Donella Meadows et al., *The Limits to Growth*, 1972) in miniature, with its
* persistent-pollution sector reframed as the climate channel. It is a *qualitative
* tribute*, not a port: World3 runs on hundreds of equations, table-function
* nonlinearities, and delays; here four Stocks on the gallery's four rules
* reproduce the famous *shape* — overshoot and collapse — without the apparatus.
*
* The four sectors and how they couple:
* - **Capital** is the growth engine. output = factor × Capital × availability;
* a slice is reinvested (investment, an inflow) so Capital → output → investment
* → Capital is **Reinforcing** — the economy compounds. depreciation drains it
* (**Balancing**).
* - **Resources** is the finite planet: a nonrenewable Stock with *no* inflow.
* `availability` ∝ Resources scales output, so as the reserve runs down the
* economy is choked: Resources → availability → output → combustion → Resources,
* one `` → **Balancing**. This is the World3 limit.
* - **Atmospheric carbon** is the climate channel. The same act that runs the
* economy loads the sky: `combustion` is a single Stock→Stock Flow that drains
* Resources straight *into* carbon — burning the reserve *is* the emission. A
* slow `removal` (a Gap toward preindustrial) is the sink; tuned slow, so the
* carbon **stays** — the bathtub, the lesson that stabilising emissions ≠
* stabilising concentration.
* - **Population** grows on births (**Reinforcing**) against baseline deaths
* (**Balancing**). `warming` (a Gap reading carbon above the same preindustrial
* baseline — the constant does double duty) drives `heat deaths`, the climate's
* bite on people, and `climate damage`, its bite on Capital. That damage closes
* the long cross-sector loop output → combustion → carbon → warming → climate
* damage → Capital — so the climate is genuine *feedback*, not one-way forcing.
*
* Note what is *absent*: there is no Reinforcing tipping loop. The climate here is
* slow accumulation braked by its sink, not a runaway — faithful to World3's
* pollution sector, and a different model from a carbon-cycle one built to tip.
* The Reinforcing engines are growth (Capital) and births (Population); everything
* the planet pushes back with is Balancing. That asymmetry *is* limits-to-growth.
*
* What emerges (start 0, stop 250, dt 1): the economy booms, then overshoots the
* reserve and collapses — output peaks ≈540 at t≈47, Capital ≈1040 at t≈60, both
* falling to near nothing as Resources deplete 1000 → ~120. The carbon they burned
* climbs to ~1085 ppm and *holds* near 1000 (warming locked at ~4 °C) long after
* the emissions stop. That locked-in heat is what finishes Population: it overshoots
* to ~310 at t≈60 — the *last* sector to peak — then collapses to ~30, a third of
* where it began. Growth first, people last; the limit and the heat together.
*/
function worldOnAWarmingPlanet(): Model {
// Economy upper-left (Resources, Capital and their converters/flows); the carbon
// Stock sits centre on the same baseline as Resources, joined by the combustion
// bridge; `warming` hangs below it and radiates harm out to *both* the economy
// (up-left, climate damage) and Population (right, heat deaths). Population runs
// its own birth/death column on the right. Valves are hand-placed, not at
// midpoints, to keep every Information Link in open space.
// Resources sector (far left): a finite reserve that only depletes; its grade
// (availability) is what the economy can actually draw on.
const resources = makeStock({ x: -820, y: 60 }, "Resources")
resources.initialValue = 1000
resources.description =
"The planet's nonrenewable reserve — it has no inflow, so it only ever falls. As it runs down it chokes the economy that lives off it."
const availability = makeConverter({ x: -820, y: -140 }, "availability")
// availability = 0.001 × Resources: a 0…1 grade (starts at 1.0) that scales output.
availability.rule = { kind: "proportional", factor: 0.001 }
availability.description =
"How much of the reserve is still cheap to reach (∝ Resources, ~1.0 at the start, → 0 as it empties). It is the brake the finite planet puts on output."
// Economy: Capital is the hub — investment fills it (Reinforcing), depreciation
// and climate damage drain it (Balancing). output relays Capital × availability.
const capital = makeStock({ x: -420, y: -40 }, "Capital")
capital.initialValue = 50
capital.description =
"Industrial capital — the engine that compounds by reinvesting its own output, until the reserve it burns runs short and the heat it raises bites back."
const output = makeConverter({ x: -600, y: -200 }, "output")
// output = Capital × availability (both `+`): the economy's activity, throttled by
// how much reserve is left. It feeds investment, combustion, and (via carbon) warming.
output.rule = { kind: "proportional", factor: 1 }
output.description =
"Industrial output, Capital × availability: more capital makes more, but a depleting reserve scales it down. The relay that ties the economy to the planet."
const investSrc = makeCloud({ x: -680, y: -40 })
const investment = makeFlow({ x: -550, y: -40 }, "investment", investSrc.id, capital.id)
// investment = 12% of output, reinvested: Capital → output → investment → Capital,
// no `` → the Reinforcing engine that drives the boom.
investment.rule = { kind: "proportional", factor: 0.12 }
investment.description =
"Output ploughed back into capital, 12% per step. More capital → more output → more investment: the Reinforcing engine of growth."
const deprSink = makeCloud({ x: -240, y: -200 })
const depreciation = makeFlow({ x: -330, y: -120 }, "depreciation", capital.id, deprSink.id)
// depreciation = 4% of Capital: wear. Below investment while the reserve lasts,
// above it once availability collapses — which is what tips Capital into decline.
depreciation.rule = { kind: "proportional", factor: 0.04 }
depreciation.description =
"Capital wearing out, 4% per step — the Balancing drain that overtakes investment once a depleted reserve starves output."
const dmgSink = makeCloud({ x: -640, y: -260 })
const climateDamage = makeFlow({ x: -540, y: -160 }, "climate damage", capital.id, dmgSink.id)
// climate damage = 0.004 × Capital × warming: the heat's bite on the economy. It
// closes the long climate→economy loop, so warming is feedback, not just forcing.
climateDamage.rule = { kind: "proportional", factor: 0.004 }
climateDamage.description =
"Capital lost to a hotter world, ∝ Capital × warming. It closes the loop from output through carbon and warming back onto Capital — the climate biting the economy that warmed it."
// The combustion bridge: a single Stock→Stock Flow draining Resources *into*
// carbon. Burning the reserve is the emission — no Source, no Sink, both ends Stocks.
const carbon = makeStock({ x: 40, y: 60 }, "Atmospheric carbon")
carbon.initialValue = 280
carbon.unit = "ppm"
carbon.description =
"Carbon in the air (ppm), 280 at the preindustrial start. Combustion fills it fast, the sink empties it slow — so it climbs, then stays. The bathtub."
const combustion = makeFlow({ x: -380, y: 60 }, "combustion", resources.id, carbon.id)
// combustion = 3% of output: the reserve burned each step, drained from Resources
// and added to carbon in one move. Conserved — what leaves the ground enters the sky.
combustion.rule = { kind: "proportional", factor: 0.03 }
combustion.description =
"The reserve burned to run the economy, ∝ output — drained from Resources straight into the air. The same act depletes the planet and loads the sky."
const removalSink = makeCloud({ x: 40, y: -140 })
const removal = makeFlow({ x: 40, y: -40 }, "removal", carbon.id, removalSink.id)
// removal = 0.001 × (carbon preindustrial): the natural sink, a Gap toward the
// baseline. Deliberately slow, so carbon barely recedes — the locked-in warming.
removal.rule = { kind: "gap", factor: 0.001 }
removal.description =
"Nature drawing carbon back toward the preindustrial baseline — a slow Gap. So slow that once emitted, the carbon stays for the run: warming you cannot take back."
const preindustrial = makeConverter({ x: -180, y: 240 }, "preindustrial")
preindustrial.rule = { kind: "constant", value: 280 }
preindustrial.description =
"The preindustrial carbon baseline (280 ppm), doing double duty: the target the sink draws toward and the zero from which warming is measured."
const warming = makeConverter({ x: 300, y: 200 }, "warming")
// warming = 0.005 × (carbon preindustrial): °C above preindustrial. A Gap reading
// carbon as the level and the baseline as the target; ~4 °C at the carbon peak.
warming.rule = { kind: "gap", factor: 0.005 }
warming.description =
"Warming in °C above preindustrial, ∝ (carbon baseline) — about 4 °C at the peak. It feeds both the deaths it causes and the capital it destroys."
// Population: births compound it (Reinforcing), baseline deaths brake it (Balancing),
// and heat deaths add the climate toll that eventually overwhelms the birth engine.
const population = makeStock({ x: 660, y: 60 }, "Population")
population.initialValue = 100
population.unit = "people"
population.description =
"The people — grows on births, thinned by ordinary deaths, and finally overwhelmed by the heat the economy's carbon locked in. The last sector to peak, and to fall."
const birthSrc = makeCloud({ x: 660, y: -140 })
const births = makeFlow({ x: 660, y: -40 }, "births", birthSrc.id, population.id)
// births = 5% of Population: the Reinforcing engine, +3%/step net of baseline deaths.
births.rule = { kind: "proportional", factor: 0.05 }
births.description =
"New people, 5% of the population each step — the Reinforcing engine that grows it while the world stays cool enough to bear it."
const deathSink = makeCloud({ x: 520, y: 240 })
const deaths = makeFlow({ x: 600, y: 160 }, "deaths", population.id, deathSink.id)
// deaths = 2% of Population: ordinary mortality, the Balancing drain births outrun early.
deaths.rule = { kind: "proportional", factor: 0.02 }
deaths.description =
"Ordinary deaths, 2% of the population each step — the Balancing drain the birth engine outpaces, until the heat tips the balance."
const heatSink = makeCloud({ x: 820, y: 240 })
const heatDeaths = makeFlow({ x: 740, y: 160 }, "heat deaths", population.id, heatSink.id)
// heat deaths = 0.011 × Population × warming: the climate toll. Once warming passes
// ≈2.7 °C this outflow overtakes net births and Population turns from boom to collapse.
heatDeaths.rule = { kind: "proportional", factor: 0.011 }
heatDeaths.description =
"Deaths from a hotter world, ∝ Population × warming. Once warming passes ≈2.7 °C this overtakes net births, and the population collapses with the heat that never lifts."
return model(
"World on a warming planet",
[
resources,
availability,
capital,
output,
investSrc,
investment,
deprSink,
depreciation,
dmgSink,
climateDamage,
carbon,
combustion,
removalSink,
removal,
preindustrial,
warming,
population,
birthSrc,
births,
deathSink,
deaths,
heatSink,
heatDeaths,
],
[
link(resources, availability, "+", "More reserve left → more of it cheap to reach."),
link(capital, output, "+", "More capital → more output: the level the economy compounds on."),
link(
availability,
output,
"+",
"More availability → more output. With Capital, output = Capital × availability — the planet throttling the economy.",
),
link(
output,
investment,
"+",
"More output → more reinvested: the + that makes the growth loop Reinforcing.",
),
link(capital, depreciation, "+", "More capital → more wearing out each step."),
link(capital, climateDamage, "+", "More capital → more of it exposed to a hotter world."),
link(
warming,
climateDamage,
"+",
"More warming → more capital destroyed. This outflow closes the climate→economy loop: feedback, not forcing.",
),
link(
output,
combustion,
"+",
"More output → more reserve burned: economic activity is what emits.",
),
link(carbon, removal, "+", "More carbon above the baseline → faster (but slow) removal."),
link(
preindustrial,
removal,
"-",
"The baseline the sink draws toward (the target): removal stops once carbon is back to it.",
),
link(carbon, warming, "+", "More carbon → more warming: carbon is the level in the gap."),
link(
preindustrial,
warming,
"-",
"The baseline warming is measured from (the target): no carbon above it, no warming.",
),
link(
population,
births,
"+",
"More people → more births: the + that makes the population loop Reinforcing.",
),
link(population, deaths, "+", "More people → more ordinary deaths."),
link(population, heatDeaths, "+", "More people → more of them exposed to the heat."),
link(
warming,
heatDeaths,
"+",
"More warming → more heat deaths. With Population this is the climate toll that ends the boom.",
),
],
// The economy overshoots the reserve and collapses (output peaks ≈540 @ t≈47,
// Capital ≈1040 @ t≈60 → ~1); Resources deplete 1000 → ~120. The carbon burned
// climbs to ~1085 ppm and holds near 1000 (warming locked at ~4 °C). Population
// overshoots last (~310 @ t≈60) then collapses to ~30 as the locked-in heat bites.
{ start: 0, stop: 250, dt: 1 },
)
}
/** 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",
blurb:
"A flat-out tap and a spillway: the excess spills to a second Stock and the level holds.",
category: "Mechanics & capstone",
build: bathtubOverflow,
},
{
title: "The cobra effect",
blurb:
"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,
},
{
title: "World on a warming planet",
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.",
category: "Mechanics & capstone",
build: worldOnAWarmingPlanet,
},
] ]

View File

@@ -107,6 +107,19 @@ function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) =>
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0)) rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
) )
} }
case "overflow": {
// max(0, factor × (level threshold)): a one-sided gap. The `+` input is the
// level, the `` the threshold; it stays shut until the level passes it, so an
// overflow Flow spills only the excess. Clamping at 0 is what stops it running
// backwards below the threshold — gap can't, by design (it's bidirectional).
const level = links.find((link) => link.polarity === "+")
const threshold = links.find((link) => link.polarity === "-")
return Math.max(
0,
rule.factor *
((level ? valueOf(level.source) : 0) - (threshold ? valueOf(threshold.source) : 0)),
)
}
} }
} }

View File

@@ -42,11 +42,15 @@ export interface Position {
* (→ exponential growth/decay) * (→ exponential growth/decay)
* - `gap` — `factor × (level target)`, where the `+` input is the * - `gap` — `factor × (level target)`, where the `+` input is the
* level and the `` input the target. (→ goal-seeking) * level and the `` input the target. (→ goal-seeking)
* - `overflow` — `max(0, factor × (level threshold))`: a one-sided `gap`
* that only fires once the `+` level exceeds the ``
* threshold. (→ a spillway / hard ceiling)
*/ */
export type Rule = export type Rule =
| { kind: "constant"; value: number } | { kind: "constant"; value: number }
| { kind: "proportional"; factor: number } | { kind: "proportional"; factor: number }
| { kind: "gap"; factor: number } | { kind: "gap"; factor: number }
| { kind: "overflow"; factor: number }
/** /**
* The run parameters for a simulation: integrate from `start` to `stop` in steps * The run parameters for a simulation: integrate from `start` to `stop` in steps