feat(model): extend the sample gallery with five more models

Add a second tier past the four-model primer: Limits to growth, Predator
and prey, and Epidemic each introduce one structure the primer never
shows, plus two of Donella Meadows' system traps — Tragedy of the
commons and Escalation — to contrast healthy dynamics.
This commit is contained in:
Julien Calixte
2026-06-20 10:40:55 +02:00
parent 169dd043c3
commit 26dd245a94

View File

@@ -12,6 +12,23 @@
* 3. Coffee cooling — + Converter, `` polarity → a Balancing loop.
* 4. Population — all of the above at once: Reinforcing *and* Balancing.
*
* Beyond that primer, three classic models go a step further — each adds one
* structure the first four never show, so they read as a second tier:
*
* 5. Limits to growth — two loops (R and B) fighting over a single Flow, plus a
* constant Converter (carrying capacity) that feeds a loop
* without being part of it.
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
*
* Last, two of Donella Meadows' system *traps* — structures that reliably misbehave
* (Thinking in Systems, ch. 5), to contrast the healthy dynamics above:
*
* 8. Tragedy of the commons — competing Reinforcing loops drain a shared Stock
* faster than its weak, shared Balancing brake reacts.
* 9. Escalation — a single Reinforcing loop spanning two Stocks, with
* no brake in the structure: an arms race.
*
* 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()`
* mints fresh ids on each call, so loading a sample twice never collides.
@@ -134,6 +151,232 @@ function population(): Model {
)
}
/**
* Limits to growth — the S-curve, and the first model where two loops fight over
* one Flow. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
* Reinforcing loop), but the fuller the vat the more crowding holds growth back
* (Yeast → [+] → crowding → [] → growth: a Balancing loop). Carrying capacity is
* a *constant* Converter — no inputs — that sets how soon crowding bites; it feeds
* the balancing loop without sitting on any cycle.
*/
function limitsToGrowth(): Model {
const source = makeCloud({ x: -300, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
// crowding rides above the pipe; carrying capacity sits to its right so the
// `capacity → crowding` link is a short horizontal hop along the top.
const crowding = makeConverter({ x: -40, y: -150 }, "crowding")
const capacity = makeConverter({ x: 170, y: -150 }, "carrying capacity")
return model(
"Limits to growth",
[source, yeast, growth, crowding, capacity],
[
link(yeast, growth, "+"),
link(yeast, crowding, "+"),
link(crowding, growth, "-"),
link(capacity, crowding, "-"),
],
)
}
/**
* Predator and prey — the first model with two Stocks, coupled so each drives the
* other (LotkaVolterra). Rabbits breed (Reinforcing) and are thinned by predation
* (Balancing); foxes die off on their own (Balancing). The interesting one is the
* cross-stock loop Rabbits → fox births → Foxes → predation → Rabbits: more rabbits
* feed more foxes, more foxes eat more rabbits — one `` → Balancing, and the lag
* around it is what makes the two populations oscillate.
*/
function predatorPrey(): Model {
// Rabbits run along the top row, foxes along a lower row, so the two long
// coupling links cross in the open space between the rows.
const preySource = makeCloud({ x: -520, y: 0 })
const rabbits = makeStock({ x: -260, y: 0 }, "Rabbits")
const preySink = makeCloud({ x: 0, y: 0 })
const rabbitBirths = makeFlow(
midpoint(preySource.position, rabbits.position),
"rabbit births",
preySource.id,
rabbits.id,
)
const predation = makeFlow(
midpoint(rabbits.position, preySink.position),
"predation",
rabbits.id,
preySink.id,
)
const foxSource = makeCloud({ x: 120, y: 220 })
const foxes = makeStock({ x: 380, y: 220 }, "Foxes")
const foxSink = makeCloud({ x: 640, y: 220 })
const foxBirths = makeFlow(
midpoint(foxSource.position, foxes.position),
"fox births",
foxSource.id,
foxes.id,
)
const foxDeaths = makeFlow(
midpoint(foxes.position, foxSink.position),
"fox deaths",
foxes.id,
foxSink.id,
)
return model(
"Predator and prey",
[
preySource,
rabbits,
preySink,
rabbitBirths,
predation,
foxSource,
foxes,
foxSink,
foxBirths,
foxDeaths,
],
[
link(rabbits, rabbitBirths, "+"),
link(rabbits, predation, "+"),
link(foxes, predation, "+"),
link(rabbits, foxBirths, "+"),
link(foxes, foxDeaths, "+"),
],
)
}
/**
* Epidemic — contagion as a chain of three Stocks (Susceptible → Infected →
* Recovered) with no model boundary: every Flow runs Stock → Stock, so no clouds
* appear. Infection feeds on both ends at once (Susceptible → [+] and Infected →
* [+] → infection): the more infected there are the faster it spreads — a
* Reinforcing outbreak — until susceptibles run low (Balancing) and recovery
* drains the infected (Balancing). Infectivity is a constant Converter setting the
* pace; Recovered is a terminal Stock, on no loop.
*/
function epidemic(): Model {
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
const infected = makeStock({ x: 0, y: 0 }, "Infected")
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
const infection = makeFlow(
midpoint(susceptible.position, infected.position),
"infection",
susceptible.id,
infected.id,
)
const recovery = makeFlow(
midpoint(infected.position, recovered.position),
"recovery",
infected.id,
recovered.id,
)
const infectivity = makeConverter({ x: -140, y: -150 }, "infectivity")
return model(
"Epidemic",
[susceptible, infected, recovered, infection, recovery, infectivity],
[
link(susceptible, infection, "+"),
link(infected, infection, "+"),
link(infected, recovery, "+"),
link(infectivity, infection, "+"),
],
)
}
/**
* Tragedy of the commons — Meadows' first system *trap*: several users sharing one
* resource, each with a Reinforcing loop that grows its own use, and only a weak,
* shared Balancing loop to rein them in. Each Herd breeds the more cattle it has
* (Herd → [+] → growth: Reinforcing) and grazes the shared Pasture (Herd → [+] →
* grazing, which drains Pasture). Less Pasture does slow each herd (Pasture → [+] →
* growth: a Balancing loop per herd) — but that brake runs through the *one* shared
* Stock, so in practice it is too slow to stop the herds racing each other down to
* bare dirt. The trap is structural: each herder gains by growing, while the cost
* falls on the commons they both depend on.
*/
function tragedyOfTheCommons(): Model {
const pasture = makeStock({ x: 0, y: 0 }, "Pasture")
// Two symmetric herds: cattle enter from a Source on the outside, grass leaves
// the Pasture downward to a Sink. The two `Pasture → growth` links are the weak
// brake the trap overruns.
const sourceA = makeCloud({ x: -620, y: 0 })
const herdA = makeStock({ x: -360, y: 0 }, "Herd A")
const growthA = makeFlow(
midpoint(sourceA.position, herdA.position),
"growth A",
sourceA.id,
herdA.id,
)
const sinkA = makeCloud({ x: -180, y: 220 })
const grazingA = makeFlow(
midpoint(pasture.position, sinkA.position),
"grazing A",
pasture.id,
sinkA.id,
)
const sourceB = makeCloud({ x: 620, y: 0 })
const herdB = makeStock({ x: 360, y: 0 }, "Herd B")
const growthB = makeFlow(
midpoint(sourceB.position, herdB.position),
"growth B",
sourceB.id,
herdB.id,
)
const sinkB = makeCloud({ x: 180, y: 220 })
const grazingB = makeFlow(
midpoint(pasture.position, sinkB.position),
"grazing B",
pasture.id,
sinkB.id,
)
return model(
"Tragedy of the commons",
[pasture, sourceA, herdA, growthA, sinkA, grazingA, sourceB, herdB, growthB, sinkB, grazingB],
[
link(herdA, growthA, "+"),
link(herdA, grazingA, "+"),
link(pasture, growthA, "+"),
link(herdB, growthB, "+"),
link(herdB, grazingB, "+"),
link(pasture, growthB, "+"),
],
)
}
/**
* Escalation — the arms-race trap: two Stocks locked in a single Reinforcing loop,
* each building up in answer to the other. The more the Blue arsenal holds the
* faster Red builds (Blue → [+] → Red buildup), and vice versa, so the loop
* Red arsenal → Blue buildup → Blue arsenal → Red buildup → Red arsenal carries no
* `` → Reinforcing: with no brake in the structure, both grow without bound. (The
* benign cousin is "Predator and prey", whose cross loop has one `` and so settles
* into oscillation instead of exploding.)
*/
function escalation(): Model {
// Both arsenals sit inboard with their Sources outside; the two links crossing
// the centre are the escalation loop.
const redSource = makeCloud({ x: -520, y: 0 })
const redArsenal = makeStock({ x: -260, y: 0 }, "Red arsenal")
const redBuildup = makeFlow(
midpoint(redSource.position, redArsenal.position),
"Red buildup",
redSource.id,
redArsenal.id,
)
const blueSource = makeCloud({ x: 520, y: 0 })
const blueArsenal = makeStock({ x: 260, y: 0 }, "Blue arsenal")
const blueBuildup = makeFlow(
midpoint(blueSource.position, blueArsenal.position),
"Blue buildup",
blueSource.id,
blueArsenal.id,
)
return model(
"Escalation",
[redSource, redArsenal, redBuildup, blueSource, blueArsenal, blueBuildup],
[link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")],
)
}
/** The gallery, ordered simplest first. */
export const SAMPLES: Sample[] = [
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
@@ -152,4 +395,29 @@ export const SAMPLES: Sample[] = [
blurb: "Births and deaths: Reinforcing and Balancing together.",
build: population,
},
{
title: "Limits to growth",
blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.",
build: limitsToGrowth,
},
{
title: "Predator and prey",
blurb: "Two coupled Stocks whose loops make them oscillate.",
build: predatorPrey,
},
{
title: "Epidemic",
blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.",
build: epidemic,
},
{
title: "Tragedy of the commons",
blurb: "Two Reinforcing appetites drain one shared Stock: a system trap.",
build: tragedyOfTheCommons,
},
{
title: "Escalation",
blurb: "An arms race: one Reinforcing loop spanning two Stocks.",
build: escalation,
},
]