feat(model): make limits-to-growth a runnable two-flow logistic

Recast the S-curve as a Reinforcing inflow plus a crowding-driven die-off that
grows with Yeast², using only the existing proportional rule. Yeast now climbs
20 → ~1000 as a true sigmoid, and the detector still classifies it R + B — the
balancing loop stays visible, which a single "logistic" rule would have hidden.
Drops the carrying-capacity converter (a faithful one needs a divide rule).
This commit is contained in:
Julien Calixte
2026-06-20 14:03:43 +02:00
parent 34df540e4a
commit f0d207c4d5

View File

@@ -15,9 +15,9 @@
* Beyond that primer, three classic models go a step further — each adds one * 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: * 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 * 5. Limits to growth — a Reinforcing inflow and a Balancing outflow on one
* constant Converter (carrying capacity) that feeds a loop * Stock, with a Converter (crowding) relaying the density
* without being part of it. * that brakes growth: the S-curve.
* 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.
* *
@@ -192,30 +192,41 @@ function population(): Model {
} }
/** /**
* Limits to growth — the S-curve, and the first model where two loops fight over * Limits to growth — the S-curve, where a Reinforcing engine meets a Balancing
* one Flow. Yeast multiplies the more there is of it (Yeast → [+] → growth: a * brake. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
* Reinforcing loop), but the fuller the vat the more crowding holds growth back * Reinforcing inflow), but crowding rises with the population (Yeast → [+] →
* (Yeast → [+] → crowding → [] → growth: a Balancing loop). Carrying capacity is * crowding) and drives a die-off that grows with the *square* of the Yeast
* a *constant* Converter — no inputs — that sets how soon crowding bites; it feeds * (Yeast, crowding → [+] → die-off → drains Yeast: a Balancing outflow). Growth
* the balancing loop without sitting on any cycle. * 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 { function limitsToGrowth(): Model {
const source = makeCloud({ x: -280, y: 0 }) const source = makeCloud({ x: -280, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast") const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
yeast.initialValue = 20
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id) const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
// crowding rides above the pipe; carrying capacity stacks above the Source on the // growth = 30% of Yeast (its `+` input): the Reinforcing engine.
// left, so the `capacity → crowding` link is a clean horizontal hop along the top. growth.rule = { kind: "proportional", factor: 0.3 }
const capacity = makeConverter({ x: -280, y: -160 }, "carrying capacity") const sink = makeCloud({ x: 360, y: 0 })
const crowding = makeConverter({ x: -40, y: -160 }, "crowding") 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( return model(
"Limits to growth", "Limits to growth",
[source, yeast, growth, crowding, capacity], [source, yeast, growth, sink, dieOff, crowding],
[ [
link(yeast, growth, "+"), link(yeast, growth, "+"),
link(yeast, crowding, "+"), link(yeast, crowding, "+"),
link(crowding, growth, "-"), link(yeast, dieOff, "+"),
link(capacity, crowding, "-"), link(crowding, dieOff, "+"),
], ],
{ start: 0, stop: 40, dt: 1 },
) )
} }