diff --git a/src/model/samples.ts b/src/model/samples.ts index c9bac14..2cebd73 100644 --- a/src/model/samples.ts +++ b/src/model/samples.ts @@ -71,9 +71,23 @@ export interface Sample { 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 } +/** + * Information Link between two already-built nodes, with an explicit polarity and + * an optional why-note — the per-link half of each sample's selection tour (G4). + */ +function link( + source: ModelNode, + target: ModelNode, + polarity: Polarity, + description?: string, +): InformationLink { + return { + id: newId("link"), + source: source.id, + target: target.id, + polarity, + ...(description !== undefined && { description }), + } } function model( @@ -95,6 +109,8 @@ function bathtub(): Model { const water = makeStock({ x: 0, y: 0 }, "Water") water.initialValue = 20 water.unit = "L" + water.description = + "The water level — the one thing that accumulates here. It rises or falls only as the two flows differ." const sink = makeCloud({ x: 280, y: 0 }) const filling = makeFlow( midpoint(source.position, water.position), @@ -102,7 +118,11 @@ function bathtub(): Model { source.id, water.id, ) + filling.description = + "A constant 5 L/step from the tap (the Source cloud). Nothing reads the level, so it never reacts — pure inflow." const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.id) + emptying.description = + "A constant 3 L/step to the drain (the Sink). Slower than filling, so the level climbs in a straight line." // 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 } @@ -127,6 +147,7 @@ function savings(): Model { const balance = makeStock({ x: 120, y: 40 }, "Balance") balance.initialValue = 1000 balance.unit = "$" + balance.description = "The money in the account — the stock that interest compounds on." const interest = makeFlow( midpoint(source.position, balance.position), "interest", @@ -136,11 +157,25 @@ function savings(): Model { // 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, - }) + interest.description = + "5% of the Balance paid in each step. Because it scales with the Balance, more money earns more — the loop's engine." + return model( + "Savings account", + [source, balance, interest], + [ + link( + balance, + interest, + "+", + "More Balance → more interest. This + feeds the Stock back into its own inflow, closing the Reinforcing loop: exponential growth.", + ), + ], + { + start: 0, + stop: 40, + dt: 1, + }, + ) } /** @@ -153,17 +188,35 @@ function coffee(): Model { const coffee = makeStock({ x: -200, y: 0 }, "Coffee") coffee.initialValue = 90 coffee.unit = "°C" + coffee.description = "The cup's temperature — the level that cools over time toward the room." 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 } + cooling.description = + "Heat leaving the cup, 10% of the gap to the room each step. The bigger the gap the faster it cools — and it stops once they match." const room = makeConverter({ x: 0, y: -160 }, "room temperature") room.rule = { kind: "constant", value: 20 } + room.description = + "Room temperature, a fixed 20 °C: the target the coffee settles toward, never changed by the model." return model( "Coffee cooling", [coffee, sink, cooling, room], - [link(coffee, cooling, "+"), link(room, cooling, "-")], + [ + link( + coffee, + cooling, + "+", + "Hotter coffee → faster cooling. Coffee is the `+` input (the level) in the gap rule.", + ), + link( + room, + cooling, + "-", + "A warmer room → slower cooling. Room is the `−` input (the target); this lone minus makes the loop Balancing.", + ), + ], { start: 0, stop: 60, dt: 1 }, ) } @@ -182,31 +235,51 @@ function population(): Model { const source = makeCloud({ x: -360, y: -240 }) const fertility = makeConverter({ x: -360, y: -40 }, "fertility") fertility.rule = { kind: "constant", value: 0.03 } + fertility.description = + "Births per person per step (a fixed rate). Higher fertility lifts the birth rate." 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 } + lifeExpectancy.description = + "How long people live (a fixed 70). Longer lives mean fewer deaths — wired in for the loop's shape, though the flat death rate here doesn't yet read it." const people = makeStock({ x: 0, y: 0 }, "Population") people.initialValue = 100 people.unit = "people" + people.description = + "The population — births add to it, deaths drain it; everything else here just sets those two rates." 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 } + births.description = + "New people arriving, fertility × Population. More people make more births — the Reinforcing inflow." 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 } + deaths.description = + "People leaving, 2% of the population each step. More people make more deaths too — the Balancing outflow." return model( "Population", [source, people, sink, births, deaths, fertility, lifeExpectancy], [ - link(people, births, "+"), - link(fertility, births, "+"), - link(people, deaths, "+"), - link(lifeExpectancy, deaths, "-"), + link( + people, + births, + "+", + "More people → more births: the + that makes the birth loop Reinforcing.", + ), + link(fertility, births, "+", "Higher fertility → more births."), + link( + people, + deaths, + "+", + "More people → more deaths: the + that makes the death loop Balancing.", + ), + link(lifeExpectancy, deaths, "-", "Longer life expectancy → fewer deaths (the − input)."), ], { start: 0, stop: 100, dt: 1 }, ) @@ -228,25 +301,38 @@ function limitsToGrowth(): Model { const yeast = makeStock({ x: 40, y: 0 }, "Yeast") yeast.initialValue = 20 yeast.unit = "cells" + yeast.description = + "The yeast population — it multiplies on itself early, then a crowding die-off catches up and holds it at a ceiling." 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 } + growth.description = + "New cells, 30% of the population each step. More yeast → more growth — the Reinforcing engine that wins while the colony is small." 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 } + dieOff.description = + "Cells dying, scaled by Yeast × crowding (≈ Yeast²). It overtakes the linear growth as the colony fills up — the Balancing brake." // 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 } + crowding.description = + "Population density (∝ Yeast): the relay that lets the die-off grow with the square of the population, so a ceiling emerges." return model( "Limits to growth", [source, yeast, growth, sink, dieOff, crowding], [ - link(yeast, growth, "+"), - link(yeast, crowding, "+"), - link(yeast, dieOff, "+"), - link(crowding, dieOff, "+"), + link(yeast, growth, "+", "More yeast → more growth: the Reinforcing +."), + link(yeast, crowding, "+", "More yeast → more crowding (density rises with the population)."), + link(yeast, dieOff, "+", "More yeast → more dying."), + link( + crowding, + dieOff, + "+", + "More crowding → more dying. With yeast this makes the die-off ≈ Yeast² — the brake that caps growth.", + ), ], { start: 0, stop: 40, dt: 1 }, ) @@ -268,6 +354,8 @@ function predatorPrey(): Model { const preySource = makeCloud({ x: -480, y: -140 }) const rabbits = makeStock({ x: -80, y: -140 }, "Rabbits") rabbits.initialValue = 100 + rabbits.description = + "The prey population — breeds on its own and is thinned by foxes; half of the oscillating pair." const preySink = makeCloud({ x: 320, y: -140 }) const rabbitBirths = makeFlow( midpoint(preySource.position, rabbits.position), @@ -277,6 +365,8 @@ function predatorPrey(): Model { ) // rabbits breed in proportion to themselves (Reinforcing) … rabbitBirths.rule = { kind: "proportional", factor: 0.08 } + rabbitBirths.description = + "Rabbits breeding, 8% of themselves each step — the Reinforcing growth the foxes keep in check." const predation = makeFlow( midpoint(rabbits.position, preySink.position), "predation", @@ -285,9 +375,13 @@ function predatorPrey(): Model { ) // … and are thinned by predation = rabbits × foxes (both `+`): the coupling term. predation.rule = { kind: "proportional", factor: 0.004 } + predation.description = + "Rabbits eaten, scaled by Rabbits × Foxes — the coupling term that ties the two populations together." const foxSource = makeCloud({ x: -480, y: 140 }) const foxes = makeStock({ x: -80, y: 140 }, "Foxes") foxes.initialValue = 20 + foxes.description = + "The predator population — fed by eating rabbits and dying off on its own; the other half of the pair." const foxSink = makeCloud({ x: 320, y: 140 }) const foxBirths = makeFlow( midpoint(foxSource.position, foxes.position), @@ -297,6 +391,8 @@ function predatorPrey(): Model { ) // foxes are born in proportion to the rabbits available to eat … foxBirths.rule = { kind: "proportional", factor: 0.02 } + foxBirths.description = + "New foxes, in proportion to the rabbits available to eat: more prey → more predators." const foxDeaths = makeFlow( midpoint(foxes.position, foxSink.position), "fox deaths", @@ -306,6 +402,8 @@ function predatorPrey(): Model { // … 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 } + foxDeaths.description = + "Foxes dying off on their own — the drain that thins the predators once the rabbits they feed on run low." return model( "Predator and prey", [ @@ -321,11 +419,21 @@ function predatorPrey(): Model { foxDeaths, ], [ - link(rabbits, rabbitBirths, "+"), - link(rabbits, predation, "+"), - link(foxes, predation, "+"), - link(rabbits, foxBirths, "+"), - link(foxes, foxDeaths, "+"), + link( + rabbits, + rabbitBirths, + "+", + "More rabbits → more rabbit births: the prey's Reinforcing loop.", + ), + link(rabbits, predation, "+", "More rabbits → more of them get eaten."), + link( + foxes, + predation, + "+", + "More foxes → more predation. With rabbits, this product is the kill rate.", + ), + link(rabbits, foxBirths, "+", "More rabbits → more foxes born (food drives the predators)."), + link(foxes, foxDeaths, "+", "More foxes → more fox deaths."), ], { start: 0, stop: 120, dt: 0.25 }, ) @@ -344,12 +452,18 @@ function epidemic(): Model { const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible") susceptible.initialValue = 990 susceptible.unit = "people" + susceptible.description = + "People who can still catch it — the pool the outbreak draws from, draining as it spreads." const infected = makeStock({ x: 0, y: 0 }, "Infected") infected.initialValue = 10 infected.unit = "people" + infected.description = + "People currently carrying and spreading it — rises in the outbreak, then drains as they recover." const recovered = makeStock({ x: 280, y: 0 }, "Recovered") recovered.initialValue = 0 recovered.unit = "people" + recovered.description = + "People past the illness and immune — a terminal Stock on no loop; it only ever fills." const infection = makeFlow( midpoint(susceptible.position, infected.position), "infection", @@ -360,6 +474,8 @@ function epidemic(): Model { // `+` 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 } + infection.description = + "New cases, ∝ infectivity × Susceptible × Infected: it needs both a carrier and someone to infect, so it speeds up as Infected grows, then starves as Susceptible runs out." const recovery = makeFlow( midpoint(infected.position, recovered.position), "recovery", @@ -368,17 +484,26 @@ function epidemic(): Model { ) // recovery = 15% of the Infected each step (its one `+` input). recovery.rule = { kind: "proportional", factor: 0.15 } + recovery.description = + "People recovering, 15% of the Infected each step — the Balancing drain that ends the outbreak." 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 } + infectivity.description = + "How contagious it is (a fixed rate): the dial that sets R₀ and the overall pace of the outbreak." return model( "Epidemic", [susceptible, infected, recovered, infection, recovery, infectivity], [ - link(susceptible, infection, "+"), - link(infected, infection, "+"), - link(infected, recovery, "+"), - link(infectivity, infection, "+"), + link(susceptible, infection, "+", "More susceptibles → faster spread."), + link( + infected, + infection, + "+", + "More carriers → faster spread: the Reinforcing arm of the outbreak.", + ), + link(infected, recovery, "+", "More infected → more recoveries."), + link(infectivity, infection, "+", "Higher infectivity → faster spread."), ], { start: 0, stop: 60, dt: 1 }, ) @@ -398,12 +523,15 @@ function epidemic(): Model { function tragedyOfTheCommons(): Model { const pasture = makeStock({ x: 0, y: 0 }, "Pasture") pasture.initialValue = 1000 + pasture.description = + "The shared grass — one Stock both herds graze, with no regrowth here. The commons the trap exhausts." // 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 + herdA.description = "Herd A's cattle — grows on itself and grazes the shared Pasture." const growthA = makeFlow( midpoint(sourceA.position, herdA.position), "growth A", @@ -413,6 +541,8 @@ function tragedyOfTheCommons(): Model { // 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 } + growthA.description = + "Herd A breeding, ∝ herd × Pasture: more cattle and more grass both speed it — a Reinforcing loop braked only by the shared Pasture." const sinkA = makeCloud({ x: -200, y: 240 }) const grazingA = makeFlow( midpoint(pasture.position, sinkA.position), @@ -423,9 +553,12 @@ function tragedyOfTheCommons(): Model { // 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 } + grazingA.description = + "Grass eaten by Herd A, 6% of the herd, drained from the shared Pasture — one of two appetites on one Stock." const sourceB = makeCloud({ x: 640, y: 0 }) const herdB = makeStock({ x: 360, y: 0 }, "Herd B") herdB.initialValue = 10 + herdB.description = "Herd B's cattle — identical to A, racing it for the same grass." const growthB = makeFlow( midpoint(sourceB.position, herdB.position), "growth B", @@ -433,6 +566,8 @@ function tragedyOfTheCommons(): Model { herdB.id, ) growthB.rule = { kind: "proportional", factor: 0.0003 } + growthB.description = + "Herd B breeding, the same rule as A: a Reinforcing loop on the shared Pasture." const sinkB = makeCloud({ x: 200, y: 240 }) const grazingB = makeFlow( midpoint(pasture.position, sinkB.position), @@ -441,16 +576,27 @@ function tragedyOfTheCommons(): Model { sinkB.id, ) grazingB.rule = { kind: "proportional", factor: 0.06 } + grazingB.description = "Grass eaten by Herd B — the second appetite draining the same Stock." 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, "+"), + link(herdA, growthA, "+", "More cattle in A → more growth: A's Reinforcing engine."), + link(herdA, grazingA, "+", "More cattle in A → more grazing off the commons."), + link( + pasture, + growthA, + "+", + "More Pasture → faster growth for A: the weak, shared brake that runs through the one Stock.", + ), + link(herdB, growthB, "+", "More cattle in B → more growth: B's Reinforcing engine."), + link(herdB, grazingB, "+", "More cattle in B → more grazing off the commons."), + link( + pasture, + growthB, + "+", + "More Pasture → faster growth for B: the same shared brake, too slow to stop the race.", + ), ], { start: 0, stop: 60, dt: 1 }, ) @@ -472,6 +618,7 @@ function escalation(): Model { const blueSource = makeCloud({ x: -560, y: -120 }) const blueArsenal = makeStock({ x: 280, y: -120 }, "Blue arsenal") blueArsenal.initialValue = 10 + blueArsenal.description = "Blue's weapons — built up purely in answer to Red's." const blueBuildup = makeFlow( midpoint(blueSource.position, blueArsenal.position), "Blue buildup", @@ -481,9 +628,13 @@ function escalation(): Model { // 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 } + blueBuildup.description = + "Blue arming, 10% of Red's arsenal each step — it reacts only to the other side, never to itself." const redSource = makeCloud({ x: -560, y: 120 }) const redArsenal = makeStock({ x: 280, y: 120 }, "Red arsenal") redArsenal.initialValue = 12 + redArsenal.description = + "Red's weapons — built up purely in answer to Blue's; the mirror of Blue." const redBuildup = makeFlow( midpoint(redSource.position, redArsenal.position), "Red buildup", @@ -491,10 +642,19 @@ function escalation(): Model { redArsenal.id, ) redBuildup.rule = { kind: "proportional", factor: 0.1 } + redBuildup.description = "Red arming, 10% of Blue's arsenal each step — the symmetric response." return model( "Escalation", [blueSource, blueArsenal, blueBuildup, redSource, redArsenal, redBuildup], - [link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")], + [ + link(blueArsenal, redBuildup, "+", "More Blue → more Red buildup."), + link( + redArsenal, + blueBuildup, + "+", + "More Red → more Blue buildup. With the other +, the loop has no minus → Reinforcing: both grow without bound.", + ), + ], { start: 0, stop: 40, dt: 1 }, ) } @@ -518,20 +678,39 @@ function fixesThatFail(): Model { const source = makeCloud({ x: -420, y: 120 }) const congestion = makeStock({ x: 300, y: 120 }, "Congestion") congestion.initialValue = 50 + congestion.description = + "Traffic congestion — the symptom. Road building drains it, but the induced driving keeps refilling it." 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 } + driving.description = + "Traffic pouring in, 1.5× the road building — every new road induces more driving than it cleared: the backfiring side effect." 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 } + roadBuilding.description = + "Capacity added, 40% of the Congestion — the quick fix that drains the symptom (a Balancing outflow)." return model( "Fixes that fail", [source, congestion, driving, sink, roadBuilding], - [link(congestion, roadBuilding, "+"), link(roadBuilding, driving, "+")], + [ + link( + congestion, + roadBuilding, + "+", + "More congestion → more road building: the fix responding to the symptom.", + ), + link( + roadBuilding, + driving, + "+", + "More roads → more driving. This + closes the Reinforcing loop that refills congestion — you can't build your way out of traffic.", + ), + ], { start: 0, stop: 30, dt: 1 }, ) } @@ -558,6 +737,8 @@ function driftToLowPerformance(): Model { const source = makeCloud({ x: -560, y: 120 }) const performance = makeStock({ x: -160, y: 120 }, "Performance") performance.initialValue = 70 + performance.description = + "Actual performance — pushed up by improvement, leaked away by decay, and forever chasing a Standard that keeps sinking under it." const improvement = makeFlow( midpoint(source.position, performance.position), "improvement", @@ -567,6 +748,8 @@ function driftToLowPerformance(): Model { // 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 } + improvement.description = + "Effort closing the gap up to the Standard, 10% per step — the one force resisting decay." // 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. @@ -578,8 +761,12 @@ function driftToLowPerformance(): Model { decaySink.id, ) decay.rule = { kind: "constant", value: 2 } + decay.description = + "Steady erosion, 2/step (wear, rising demands): the ever-present downward pressure improvement has to offset." const standard = makeStock({ x: 160, y: -120 }, "Standard") standard.initialValue = 80 + standard.description = + "The goal you hold yourself to — but it slips toward whatever you're actually delivering, so it never holds a floor." const slippageSink = makeCloud({ x: 560, y: -120 }) const slippage = makeFlow( midpoint(standard.position, slippageSink.position), @@ -591,14 +778,36 @@ function driftToLowPerformance(): Model { // 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 } + slippage.description = + "The Standard eroding toward actual Performance, 10% of the gap — what removes the floor and lets both slide down together." 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, "-"), + link( + standard, + improvement, + "+", + "Standard is the level in improvement's gap: a higher Standard opens a wider gap → more improvement.", + ), + link( + performance, + improvement, + "-", + "Performance is the target: the closer it climbs to the Standard, the less improvement is needed (the − input).", + ), + link( + standard, + slippage, + "+", + "Standard is the level in slippage's gap: the higher it sits above Performance, the faster it sags.", + ), + link( + performance, + slippage, + "-", + "Performance is the target slippage drags the Standard toward (the − input). Two minuses round the loop → Reinforcing: both ratchet downhill.", + ), ], { start: 0, stop: 60, dt: 1 }, ) @@ -638,47 +847,67 @@ function overshootAndCollapse(): Model { const fish = makeStock({ x: -420, y: 0 }, "Fish") fish.initialValue = 1000 fish.unit = "tonnes" + fish.description = + "The fishery — a renewable resource that regrows by spawning but has a floor (the Allee threshold) below which it can't recover." // 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 } + density.description = + "How easily fish meet to spawn (∝ Fish). Relays the stock into spawning so births scale with ≈ Fish² — sparse fish breed slowly." // 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 } + crowding.description = + "Overcrowding pressure (∝ Fish²). Lifts crowding deaths to ≈ Fish³ so a healthy stock plateaus at carrying capacity. A Converter read by a Converter." 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 } + spawning.description = + "Births, ∝ Fish × density (≈ Fish²): the Reinforcing engine that needs a crowd — it falls away faster than deaths as the Fish thin out." 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 } + naturalDeaths.description = + "Ordinary mortality, linear in Fish. Below the Allee threshold this linear drain beats ≈ Fish² spawning, and extinction follows." 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 } + crowdingDeaths.description = + "Deaths from overcrowding, ≈ Fish³: the steep Balancing ceiling that holds a healthy stock at carrying capacity." 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 } + catching.description = + "The catch, ∝ Fish × Boats: more boats and more fish both lift the haul. This is what overshoots renewal and drags Fish under the threshold." const boats = makeStock({ x: 420, y: 0 }, "Boats") boats.initialValue = 5 + boats.description = + "The fishing fleet — reinvests its catch into more boats, so it compounds and overshoots, then scraps itself once the fish are gone." 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 } + fleetGrowth.description = + "New boats bought from revenue, ∝ catch — a Flow feeding a Flow; the edge that closes the Reinforcing loop through Boats." 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 } + scrapping.description = + "Boats retired, linear in Boats — the Balancing drain that shrinks the fleet once the catch can no longer feed growth." return model( "Overshoot and collapse", [ @@ -700,18 +929,38 @@ function overshootAndCollapse(): Model { 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, "+"), + link(fish, density, "+", "More Fish → higher density (easier to meet and spawn)."), + link(fish, crowding, "+", "More Fish → more crowding pressure."), + link(density, crowding, "+", "Density feeds crowding, so crowding tracks ≈ Fish²."), + link(fish, spawning, "+", "More Fish → more spawning."), + link( + density, + spawning, + "+", + "Higher density → more spawning. With Fish this makes births ≈ Fish² — the Allee mechanism.", + ), + link(fish, naturalDeaths, "+", "More Fish → more natural deaths (linear)."), + link(fish, crowdingDeaths, "+", "More Fish → more crowding deaths."), + link( + crowding, + crowdingDeaths, + "+", + "More crowding → more crowding deaths. With Fish this makes deaths ≈ Fish³ — the carrying-capacity ceiling.", + ), + link(fish, catching, "+", "More Fish → a bigger catch."), + link( + boats, + catching, + "+", + "More Boats → a bigger catch: the coupling that overfishes the stock.", + ), + link( + catching, + fleetGrowth, + "+", + "A bigger catch → more boats bought: the + that makes the fleet loop Reinforcing.", + ), + link(boats, scrapping, "+", "More Boats → more scrapping (the Balancing drain)."), ], // 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 @@ -745,34 +994,50 @@ function aiDeskillingSpiral(): Model { // close the Reinforcing spiral cross the open centre. const skillCeiling = makeConverter({ x: -360, y: -300 }, "skill ceiling") skillCeiling.rule = { kind: "constant", value: 100 } + skillCeiling.description = + "The team's potential skill (a fixed 100): the ceiling learning pulls Expertise back toward." const learningSource = makeCloud({ x: -540, y: -120 }) const expertise = makeStock({ x: -180, y: -120 }, "Expertise") expertise.initialValue = 70 + expertise.description = + "The team's skill — built by learning, drained by atrophy as work is offloaded to AI. Read its inverse as lead time." 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 } + learning.description = + "Practice closing the gap up to the skill ceiling, 4% per step — the Balancing brake, tuned here to lose to the spiral." 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 } + atrophy.description = + "Skills lapsing, ∝ AI reliance: the more you offload to AI, the faster unused skills fade — the side effect that makes this an addiction." const accrualSource = makeCloud({ x: -540, y: 120 }) const debt = makeStock({ x: -180, y: 120 }, "Technical debt") debt.initialValue = 20 + debt.description = + "Technical debt — grows as AI emits unreviewed code, paid down by refactoring. Read its inverse as code quality." 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 } + accrual.description = + "Debt added, ∝ AI reliance: AI writes plausible code faster than anyone reviews it." 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 } + refactoring.description = + "Debt paid down, ∝ Expertise × Technical debt: skilled teams clear it in proportion to how much there is — the Balancing payoff the spiral starves." 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 } + reliance.description = + "How hard the team leans on AI, ∝ Technical debt. The factor is how cheap and available models are — lower price, higher reliance." return model( "AI deskilling spiral", [ @@ -790,13 +1055,38 @@ function aiDeskillingSpiral(): Model { reliance, ], [ - link(skillCeiling, learning, "+"), - link(expertise, learning, "-"), - link(reliance, atrophy, "+"), - link(debt, reliance, "+"), - link(reliance, accrual, "+"), - link(expertise, refactoring, "+"), - link(debt, refactoring, "+"), + link( + skillCeiling, + learning, + "+", + "Skill ceiling is the level in learning's gap: the higher it sits above current skill, the more you study.", + ), + link( + expertise, + learning, + "-", + "Expertise is the target: the closer to mastery, the less there is left to learn (the − input).", + ), + link(reliance, atrophy, "+", "More AI reliance → faster atrophy of skills."), + link( + debt, + reliance, + "+", + "More debt → more AI reliance: the pressure that drives the offloading.", + ), + link(reliance, accrual, "+", "More AI reliance → more debt accrued."), + link( + expertise, + refactoring, + "+", + "More Expertise → more refactoring (skilled teams pay debt down).", + ), + link( + debt, + refactoring, + "+", + "More debt → more refactoring. With Expertise, this is the Balancing payoff that weakens as skills fade.", + ), ], // Expertise slides 70 → ~6 and Technical debt spirals 20 → ~150 over the window — // the Reinforcing loop clearly taking off, stopped (like "Escalation") before it