The model had no downward force on Performance, so its only flow (improvement) could only pull it up. Performance rose 40 -> 60 while the Standard fell 80 -> 60, contradicting the "slide downhill" story. Add a constant decay outflow on Performance and seat it just below the goal. Now both ratchet down in lockstep (Performance 70 -> 10, Standard 80 -> 20), a fixed gap apart — the genuine eroding-goals trap. Rewrite the doc-comment, layout note, overview line, and blurb to match.
872 lines
39 KiB
TypeScript
872 lines
39 KiB
TypeScript
/**
|
||
* Sample Models — a small, curated gallery the user can load to learn the
|
||
* language by example (CONTEXT.md) and to have something on the canvas in one
|
||
* click.
|
||
*
|
||
* The set is deliberately "simple yet exhaustive": each Model is the smallest
|
||
* thing that makes its point, but read top to bottom they introduce the whole
|
||
* vocabulary and both loop kinds, one new idea at a time:
|
||
*
|
||
* 1. Bathtub — Stock, Flow (in/out), Source, Sink. No feedback.
|
||
* 2. Savings account — + Information Link, `+` polarity → a Reinforcing loop.
|
||
* 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 — a Reinforcing inflow and a Balancing outflow on one
|
||
* Stock, with a Converter (crowding) relaying the density
|
||
* that brakes growth: the S-curve.
|
||
* 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, four 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.
|
||
* 10. Fixes that fail — a fix drains the symptom Stock (B) while its side
|
||
* effect refills it (R): the cure feeds the disease.
|
||
* 11. 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
|
||
* reader knows every piece it needs:
|
||
*
|
||
* 12. Overshoot and collapse — a Reinforcing harvester on a *renewable* Resource with
|
||
* an extinction threshold (an Allee floor): a fleet
|
||
* overshoots the renewal rate and pushes the fishery past
|
||
* the point of no return. The dark twin of "Limits to
|
||
* growth" — the limit doesn't hold, it collapses for good.
|
||
*
|
||
* Last, the language pointed at a live debate — a classic trap (Shifting the burden to
|
||
* the intervenor, ch. 5) wearing today's clothes:
|
||
*
|
||
* 13. AI deskilling spiral — handing the burden of code quality to AI atrophies the
|
||
* Expertise that holds quality up, so the team leans on AI
|
||
* harder and Technical debt spirals: addiction, not a fix.
|
||
*
|
||
* 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.
|
||
*/
|
||
import { makeCloud, makeConverter, makeFlow, makeStock, midpoint, newId } from "./factory"
|
||
import {
|
||
type InformationLink,
|
||
type Model,
|
||
MODEL_VERSION,
|
||
type ModelNode,
|
||
type Polarity,
|
||
type SimSpec,
|
||
} from "./types"
|
||
|
||
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
|
||
export interface Sample {
|
||
title: string
|
||
blurb: string
|
||
build: () => Model
|
||
}
|
||
|
||
/** Information Link between two already-built nodes, with an explicit polarity. */
|
||
function link(source: ModelNode, target: ModelNode, polarity: Polarity): InformationLink {
|
||
return { id: newId("link"), source: source.id, target: target.id, polarity }
|
||
}
|
||
|
||
function model(
|
||
name: string,
|
||
nodes: ModelNode[],
|
||
infoLinks: InformationLink[],
|
||
sim?: SimSpec,
|
||
): Model {
|
||
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks, sim }
|
||
}
|
||
|
||
/**
|
||
* Bathtub — the canonical first model. A Stock filled by one Flow and drained by
|
||
* another, each open end resting on a Cloud. No Information Links, so no
|
||
* feedback: it just shows the substrate (Stock, Flow, Source, Sink).
|
||
*/
|
||
function bathtub(): Model {
|
||
const source = makeCloud({ x: -280, y: 0 })
|
||
const water = makeStock({ x: 0, y: 0 }, "Water")
|
||
water.initialValue = 20
|
||
water.unit = "L"
|
||
const sink = makeCloud({ x: 280, y: 0 })
|
||
const filling = makeFlow(
|
||
midpoint(source.position, water.position),
|
||
"filling",
|
||
source.id,
|
||
water.id,
|
||
)
|
||
const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.id)
|
||
// No Information Links, so each rate is a plain Constant. A faster inflow than
|
||
// outflow means Water rises in a straight line — accumulation with no feedback.
|
||
filling.rule = { kind: "constant", value: 5 }
|
||
emptying.rule = { kind: "constant", value: 3 }
|
||
return model("Bathtub", [source, water, sink, filling, emptying], [], {
|
||
start: 0,
|
||
stop: 40,
|
||
dt: 1,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Savings account — the simplest Reinforcing loop. Interest flows in from outside
|
||
* (a Source), and the bigger the Balance the larger the interest: Balance → [+]
|
||
* → interest → (inflow) → Balance. Even number of `−` (zero) → Reinforcing.
|
||
*/
|
||
function savings(): Model {
|
||
// Source up-left, Balance down-right: the interest valve lands at their
|
||
// midpoint (above Balance), so the `Balance → interest` link arcs back up as a
|
||
// visible Reinforcing loop instead of overlapping the inflow pipe.
|
||
const source = makeCloud({ x: -240, y: -80 })
|
||
const balance = makeStock({ x: 120, y: 40 }, "Balance")
|
||
balance.initialValue = 1000
|
||
balance.unit = "$"
|
||
const interest = makeFlow(
|
||
midpoint(source.position, balance.position),
|
||
"interest",
|
||
source.id,
|
||
balance.id,
|
||
)
|
||
// interest = 5% × Balance (the `+` link). A Stock feeding its own inflow → the
|
||
// Reinforcing loop runs as exponential growth.
|
||
interest.rule = { kind: "proportional", factor: 0.05 }
|
||
return model("Savings account", [source, balance, interest], [link(balance, interest, "+")], {
|
||
start: 0,
|
||
stop: 40,
|
||
dt: 1,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Coffee cooling — the simplest Balancing loop, plus a Converter. The cup cools
|
||
* faster the hotter it is (Coffee → [+] → cooling) but slower the warmer the room
|
||
* (room temperature → [−] → cooling). The loop Coffee → cooling → (outflow) →
|
||
* Coffee has one `−` → Balancing: it settles toward room temperature.
|
||
*/
|
||
function coffee(): Model {
|
||
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
|
||
coffee.initialValue = 90
|
||
coffee.unit = "°C"
|
||
const sink = makeCloud({ x: 200, y: 0 })
|
||
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id)
|
||
// cooling = 0.1 × (Coffee − room): the `+` input is the level, the `−` the target.
|
||
// An outflow closing the gap to room temperature → the Balancing loop settles there.
|
||
cooling.rule = { kind: "gap", factor: 0.1 }
|
||
const room = makeConverter({ x: 0, y: -160 }, "room temperature")
|
||
room.rule = { kind: "constant", value: 20 }
|
||
return model(
|
||
"Coffee cooling",
|
||
[coffee, sink, cooling, room],
|
||
[link(coffee, cooling, "+"), link(room, cooling, "-")],
|
||
{ start: 0, stop: 60, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Population — the whole language in one model. Births add to Population and more
|
||
* people means more births (Reinforcing); deaths remove from it and more people
|
||
* means more deaths (Balancing). Converters feed each rate: fertility raises
|
||
* births (`+`), life expectancy lowers deaths (`−`).
|
||
*/
|
||
function population(): Model {
|
||
// A grid-aligned cascade (20px grid): Source and both Converters stack in the left
|
||
// column, the Stock sits at the centre, and births → Population → deaths step down
|
||
// toward the Sink — so every Information Link lands in open space, not on a pipe.
|
||
// Valves are placed by hand, not at the midpoint, to hold the steps.
|
||
const source = makeCloud({ x: -360, y: -240 })
|
||
const fertility = makeConverter({ x: -360, y: -40 }, "fertility")
|
||
fertility.rule = { kind: "constant", value: 0.03 }
|
||
const lifeExpectancy = makeConverter({ x: -360, y: 240 }, "life expectancy")
|
||
// Wired into deaths for the Balancing loop's structure, but not yet read by the
|
||
// rate: a faithful "deaths = Population ÷ life expectancy" needs a divide rule we
|
||
// don't have, so deaths uses a flat mortality rate below. (See the gallery notes.)
|
||
lifeExpectancy.rule = { kind: "constant", value: 70 }
|
||
const people = makeStock({ x: 0, y: 0 }, "Population")
|
||
people.initialValue = 100
|
||
people.unit = "people"
|
||
const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id)
|
||
// births = fertility × Population (both `+` inputs): more people and higher
|
||
// fertility, more births — the Reinforcing engine.
|
||
births.rule = { kind: "proportional", factor: 1 }
|
||
const sink = makeCloud({ x: 360, y: 240 })
|
||
const deaths = makeFlow({ x: 160, y: 160 }, "deaths", people.id, sink.id)
|
||
// deaths = 2% of Population each step (its `+` input) — the Balancing drain. With
|
||
// births at 3%, the Reinforcing loop wins and the population grows exponentially.
|
||
deaths.rule = { kind: "proportional", factor: 0.02 }
|
||
return model(
|
||
"Population",
|
||
[source, people, sink, births, deaths, fertility, lifeExpectancy],
|
||
[
|
||
link(people, births, "+"),
|
||
link(fertility, births, "+"),
|
||
link(people, deaths, "+"),
|
||
link(lifeExpectancy, deaths, "-"),
|
||
],
|
||
{ start: 0, stop: 100, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Limits to growth — the S-curve, where a Reinforcing engine meets a Balancing
|
||
* brake. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
|
||
* Reinforcing inflow), but crowding rises with the population (Yeast → [+] →
|
||
* crowding) and drives a die-off that grows with the *square* of the Yeast
|
||
* (Yeast, crowding → [+] → die-off → drains Yeast: a Balancing outflow). Growth
|
||
* wins early, the die-off wins late, so Yeast settles where they balance (≈1000)
|
||
* — the classic sigmoid, with *both* loops visible to the detector. (A named
|
||
* "carrying capacity" would want a divide rule we don't have yet; here the ceiling
|
||
* falls out of the growth and die-off rates.)
|
||
*/
|
||
function limitsToGrowth(): Model {
|
||
const source = makeCloud({ x: -280, y: 0 })
|
||
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
|
||
yeast.initialValue = 20
|
||
yeast.unit = "cells"
|
||
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
|
||
// growth = 30% of Yeast (its `+` input): the Reinforcing engine.
|
||
growth.rule = { kind: "proportional", factor: 0.3 }
|
||
const sink = makeCloud({ x: 360, y: 0 })
|
||
const dieOff = makeFlow(midpoint(yeast.position, sink.position), "die-off", yeast.id, sink.id)
|
||
// die-off = factor × Yeast × crowding. With crowding ∝ Yeast it scales as Yeast²,
|
||
// so the Balancing drain overtakes the linear growth and Yeast plateaus.
|
||
dieOff.rule = { kind: "proportional", factor: 0.0003 }
|
||
// crowding ≈ the population density (proportional to Yeast), what drives the die-off.
|
||
const crowding = makeConverter({ x: 200, y: -160 }, "crowding")
|
||
crowding.rule = { kind: "proportional", factor: 1 }
|
||
return model(
|
||
"Limits to growth",
|
||
[source, yeast, growth, sink, dieOff, crowding],
|
||
[
|
||
link(yeast, growth, "+"),
|
||
link(yeast, crowding, "+"),
|
||
link(yeast, dieOff, "+"),
|
||
link(crowding, dieOff, "+"),
|
||
],
|
||
{ start: 0, stop: 40, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Predator and prey — the first model with two Stocks, coupled so each drives the
|
||
* other (Lotka–Volterra). 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 {
|
||
// Two aligned rows flowing left→right — Rabbits on top, Foxes below — each a full
|
||
// Source → birth → Stock → outflow → Sink lane. The two coupling links (Rabbits →
|
||
// fox births, Foxes → predation) run as clear diagonals between the rows, so the
|
||
// cross-stock loop traces a circuit through the open centre.
|
||
const preySource = makeCloud({ x: -480, y: -140 })
|
||
const rabbits = makeStock({ x: -80, y: -140 }, "Rabbits")
|
||
rabbits.initialValue = 100
|
||
const preySink = makeCloud({ x: 320, y: -140 })
|
||
const rabbitBirths = makeFlow(
|
||
midpoint(preySource.position, rabbits.position),
|
||
"rabbit births",
|
||
preySource.id,
|
||
rabbits.id,
|
||
)
|
||
// rabbits breed in proportion to themselves (Reinforcing) …
|
||
rabbitBirths.rule = { kind: "proportional", factor: 0.08 }
|
||
const predation = makeFlow(
|
||
midpoint(rabbits.position, preySink.position),
|
||
"predation",
|
||
rabbits.id,
|
||
preySink.id,
|
||
)
|
||
// … and are thinned by predation = rabbits × foxes (both `+`): the coupling term.
|
||
predation.rule = { kind: "proportional", factor: 0.004 }
|
||
const foxSource = makeCloud({ x: -480, y: 140 })
|
||
const foxes = makeStock({ x: -80, y: 140 }, "Foxes")
|
||
foxes.initialValue = 20
|
||
const foxSink = makeCloud({ x: 320, y: 140 })
|
||
const foxBirths = makeFlow(
|
||
midpoint(foxSource.position, foxes.position),
|
||
"fox births",
|
||
foxSource.id,
|
||
foxes.id,
|
||
)
|
||
// foxes are born in proportion to the rabbits available to eat …
|
||
foxBirths.rule = { kind: "proportional", factor: 0.02 }
|
||
const foxDeaths = makeFlow(
|
||
midpoint(foxes.position, foxSink.position),
|
||
"fox deaths",
|
||
foxes.id,
|
||
foxSink.id,
|
||
)
|
||
// … and die off on their own. The lag around the loop makes the two populations
|
||
// chase each other. (Forward Euler damps the orbit — see the gallery notes.)
|
||
foxDeaths.rule = { kind: "proportional", factor: 0.2 }
|
||
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, "+"),
|
||
],
|
||
{ start: 0, stop: 120, dt: 0.25 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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")
|
||
susceptible.initialValue = 990
|
||
susceptible.unit = "people"
|
||
const infected = makeStock({ x: 0, y: 0 }, "Infected")
|
||
infected.initialValue = 10
|
||
infected.unit = "people"
|
||
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
|
||
recovered.initialValue = 0
|
||
recovered.unit = "people"
|
||
const infection = makeFlow(
|
||
midpoint(susceptible.position, infected.position),
|
||
"infection",
|
||
susceptible.id,
|
||
infected.id,
|
||
)
|
||
// infection = infectivity × Susceptible × Infected (proportional reads all three
|
||
// `+` inputs): the more carriers and the more susceptibles, the faster it spreads.
|
||
// The non-negative-stock floor keeps Susceptible from being over-drained.
|
||
infection.rule = { kind: "proportional", factor: 1 }
|
||
const recovery = makeFlow(
|
||
midpoint(infected.position, recovered.position),
|
||
"recovery",
|
||
infected.id,
|
||
recovered.id,
|
||
)
|
||
// recovery = 15% of the Infected each step (its one `+` input).
|
||
recovery.rule = { kind: "proportional", factor: 0.15 }
|
||
const infectivity = makeConverter({ x: -140, y: -160 }, "infectivity")
|
||
// Small, so infectivity × S × I stays a sane rate (R0 = infectivity·S₀/γ ≈ 2.6).
|
||
infectivity.rule = { kind: "constant", value: 0.0004 }
|
||
return model(
|
||
"Epidemic",
|
||
[susceptible, infected, recovered, infection, recovery, infectivity],
|
||
[
|
||
link(susceptible, infection, "+"),
|
||
link(infected, infection, "+"),
|
||
link(infected, recovery, "+"),
|
||
link(infectivity, infection, "+"),
|
||
],
|
||
{ start: 0, stop: 60, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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")
|
||
pasture.initialValue = 1000
|
||
// 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: -640, y: 0 })
|
||
const herdA = makeStock({ x: -360, y: 0 }, "Herd A")
|
||
herdA.initialValue = 10
|
||
const growthA = makeFlow(
|
||
midpoint(sourceA.position, herdA.position),
|
||
"growth A",
|
||
sourceA.id,
|
||
herdA.id,
|
||
)
|
||
// growth = herd × Pasture (both `+`): each herd grows the more cattle it has and
|
||
// the more grass is left — a Reinforcing loop, braked only by the shared Pasture.
|
||
growthA.rule = { kind: "proportional", factor: 0.0003 }
|
||
const sinkA = makeCloud({ x: -200, y: 240 })
|
||
const grazingA = makeFlow(
|
||
midpoint(pasture.position, sinkA.position),
|
||
"grazing A",
|
||
pasture.id,
|
||
sinkA.id,
|
||
)
|
||
// grazing = 6% of the herd, drained from the *shared* Pasture (which never
|
||
// regrows here): two appetites racing one stock down to bare dirt.
|
||
grazingA.rule = { kind: "proportional", factor: 0.06 }
|
||
const sourceB = makeCloud({ x: 640, y: 0 })
|
||
const herdB = makeStock({ x: 360, y: 0 }, "Herd B")
|
||
herdB.initialValue = 10
|
||
const growthB = makeFlow(
|
||
midpoint(sourceB.position, herdB.position),
|
||
"growth B",
|
||
sourceB.id,
|
||
herdB.id,
|
||
)
|
||
growthB.rule = { kind: "proportional", factor: 0.0003 }
|
||
const sinkB = makeCloud({ x: 200, y: 240 })
|
||
const grazingB = makeFlow(
|
||
midpoint(pasture.position, sinkB.position),
|
||
"grazing B",
|
||
pasture.id,
|
||
sinkB.id,
|
||
)
|
||
grazingB.rule = { kind: "proportional", factor: 0.06 }
|
||
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, "+"),
|
||
],
|
||
{ start: 0, stop: 60, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
// Two parallel rows — Blue on top, Red below — each flowing left→right from a
|
||
// Source to its arsenal. The two cross-coupling links span the open centre and
|
||
// cross there, where the R badge lands, so the whole loop reads at a glance.
|
||
const blueSource = makeCloud({ x: -560, y: -120 })
|
||
const blueArsenal = makeStock({ x: 280, y: -120 }, "Blue arsenal")
|
||
blueArsenal.initialValue = 10
|
||
const blueBuildup = makeFlow(
|
||
midpoint(blueSource.position, blueArsenal.position),
|
||
"Blue buildup",
|
||
blueSource.id,
|
||
blueArsenal.id,
|
||
)
|
||
// Each side builds in proportion to the other's arsenal (its one `+` input), so
|
||
// the two feed each other: a Reinforcing loop with no brake → unbounded growth.
|
||
blueBuildup.rule = { kind: "proportional", factor: 0.1 }
|
||
const redSource = makeCloud({ x: -560, y: 120 })
|
||
const redArsenal = makeStock({ x: 280, y: 120 }, "Red arsenal")
|
||
redArsenal.initialValue = 12
|
||
const redBuildup = makeFlow(
|
||
midpoint(redSource.position, redArsenal.position),
|
||
"Red buildup",
|
||
redSource.id,
|
||
redArsenal.id,
|
||
)
|
||
redBuildup.rule = { kind: "proportional", factor: 0.1 }
|
||
return model(
|
||
"Escalation",
|
||
[blueSource, blueArsenal, blueBuildup, redSource, redArsenal, redBuildup],
|
||
[link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")],
|
||
{ start: 0, stop: 40, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* High Congestion prompts road building, and the new capacity drains it (Congestion
|
||
* → [+] → road building, an outflow: a Balancing fix). But roads also induce driving
|
||
* (road building → [+] → driving), and that extra traffic refills Congestion — the
|
||
* loop Congestion → road building → driving → Congestion carries no `−`, so it is
|
||
* Reinforcing: the cure feeds the disease, and you cannot build your way out of
|
||
* traffic.
|
||
*/
|
||
function fixesThatFail(): Model {
|
||
// Both flow valves share the left column (x = -120) so the backfire link
|
||
// road building → driving drops as a clean vertical, not a curl. Congestion sits
|
||
// on the right at driving's height; its road-building outflow runs up-left to the
|
||
// valve and on to the Sink, so the Reinforcing loop reads as the left edge plus
|
||
// the diagonal back to Congestion. Placed by hand, not midpoint, to hold the column.
|
||
const source = makeCloud({ x: -420, y: 120 })
|
||
const congestion = makeStock({ x: 300, y: 120 }, "Congestion")
|
||
congestion.initialValue = 50
|
||
const driving = makeFlow({ x: -120, y: 120 }, "driving", source.id, congestion.id)
|
||
// driving = 1.5 × road building (its `+` input): every new road induces *more*
|
||
// traffic than it cleared — the side effect that refills the symptom.
|
||
driving.rule = { kind: "proportional", factor: 1.5 }
|
||
const sink = makeCloud({ x: 300, y: -160 })
|
||
const roadBuilding = makeFlow({ x: -120, y: -160 }, "road building", congestion.id, sink.id)
|
||
// road building = 40% of Congestion (its `+` input), draining it: the Balancing
|
||
// fix. But induced driving outweighs it, so the Reinforcing loop wins and
|
||
// Congestion climbs anyway — you can't build your way out of traffic.
|
||
roadBuilding.rule = { kind: "proportional", factor: 0.4 }
|
||
return model(
|
||
"Fixes that fail",
|
||
[source, congestion, driving, sink, roadBuilding],
|
||
[link(congestion, roadBuilding, "+"), link(roadBuilding, driving, "+")],
|
||
{ start: 0, stop: 30, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Drift to low performance — the eroding-goals trap. Performance is under steady
|
||
* erosion (a constant `decay` leak: entropy, wear, rising demands), and the only
|
||
* thing fighting it is improvement, driven by the gap to the Standard you hold
|
||
* yourself to (Standard → [+] and Performance → [−] → improvement). Were the
|
||
* Standard fixed, improvement would find a floor — Performance would settle a little
|
||
* below the goal (here, at 60) and hold. But the Standard is not fixed: it slips
|
||
* toward whatever you are actually delivering (Standard → [+] and Performance → [−]
|
||
* → slippage), so there is no floor. Every notch Performance loses, the Standard
|
||
* follows down, weakening improvement, and decay carries Performance lower still —
|
||
* the loop Standard → improvement → Performance → slippage → Standard, two `−` → R.
|
||
* That R badge is the trap: both ratchet downhill in lockstep (Performance 70 → 10,
|
||
* Standard 80 → 20), the effort forever just losing to decay.
|
||
*/
|
||
function driftToLowPerformance(): Model {
|
||
// Performance sits low-left (fed by improvement from a Source, leaked away by
|
||
// decay to a Sink straight below it); Standard sits high-right (drained by
|
||
// slippage to a Sink). The two long links that close the Reinforcing loop cross
|
||
// in the open centre, where the R badge lands.
|
||
const source = makeCloud({ x: -560, y: 120 })
|
||
const performance = makeStock({ x: -160, y: 120 }, "Performance")
|
||
performance.initialValue = 70
|
||
const improvement = makeFlow(
|
||
midpoint(source.position, performance.position),
|
||
"improvement",
|
||
source.id,
|
||
performance.id,
|
||
)
|
||
// improvement closes the gap upward: 10% of (Standard − Performance), pulling
|
||
// Performance toward the Standard — the one force resisting decay.
|
||
improvement.rule = { kind: "gap", factor: 0.1 }
|
||
// decay leaks Performance away at a steady 2/step: the ever-present downward
|
||
// pressure the gap-driven improvement has to offset. Without it, two gap-closing
|
||
// flows would just meet in the middle; this is what makes the goal's erosion bite.
|
||
const decaySink = makeCloud({ x: -160, y: 400 })
|
||
const decay = makeFlow(
|
||
midpoint(performance.position, decaySink.position),
|
||
"decay",
|
||
performance.id,
|
||
decaySink.id,
|
||
)
|
||
decay.rule = { kind: "constant", value: 2 }
|
||
const standard = makeStock({ x: 160, y: -120 }, "Standard")
|
||
standard.initialValue = 80
|
||
const slippageSink = makeCloud({ x: 560, y: -120 })
|
||
const slippage = makeFlow(
|
||
midpoint(standard.position, slippageSink.position),
|
||
"slippage",
|
||
standard.id,
|
||
slippageSink.id,
|
||
)
|
||
// slippage erodes the Standard toward actual Performance, also at 10% of the gap.
|
||
// It is what removes the floor: the Standard sags after Performance instead of
|
||
// holding above it, so the gap stays open at a fixed 10 while both slide down.
|
||
slippage.rule = { kind: "gap", factor: 0.1 }
|
||
return model(
|
||
"Drift to low performance",
|
||
[source, performance, improvement, decay, decaySink, standard, slippageSink, slippage],
|
||
[
|
||
link(standard, improvement, "+"),
|
||
link(performance, improvement, "-"),
|
||
link(standard, slippage, "+"),
|
||
link(performance, slippage, "-"),
|
||
],
|
||
{ start: 0, stop: 60, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Overshoot and collapse — the dark twin of "Limits to growth", on a *renewable*
|
||
* Resource with a point of no return. A fishery (Fish) regrows on its own, but
|
||
* reproduction needs fish to find each other: spawning scales with density
|
||
* (spawning = factor × Fish × density, density ∝ Fish, so ~Fish²), while natural
|
||
* deaths are merely linear (natural deaths = factor × Fish). Above a critical
|
||
* density the quadratic births win and the stock climbs to its carrying capacity
|
||
* (crowding deaths ~Fish³ cap it there); *below* it the linear deaths win and the
|
||
* stock slides to an extinction it cannot climb back from — the Allee threshold,
|
||
* the renewable resource's hidden floor.
|
||
*
|
||
* A fishing fleet (Boats) reinvests its catch into more boats
|
||
* (Boats → catch → fleet growth → Boats, no `−` → Reinforcing), so the catch
|
||
* (catch = factor × Fish × Boats) accelerates and overshoots the renewal rate,
|
||
* dragging Fish under the threshold. Once there it is too late: even as the catch
|
||
* starves and the fleet scraps itself (Boats → [+] → scrapping, a Balancing drain),
|
||
* the Fish are gone for good and never recover. Contrast "Predator and prey", whose
|
||
* prey regrows from any level and so oscillates forever — here the prey has a floor
|
||
* it cannot climb back from, so a Reinforcing harvester collapses it permanently.
|
||
*
|
||
* The Allee curve is the one shape the proportional rule cannot draw alone (it needs
|
||
* net regrowth to go negative–positive–negative), so two relays build it: `density`
|
||
* (∝ Fish) lifts spawning to ~Fish², and `crowding` (∝ Fish²) lifts crowding deaths
|
||
* to ~Fish³ — the same crowding trick as "Limits to growth", doubled. The gallery's
|
||
* largest model, and the only one that needs a Converter feeding a Converter.
|
||
*/
|
||
function overshootAndCollapse(): Model {
|
||
// Fish (left) carries the whole renewal engine: a spawning inflow from the top, and
|
||
// three drains — natural deaths, crowding deaths, and the catch. Boats (right) runs a
|
||
// Source → fleet growth → Boats → scrapping → Sink column. The two coupling links —
|
||
// Boats → catch and catch → fleet growth — cross the open centre, where the R badge lands.
|
||
const fish = makeStock({ x: -420, y: 0 }, "Fish")
|
||
fish.initialValue = 1000
|
||
fish.unit = "tonnes"
|
||
// density ∝ Fish: how easily fish meet to spawn. Relays Fish into the births term so
|
||
// spawning reads as ~Fish² — the Allee mechanism (sparse fish breed slowly).
|
||
const density = makeConverter({ x: -700, y: -80 }, "density")
|
||
density.rule = { kind: "proportional", factor: 1 }
|
||
// crowding ∝ Fish² (Fish × density): the overcrowding pressure that lifts crowding
|
||
// deaths to ~Fish³, so the stock plateaus at its carrying capacity. A Converter read
|
||
// by a Converter — the only such wiring in the gallery.
|
||
const crowding = makeConverter({ x: -700, y: 80 }, "crowding")
|
||
crowding.rule = { kind: "proportional", factor: 1 }
|
||
const spawnSource = makeCloud({ x: -420, y: -320 })
|
||
const spawning = makeFlow({ x: -420, y: -160 }, "spawning", spawnSource.id, fish.id)
|
||
// spawning = factor × Fish × density (~Fish²): the Reinforcing birth engine that
|
||
// needs a crowd — it falls away faster than deaths as the Fish thin out.
|
||
spawning.rule = { kind: "proportional", factor: 0.00036 }
|
||
const deathSink = makeCloud({ x: -720, y: 280 })
|
||
const naturalDeaths = makeFlow({ x: -580, y: 180 }, "natural deaths", fish.id, deathSink.id)
|
||
// natural deaths = factor × Fish (linear): the Balancing drain that *wins* below the
|
||
// Allee threshold, where ~Fish² spawning can no longer keep up — and extinction follows.
|
||
naturalDeaths.rule = { kind: "proportional", factor: 0.01 }
|
||
const crowdSink = makeCloud({ x: -420, y: 320 })
|
||
const crowdingDeaths = makeFlow({ x: -420, y: 160 }, "crowding deaths", fish.id, crowdSink.id)
|
||
// crowding deaths = factor × Fish × crowding (~Fish³): the steep Balancing ceiling
|
||
// that holds the healthy stock at carrying capacity.
|
||
crowdingDeaths.rule = { kind: "proportional", factor: 3e-7 }
|
||
const catchSink = makeCloud({ x: -90, y: 220 })
|
||
const catching = makeFlow({ x: -255, y: 90 }, "catch", fish.id, catchSink.id)
|
||
// catch = factor × Fish × Boats (both `+`): more boats and more fish both lift the
|
||
// haul. This is what overshoots the renewal rate and pulls Fish under the threshold.
|
||
catching.rule = { kind: "proportional", factor: 0.0004 }
|
||
const boats = makeStock({ x: 420, y: 0 }, "Boats")
|
||
boats.initialValue = 5
|
||
const fleetSource = makeCloud({ x: 420, y: -320 })
|
||
const fleetGrowth = makeFlow({ x: 420, y: -160 }, "fleet growth", fleetSource.id, boats.id)
|
||
// fleet growth = factor × catch (its one `+` input): the revenue reinvested — a Flow
|
||
// feeding a Flow, the edge that closes the Reinforcing loop through Boats.
|
||
fleetGrowth.rule = { kind: "proportional", factor: 0.5 }
|
||
const scrapSink = makeCloud({ x: 420, y: 320 })
|
||
const scrapping = makeFlow({ x: 420, y: 160 }, "scrapping", boats.id, scrapSink.id)
|
||
// scrapping = factor × Boats (its `+` input): the Balancing drain that takes the fleet
|
||
// down once the catch can no longer feed fleet growth.
|
||
scrapping.rule = { kind: "proportional", factor: 0.04 }
|
||
return model(
|
||
"Overshoot and collapse",
|
||
[
|
||
fish,
|
||
density,
|
||
crowding,
|
||
spawnSource,
|
||
spawning,
|
||
deathSink,
|
||
naturalDeaths,
|
||
crowdSink,
|
||
crowdingDeaths,
|
||
catchSink,
|
||
catching,
|
||
boats,
|
||
fleetSource,
|
||
fleetGrowth,
|
||
scrapSink,
|
||
scrapping,
|
||
],
|
||
[
|
||
link(fish, density, "+"),
|
||
link(fish, crowding, "+"),
|
||
link(density, crowding, "+"),
|
||
link(fish, spawning, "+"),
|
||
link(density, spawning, "+"),
|
||
link(fish, naturalDeaths, "+"),
|
||
link(fish, crowdingDeaths, "+"),
|
||
link(crowding, crowdingDeaths, "+"),
|
||
link(fish, catching, "+"),
|
||
link(boats, catching, "+"),
|
||
link(catching, fleetGrowth, "+"),
|
||
link(boats, scrapping, "+"),
|
||
],
|
||
// Fish drift up toward carrying capacity (~1170) while the fleet compounds, then the
|
||
// catch overshoots the renewal rate and pulls them past the Allee threshold (~30) at
|
||
// t≈43, after which they go extinct and stay there; Boats overshoot to ~700 (t≈37)
|
||
// and collapse back near their start by t=150.
|
||
{ start: 0, stop: 150, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/**
|
||
* AI deskilling spiral — a classic trap in today's clothes: "Shifting the burden to
|
||
* the intervenor" (Thinking in Systems, ch. 5), where leaning on an outside fixer
|
||
* atrophies your own capacity to solve the problem, so you depend on the fixer ever
|
||
* more. Here the intervenor is AI. Technical debt drives reliance on it (the more cruft
|
||
* and delivery pressure, the more you reach for the model: Technical debt → [+] → AI
|
||
* reliance); AI churns out plausible code that adds debt (AI reliance → [+] → debt
|
||
* accrual) and lets skills lapse (AI reliance → [+] → atrophy, draining Expertise); and
|
||
* a thinner-skilled team refactors less (Expertise → [+] → refactoring, the Balancing
|
||
* payoff that now weakens). The loop Technical debt → AI reliance → atrophy → Expertise
|
||
* → refactoring → Technical debt carries two `−` (the two outflows) → Reinforcing: the
|
||
* spiral. The hopeful brake is learning — practice pulling Expertise back toward its
|
||
* ceiling (skill ceiling → [+], Expertise → [−] → learning: a Balancing loop) — but
|
||
* tuned here it loses to the spiral. (Code quality and lead time aren't nodes: quality
|
||
* reads as the inverse of Technical debt, lead time as the inverse of Expertise;
|
||
* "model price" lives in the AI-reliance factor — cheaper models, higher reliance.)
|
||
*/
|
||
function aiDeskillingSpiral(): Model {
|
||
// Two lanes — Expertise on top, Technical debt below — each a full Source → inflow →
|
||
// Stock → outflow → Sink. The AI reliance Converter sits between them, reading the
|
||
// debt below and feeding both atrophy (top lane) and debt accrual: the couplings that
|
||
// close the Reinforcing spiral cross the open centre.
|
||
const skillCeiling = makeConverter({ x: -360, y: -300 }, "skill ceiling")
|
||
skillCeiling.rule = { kind: "constant", value: 100 }
|
||
const learningSource = makeCloud({ x: -540, y: -120 })
|
||
const expertise = makeStock({ x: -180, y: -120 }, "Expertise")
|
||
expertise.initialValue = 70
|
||
const learning = makeFlow({ x: -360, y: -120 }, "learning", learningSource.id, expertise.id)
|
||
// learning = factor × (skill ceiling − Expertise): practice pulls skill back up — the
|
||
// Balancing brake. The further from mastery, the harder you study.
|
||
learning.rule = { kind: "gap", factor: 0.04 }
|
||
const atrophySink = makeCloud({ x: 300, y: -120 })
|
||
const atrophy = makeFlow({ x: 80, y: -120 }, "atrophy", expertise.id, atrophySink.id)
|
||
// atrophy = factor × AI reliance: the more you offload to AI, the faster unused skills
|
||
// lapse — the side effect that makes this an addiction, not a fix.
|
||
atrophy.rule = { kind: "proportional", factor: 0.6 }
|
||
const accrualSource = makeCloud({ x: -540, y: 120 })
|
||
const debt = makeStock({ x: -180, y: 120 }, "Technical debt")
|
||
debt.initialValue = 20
|
||
const accrual = makeFlow({ x: -360, y: 120 }, "debt accrual", accrualSource.id, debt.id)
|
||
// debt accrual = factor × AI reliance: AI emits plausible code faster than anyone
|
||
// reviews it, so debt grows the more you lean on it.
|
||
accrual.rule = { kind: "proportional", factor: 0.9 }
|
||
const refactorSink = makeCloud({ x: 300, y: 120 })
|
||
const refactoring = makeFlow({ x: 80, y: 120 }, "refactoring", debt.id, refactorSink.id)
|
||
// refactoring = factor × Expertise × Technical debt: skilled teams pay debt down in
|
||
// proportion to how much there is — the Balancing payoff the spiral starves.
|
||
refactoring.rule = { kind: "proportional", factor: 0.0009 }
|
||
const reliance = makeConverter({ x: -180, y: 0 }, "AI reliance")
|
||
// AI reliance = factor × Technical debt: the factor is how cheap and available models
|
||
// are — lower model price, higher reliance per unit of debt.
|
||
reliance.rule = { kind: "proportional", factor: 0.1 }
|
||
return model(
|
||
"AI deskilling spiral",
|
||
[
|
||
skillCeiling,
|
||
learningSource,
|
||
expertise,
|
||
learning,
|
||
atrophySink,
|
||
atrophy,
|
||
accrualSource,
|
||
debt,
|
||
accrual,
|
||
refactorSink,
|
||
refactoring,
|
||
reliance,
|
||
],
|
||
[
|
||
link(skillCeiling, learning, "+"),
|
||
link(expertise, learning, "-"),
|
||
link(reliance, atrophy, "+"),
|
||
link(debt, reliance, "+"),
|
||
link(reliance, accrual, "+"),
|
||
link(expertise, refactoring, "+"),
|
||
link(debt, refactoring, "+"),
|
||
],
|
||
// Expertise slides 70 → ~6 and Technical debt spirals 20 → ~150 over the window —
|
||
// the Reinforcing loop clearly taking off, stopped (like "Escalation") before it
|
||
// runs away off-chart.
|
||
{ start: 0, stop: 50, dt: 1 },
|
||
)
|
||
}
|
||
|
||
/** The gallery, ordered simplest first. */
|
||
export const SAMPLES: Sample[] = [
|
||
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
|
||
{
|
||
title: "Savings account",
|
||
blurb: "Interest on a balance: a Reinforcing loop.",
|
||
build: savings,
|
||
},
|
||
{
|
||
title: "Coffee cooling",
|
||
blurb: "Settling toward room temperature: a Balancing loop.",
|
||
build: coffee,
|
||
},
|
||
{
|
||
title: "Population",
|
||
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,
|
||
},
|
||
{
|
||
title: "Fixes that fail",
|
||
blurb: "Road building eases congestion (B) but induces the traffic that refills it (R).",
|
||
build: fixesThatFail,
|
||
},
|
||
{
|
||
title: "Drift to low performance",
|
||
blurb: "An eroding goal leaves steady decay no floor: both slide downhill.",
|
||
build: driftToLowPerformance,
|
||
},
|
||
{
|
||
title: "Overshoot and collapse",
|
||
blurb: "A fleet overfishes past the point of no return: the stock collapses for good.",
|
||
build: overshootAndCollapse,
|
||
},
|
||
{
|
||
title: "AI deskilling spiral",
|
||
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
|
||
build: aiDeskillingSpiral,
|
||
},
|
||
]
|