Compare commits
11 Commits
46d3279a3c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8a0c294f | ||
|
|
37e3a1e89f | ||
|
|
914cf01763 | ||
|
|
fe17bc8fae | ||
|
|
34bc8fa70b | ||
|
|
81425f8f3d | ||
|
|
d36f58977e | ||
|
|
62f6c5cda1 | ||
|
|
9ddff65072 | ||
|
|
faf8500fa5 | ||
|
|
f691b99ca5 |
@@ -22,20 +22,33 @@ typed-in formula.** Each instantaneous element picks one rule and a plain number
|
|||||||
or two — never an expression:
|
or two — never an expression:
|
||||||
|
|
||||||
| Rule | Value | Reads (via Information Links) | Emergent behaviour |
|
| Rule | Value | Reads (via Information Links) | Emergent behaviour |
|
||||||
| ---------------- | --------------------------- | ----------------------------------------- | -------------------------- |
|
| ---------------- | -------------------------------------- | -------------------------------------------- | -------------------------- |
|
||||||
| **Constant** | a fixed number | nothing | linear Stock change |
|
| **Constant** | a fixed number | nothing | linear Stock change |
|
||||||
| **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay |
|
| **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay |
|
||||||
| **Gap** | `factor × (level − target)` | the `+` input is _level_, `−` is _target_ | goal-seeking / asymptotic |
|
| **Gap** | `factor × (level − target)` | the `+` input is _level_, `−` is _target_ | goal-seeking / asymptotic |
|
||||||
|
| **Overflow** | `max(0, factor × (level − threshold))` | the `+` input is _level_, `−` is _threshold_ | a spillway / hard ceiling |
|
||||||
|
|
||||||
The famous curves are _compositions_ of these over the structure — a logistic
|
The famous curves are _compositions_ of these over the structure — a logistic
|
||||||
S-curve is Proportional growth meeting a Gap-driven ceiling (limits-to-growth);
|
S-curve is Proportional growth meeting a Gap-driven ceiling (limits-to-growth);
|
||||||
goal-seeking decay is a lone Gap (coffee cooling). The user sets up a local rule;
|
goal-seeking decay is a lone Gap (coffee cooling). The user sets up a local rule;
|
||||||
the global shape **emerges**. That emergence _is_ the lesson.
|
the global shape **emerges**. That emergence _is_ the lesson.
|
||||||
|
|
||||||
|
**Overflow is the one _declared_ limit.** Constant/Proportional/Gap are smooth and
|
||||||
|
their ceilings _emerge_ (the S-curve is two of them meeting); Overflow instead is a
|
||||||
|
threshold — `max(0, …)` that stays shut until a level crosses it, then spills the
|
||||||
|
excess down an outflow (a bathtub brimming onto the floor). It earns a rule of its
|
||||||
|
own because Gap _cannot_ stand in: Gap is signed and bidirectional (coffee re-warms
|
||||||
|
if it drops below the room), so a Gap outflow runs _backwards_ below its target, and
|
||||||
|
that clamp can't be applied to Gap globally without breaking the goal-seekers. So
|
||||||
|
Overflow is a deliberate exception to "the shape emerges" — a hard ceiling you
|
||||||
|
_declare_, for the real limit that is a wall, not a slope (and the rate-side sibling
|
||||||
|
of the non-negative-Stock floor the integrator already enforces).
|
||||||
|
|
||||||
**Polarity does double duty.** The `+`/`−` already captured for loop
|
**Polarity does double duty.** The `+`/`−` already captured for loop
|
||||||
classification (ADR-0001) also selects each operand's role: Proportional reads
|
classification (ADR-0001) also selects each operand's role: Proportional reads
|
||||||
its `+` inputs; Gap reads its `+` input as the level and its `−` input as the
|
its `+` inputs; Gap reads its `+` input as the level and its `−` input as the
|
||||||
target. One gesture, two payoffs — no new per-link data.
|
target, and Overflow the same with its `−` input as the threshold. One gesture,
|
||||||
|
two payoffs — no new per-link data.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
@@ -70,5 +83,7 @@ target. One gesture, two payoffs — no new per-link data.
|
|||||||
- The vocabulary starts deliberately small (Constant / Proportional / Gap —
|
- The vocabulary starts deliberately small (Constant / Proportional / Gap —
|
||||||
enough for linear, exponential, and goal-seeking, and for the coffee and
|
enough for linear, exponential, and goal-seeking, and for the coffee and
|
||||||
savings samples). Growing it is additive: a new `kind` in the union plus a case
|
savings samples). Growing it is additive: a new `kind` in the union plus a case
|
||||||
in the evaluator. Multi-input products (e.g. `Population × fertility`) are a
|
in the evaluator — as **Overflow** later bore out (one union member, one
|
||||||
later increment, not a phase-2 blocker.
|
evaluator case, plus matching touch-ups to the rule validator and the
|
||||||
|
inspector). Multi-input products (e.g. `Population × fertility`) are a later
|
||||||
|
increment, not a phase-2 blocker.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useAutosave } from "@/composables/useAutosave"
|
|||||||
import { usePlayback } from "@/composables/usePlayback"
|
import { usePlayback } from "@/composables/usePlayback"
|
||||||
import { parseModel, serializeModel } from "@/model/io"
|
import { parseModel, serializeModel } from "@/model/io"
|
||||||
import { project } from "@/model/projection"
|
import { project } from "@/model/projection"
|
||||||
import { type Sample, SAMPLES } from "@/model/samples"
|
import type { Sample } from "@/model/samples"
|
||||||
import { canConnect } from "@/model/validation"
|
import { canConnect } from "@/model/validation"
|
||||||
import { useModelStore } from "@/store/model"
|
import { useModelStore } from "@/store/model"
|
||||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||||
@@ -38,6 +38,7 @@ import Inspector from "./Inspector.vue"
|
|||||||
import LoopOverlay from "./LoopOverlay.vue"
|
import LoopOverlay from "./LoopOverlay.vue"
|
||||||
import Palette from "./Palette.vue"
|
import Palette from "./Palette.vue"
|
||||||
import ResultsPanel from "./ResultsPanel.vue"
|
import ResultsPanel from "./ResultsPanel.vue"
|
||||||
|
import SampleBrowser from "./SampleBrowser.vue"
|
||||||
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
||||||
import PipeEdge from "./edges/PipeEdge.vue"
|
import PipeEdge from "./edges/PipeEdge.vue"
|
||||||
import CloudNode from "./nodes/CloudNode.vue"
|
import CloudNode from "./nodes/CloudNode.vue"
|
||||||
@@ -55,6 +56,9 @@ const edges = computed(() => graph.value.edges)
|
|||||||
// the Model and recomputes reactively while open.
|
// the Model and recomputes reactively while open.
|
||||||
const showResults = ref(false)
|
const showResults = ref(false)
|
||||||
|
|
||||||
|
// The sample-browser modal (opened from the header "Samples" button).
|
||||||
|
const browserOpen = ref(false)
|
||||||
|
|
||||||
// The playback clock that advances the playhead while a run is playing (mounted
|
// The playback clock that advances the playhead while a run is playing (mounted
|
||||||
// once here; the simulation store holds the state it drives).
|
// once here; the simulation store holds the state it drives).
|
||||||
usePlayback()
|
usePlayback()
|
||||||
@@ -270,12 +274,12 @@ function onDragOver(event: DragEvent): void {
|
|||||||
*/
|
*/
|
||||||
function loadSample(sample: Sample): void {
|
function loadSample(sample: Sample): void {
|
||||||
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
|
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
|
||||||
|
// Cancelled: leave the model untouched and the browser open to pick again.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fitAfterInit = true
|
fitAfterInit = true
|
||||||
store.setModel(sample.build())
|
store.setModel(sample.build())
|
||||||
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
|
browserOpen.value = false
|
||||||
;(document.activeElement as HTMLElement | null)?.blur()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
|
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
|
||||||
@@ -397,20 +401,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
||||||
</span>
|
</span>
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
<div class="dropdown dropdown-end">
|
<button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
|
||||||
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
|
|
||||||
<ul
|
|
||||||
tabindex="0"
|
|
||||||
class="dropdown-content menu z-40 mt-1 max-h-80 w-72 flex-nowrap gap-1 overflow-y-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
|
||||||
>
|
|
||||||
<li v-for="sample in SAMPLES" :key="sample.title">
|
|
||||||
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
|
|
||||||
<span class="font-medium">{{ sample.title }}</span>
|
|
||||||
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
:class="{ 'btn-active': showResults }"
|
:class="{ 'btn-active': showResults }"
|
||||||
@@ -504,5 +495,7 @@ onBeforeUnmount(() => {
|
|||||||
the canvas to the space above, where the diagram re-fits (see the
|
the canvas to the space above, where the diagram re-fits (see the
|
||||||
`dimensions` watcher in <script>). -->
|
`dimensions` watcher in <script>). -->
|
||||||
<ResultsPanel v-if="showResults" @close="showResults = false" />
|
<ResultsPanel v-if="showResults" @close="showResults = false" />
|
||||||
|
|
||||||
|
<SampleBrowser v-model:open="browserOpen" @select="loadSample" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
constant: "A fixed number — no inputs.",
|
constant: "A fixed number — no inputs.",
|
||||||
proportional: "rate = factor × its “+” inputs.",
|
proportional: "rate = factor × its “+” inputs.",
|
||||||
gap: "rate = factor × (level − target): the “+” input is the level, the “−” the target.",
|
gap: "rate = factor × (level − target): the “+” input is the level, the “−” the target.",
|
||||||
|
overflow:
|
||||||
|
"rate = max(0, factor × (level − threshold)): spills only once the “+” level passes the “−” threshold.",
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
<option value="constant">Constant</option>
|
<option value="constant">Constant</option>
|
||||||
<option value="proportional">Proportional</option>
|
<option value="proportional">Proportional</option>
|
||||||
<option value="gap">Gap</option>
|
<option value="gap">Gap</option>
|
||||||
|
<option value="overflow">Overflow</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -204,7 +207,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
<label class="mt-2 block">
|
<label class="mt-2 block">
|
||||||
<span class="text-xs text-base-content/60">Description</span>
|
<span class="text-xs text-base-content/60">Description</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows="3"
|
rows="6"
|
||||||
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
|
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
|
||||||
:value="(element ?? link)?.description ?? ''"
|
:value="(element ?? link)?.description ?? ''"
|
||||||
placeholder="Why this element is here…"
|
placeholder="Why this element is here…"
|
||||||
|
|||||||
85
src/components/SampleBrowser.vue
Normal file
85
src/components/SampleBrowser.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sample browser — a modal gallery of the curated example models, laid out as a
|
||||||
|
* card grid sectioned by pedagogical tier (Primer → … → Mechanics & capstone).
|
||||||
|
* As the gallery grows this beats the old narrow dropdown: the whole set stays
|
||||||
|
* scannable in one scroll and the tiers show how the samples relate.
|
||||||
|
*
|
||||||
|
* Presentational only — it never touches the Model store. Picking a card emits
|
||||||
|
* `select`; the Editor owns the confirm/replace/fit logic (one place for it).
|
||||||
|
* Built on the native <dialog> so Esc, the backdrop click and focus-trapping come
|
||||||
|
* for free; `open` drives showModal()/close() and the native close event keeps the
|
||||||
|
* parent's state in sync.
|
||||||
|
*/
|
||||||
|
import { computed, useTemplateRef, watch } from "vue"
|
||||||
|
import { type Sample, SAMPLE_CATEGORIES, SAMPLES } from "@/model/samples"
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
const emit = defineEmits<{ "update:open": [boolean]; select: [Sample] }>()
|
||||||
|
|
||||||
|
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
|
||||||
|
|
||||||
|
// Mirror the `open` prop onto the native dialog (showModal gives us the focus trap
|
||||||
|
// and inert backdrop a plain div can't).
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
const el = dialog.value
|
||||||
|
if (!el) return
|
||||||
|
if (open && !el.open) el.showModal()
|
||||||
|
else if (!open && el.open) el.close()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fired by Esc, the backdrop form, the ✕, or our own close() — fold them all into
|
||||||
|
// one update:open so the parent's state can't drift from the dialog's.
|
||||||
|
function onClose(): void {
|
||||||
|
if (props.open) emit("update:open", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The categories in display order, each with its samples (SAMPLES is already in
|
||||||
|
// pedagogical order, so first-appearance order is preserved). Empty tiers drop out.
|
||||||
|
const sections = computed(() =>
|
||||||
|
SAMPLE_CATEGORIES.map((category) => ({
|
||||||
|
category,
|
||||||
|
samples: SAMPLES.filter((sample) => sample.category === category),
|
||||||
|
})).filter((section) => section.samples.length > 0),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog ref="dialog" class="modal" @close="onClose">
|
||||||
|
<div class="modal-box w-11/12 max-w-3xl">
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<h3 class="text-base font-semibold">Browse samples</h3>
|
||||||
|
<span class="text-xs text-base-content/50">{{ SAMPLES.length }} models</span>
|
||||||
|
<form method="dialog" class="ml-auto">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm" aria-label="Close">✕</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-for="section in sections" :key="section.category" class="mb-5 last:mb-0">
|
||||||
|
<h4 class="mb-2 text-xs font-semibold tracking-wide text-base-content/50 uppercase">
|
||||||
|
{{ section.category }}
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="sample in section.samples"
|
||||||
|
:key="sample.title"
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-3 text-left transition hover:border-primary hover:shadow-md"
|
||||||
|
@click="emit('select', sample)"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ sample.title }}</span>
|
||||||
|
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Click outside the box to dismiss (submits the dialog → fires @close). -->
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
|
|||||||
function isRule(value: unknown): value is Rule {
|
function isRule(value: unknown): value is Rule {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
if (value.kind === "constant") return isFiniteNumber(value.value)
|
if (value.kind === "constant") return isFiniteNumber(value.value)
|
||||||
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor)
|
if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
|
||||||
|
return isFiniteNumber(value.factor)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
|
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
|
||||||
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
|
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
|
||||||
*
|
*
|
||||||
* Last, four of Donella Meadows' system *traps* — structures that reliably misbehave
|
* Last, five of Donella Meadows' system *traps* — structures that reliably misbehave
|
||||||
* (Thinking in Systems, ch. 5), to contrast the healthy dynamics above — and, paired
|
* (Thinking in Systems, ch. 5), to contrast the healthy dynamics above — and, paired
|
||||||
* with the first, the cure that escapes it:
|
* with the first, the cure that escapes it:
|
||||||
*
|
*
|
||||||
@@ -32,16 +32,20 @@
|
|||||||
* *larger* than the trap's boom leaves alive.
|
* *larger* than the trap's boom leaves alive.
|
||||||
* 10. Escalation — a single Reinforcing loop spanning two Stocks, with
|
* 10. Escalation — a single Reinforcing loop spanning two Stocks, with
|
||||||
* no brake in the structure: an arms race.
|
* no brake in the structure: an arms race.
|
||||||
* 11. Fixes that fail — a fix drains the symptom Stock (B) while its side
|
* 11. Success to the successful — two equal rivals split one conserved prize (the field's
|
||||||
|
* attention); a gap-driven Flow tilts it to whoever leads,
|
||||||
|
* so a 51–49 near-tie locks in to ~100–0. Escalation's
|
||||||
|
* zero-sum twin — it concentrates instead of exploding.
|
||||||
|
* 12. Fixes that fail — a fix drains the symptom Stock (B) while its side
|
||||||
* effect refills it (R): the cure feeds the disease.
|
* effect refills it (R): the cure feeds the disease.
|
||||||
* 12. Drift to low performance — a goal that erodes toward actual performance, so the
|
* 13. Drift to low performance — a goal that erodes toward actual performance, so the
|
||||||
* effort it drives never overcomes a steady decay: a
|
* effort it drives never overcomes a steady decay: a
|
||||||
* Reinforcing loop ratchets both downward.
|
* Reinforcing loop ratchets both downward.
|
||||||
*
|
*
|
||||||
* Next, the dynamic the book is named for, and the one the gallery has saved until a
|
* Next, the dynamic the book is named for, and the one the gallery has saved until a
|
||||||
* reader knows every piece it needs:
|
* reader knows every piece it needs:
|
||||||
*
|
*
|
||||||
* 13. Overshoot and collapse — a Reinforcing harvester on a *renewable* Resource with
|
* 14. Overshoot and collapse — a Reinforcing harvester on a *renewable* Resource with
|
||||||
* an extinction threshold (an Allee floor): a fleet
|
* an extinction threshold (an Allee floor): a fleet
|
||||||
* overshoots the renewal rate and pushes the fishery past
|
* overshoots the renewal rate and pushes the fishery past
|
||||||
* the point of no return. The dark twin of "Limits to
|
* the point of no return. The dark twin of "Limits to
|
||||||
@@ -50,10 +54,46 @@
|
|||||||
* Last, the language pointed at a live debate — a classic trap (Shifting the burden to
|
* Last, the language pointed at a live debate — a classic trap (Shifting the burden to
|
||||||
* the intervenor, ch. 5) wearing today's clothes:
|
* the intervenor, ch. 5) wearing today's clothes:
|
||||||
*
|
*
|
||||||
* 14. AI deskilling spiral — handing the burden of code quality to AI atrophies the
|
* 15. AI deskilling spiral — handing the burden of code quality to AI atrophies the
|
||||||
* Expertise that holds quality up, so the team leans on AI
|
* Expertise that holds quality up, so the team leans on AI
|
||||||
* harder and Technical debt spirals: addiction, not a fix.
|
* harder and Technical debt spirals: addiction, not a fix.
|
||||||
*
|
*
|
||||||
|
* And a mechanical coda — the gallery's one *hard* ceiling, to contrast the emergent
|
||||||
|
* ones (above all "Limits to growth"):
|
||||||
|
*
|
||||||
|
* 16. Bathtub with an overflow — the tap runs flat out (a Constant inflow that never
|
||||||
|
* reads the level) and a spillway carries whatever rises
|
||||||
|
* past the brim into a second Stock, the floor. The only
|
||||||
|
* model on the `overflow` rule — a one-sided Gap, the
|
||||||
|
* threshold the smooth rules can't draw — so here the
|
||||||
|
* ceiling is *declared*, not emergent.
|
||||||
|
*
|
||||||
|
* Then one last trap, held back until the overflow rule existed to carry it — the
|
||||||
|
* language pointed at the most-told cautionary tale in systems thinking:
|
||||||
|
*
|
||||||
|
* 17. The cobra effect — a bounty on dead cobras (a Balancing fix) quietly funds a
|
||||||
|
* cobra *farm* (a Reinforcing engine); breeding outruns what
|
||||||
|
* the bounty can absorb, the farm gluts, and its now-worthless
|
||||||
|
* surplus spills through an `overflow` gate into the wild —
|
||||||
|
* leaving four times the snakes there were to begin with. The
|
||||||
|
* perverse incentive: rewarding the proxy (dead cobras) over
|
||||||
|
* the goal (fewer cobras), and the overflow rule's dark payoff.
|
||||||
|
*
|
||||||
|
* And the capstone — every piece at once, at the scale the whole gallery points toward:
|
||||||
|
*
|
||||||
|
* 18. World on a warming planet — the Club of Rome's World3 (Meadows et al., *The Limits
|
||||||
|
* to Growth*) in miniature, its pollution sector reframed
|
||||||
|
* as a climate channel: four coupled Stocks where two
|
||||||
|
* Reinforcing engines (capital, population) overshoot a
|
||||||
|
* finite Resource and the carbon they burn locks in the
|
||||||
|
* warming that finishes them. The gallery's largest model,
|
||||||
|
* it composes the whole vocabulary — a Stock→Stock bridge
|
||||||
|
* ("Epidemic"), logistic-style limits ("Limits to growth"),
|
||||||
|
* a renewable-resource overshoot ("Overshoot and collapse"),
|
||||||
|
* and the bathtub's slow sink ("Bathtub with an overflow")
|
||||||
|
* — into one system. A qualitative tribute to the shape, not
|
||||||
|
* a port of the equations.
|
||||||
|
*
|
||||||
* These are plain data built from the same tested constructors the store uses
|
* These are plain data built from the same tested constructors the store uses
|
||||||
* (factory.ts), so every sample is a valid Model by construction. `build()`
|
* (factory.ts), so every sample is a valid Model by construction. `build()`
|
||||||
* mints fresh ids on each call, so loading a sample twice never collides.
|
* mints fresh ids on each call, so loading a sample twice never collides.
|
||||||
@@ -68,10 +108,31 @@ import {
|
|||||||
type SimSpec,
|
type SimSpec,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
|
/**
|
||||||
|
* The pedagogical tier a sample belongs to. Tiers run simplest-first and double as
|
||||||
|
* the section order in the sample browser (see SAMPLE_CATEGORIES).
|
||||||
|
*/
|
||||||
|
export type SampleCategory =
|
||||||
|
| "Primer"
|
||||||
|
| "Classics"
|
||||||
|
| "System traps"
|
||||||
|
| "Dynamic edge"
|
||||||
|
| "Mechanics & capstone"
|
||||||
|
|
||||||
|
/** The categories in display order, simplest tier first. */
|
||||||
|
export const SAMPLE_CATEGORIES: SampleCategory[] = [
|
||||||
|
"Primer",
|
||||||
|
"Classics",
|
||||||
|
"System traps",
|
||||||
|
"Dynamic edge",
|
||||||
|
"Mechanics & capstone",
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A loadable example: a title, one-line blurb and tier for the browser, plus a builder. */
|
||||||
export interface Sample {
|
export interface Sample {
|
||||||
title: string
|
title: string
|
||||||
blurb: string
|
blurb: string
|
||||||
|
category: SampleCategory
|
||||||
build: () => Model
|
build: () => Model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,6 +968,82 @@ function escalation(): Model {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success to the successful — Donella Meadows' competitive-exclusion trap (Thinking
|
||||||
|
* in Systems, ch. 5), known in the sociology of science as the *Matthew effect*
|
||||||
|
* (Robert Merton, after Matthew 25:29: "unto every one that hath shall be given").
|
||||||
|
* Two researchers of equal ability compete for one finite thing — the field's
|
||||||
|
* attention — and the only difference between them is that A begins a single point
|
||||||
|
* ahead (Renown 51 vs 49 of a fixed 100).
|
||||||
|
*
|
||||||
|
* The community's attention is a *conserved* budget: a citation, an invitation, a
|
||||||
|
* grant that goes to one does not go to the other, so Renown A + Renown B never
|
||||||
|
* leaves 100. The marginal piece of it drifts to whoever is already better known —
|
||||||
|
* `attention = factor × (Renown A − Renown B)`, a single Stock→Stock Flow draining
|
||||||
|
* the lesser name into the greater. There are no clouds: nothing enters or leaves
|
||||||
|
* the system, it only *redistributes*. That one valve carries *two* Reinforcing
|
||||||
|
* loops — Renown A → [+] → attention → (fills A) → Renown A (more renown wins more
|
||||||
|
* attention), and Renown B → [−] → attention → (drains B) → Renown B (the falling
|
||||||
|
* name is forgotten ever faster). Neither loop has a surviving `−`, so both are
|
||||||
|
* Reinforcing: nothing brakes the gap, and it can only widen.
|
||||||
|
*
|
||||||
|
* A tie is therefore an *unstable* equilibrium — the knife-edge this trap balances
|
||||||
|
* on. A's one-point lead is enough: attention tilts to A, the gap grows, attention
|
||||||
|
* tilts harder, and the field locks in. Renown A climbs 51 → 100 and Renown B fades
|
||||||
|
* 49 → 0 by t≈42 — a near-total monopoly of acclaim, though the two were equally
|
||||||
|
* able. The cruel lesson Meadows draws: where the prize for winning is more power to
|
||||||
|
* win, it is *initial conditions*, not merit, that decide the outcome.
|
||||||
|
*
|
||||||
|
* Contrast "Escalation", the gallery's other two-Stock all-Reinforcing trap: there
|
||||||
|
* the cross-loop has no minus and *both* arsenals blow up together (positive-sum,
|
||||||
|
* unbounded); here the single loop is zero-sum — A rises only as fast as B is drained
|
||||||
|
* — so the same Reinforcing structure *concentrates* instead of exploding. (The cure
|
||||||
|
* Meadows prescribes is to break the coupling — level the field, diversify the prize,
|
||||||
|
* or handicap the leader — none of which the structure here contains: that is the trap.)
|
||||||
|
*/
|
||||||
|
function successToTheSuccessful(): Model {
|
||||||
|
// Two Stocks on one line, the lone attention valve nudged above their midpoint so
|
||||||
|
// the pipe (Renown B → attention → Renown A) and the two info links (each Stock
|
||||||
|
// into the valve) read apart instead of overlapping. Hand-placed, not at midpoint.
|
||||||
|
const renownB = makeStock({ x: -260, y: 80 }, "Renown B")
|
||||||
|
renownB.initialValue = 49
|
||||||
|
renownB.description =
|
||||||
|
"Researcher B's standing — equal in ability to A, and a single point behind at the start. The lesser name the attention drains away from, fading to nothing though B never did anything wrong."
|
||||||
|
const renownA = makeStock({ x: 260, y: 80 }, "Renown A")
|
||||||
|
renownA.initialValue = 51
|
||||||
|
renownA.description =
|
||||||
|
"Researcher A's standing — one point ahead at the start, and that hair's lead is all it takes. Attention concentrates here until A holds nearly the whole field's acclaim."
|
||||||
|
// attention = factor × (Renown A − Renown B): a Gap whose `+` level is A and `−`
|
||||||
|
// target is B. Source B, target A, so a positive rate drains the lesser name into
|
||||||
|
// the greater — and since draining B only widens the gap, the rate never reverses.
|
||||||
|
const attention = makeFlow({ x: 0, y: -120 }, "attention", renownB.id, renownA.id)
|
||||||
|
attention.rule = { kind: "gap", factor: 0.05 }
|
||||||
|
attention.description =
|
||||||
|
"The field's finite attention drifting to the better-known name, ∝ the lead (Renown A − Renown B). A conserved transfer: what A gains, B loses — the one valve through which both Reinforcing loops run."
|
||||||
|
return model(
|
||||||
|
"Success to the successful",
|
||||||
|
[renownB, renownA, attention],
|
||||||
|
[
|
||||||
|
link(
|
||||||
|
renownA,
|
||||||
|
attention,
|
||||||
|
"+",
|
||||||
|
"Renown A is the level in the gap: the further ahead A is, the faster attention flows its way. With the inflow it fills, this closes A's Reinforcing loop — the rich getting richer.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
renownB,
|
||||||
|
attention,
|
||||||
|
"-",
|
||||||
|
"Renown B is the target the gap is measured against (the − input). As B fades the gap only widens, so it is drained faster still — a second Reinforcing loop, the same minus on both the link and the outflow.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// From a 51–49 near-tie the gap runs away: Renown A climbs 51 → ~100 and Renown B
|
||||||
|
// fades 49 → ~0 by t≈42, then both hold — a near-total monopoly of acclaim won on a
|
||||||
|
// one-point head start. The unstable 50–50 knife-edge a hair's difference tips.
|
||||||
|
{ start: 0, stop: 50, dt: 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixes that fail — the archetype where a quick fix relieves a symptom but feeds it
|
* Fixes that fail — the archetype where a quick fix relieves a symptom but feeds it
|
||||||
* through a side effect, so the symptom returns and the fix is reapplied for ever.
|
* through a side effect, so the symptom returns and the fix is reapplied for ever.
|
||||||
@@ -1343,72 +1480,599 @@ function aiDeskillingSpiral(): Model {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bathtub with an overflow — the bathtub given a hard ceiling the honest way. The tap
|
||||||
|
* keeps running flat out (filling stays a Constant; it never reads the level), and a
|
||||||
|
* spillway carries off whatever rises past the brim: overflow = max(0, factor ×
|
||||||
|
* (Water − capacity)). That `overflow` rule is a one-sided Gap — shut until Water passes
|
||||||
|
* the `−` threshold (capacity), then draining the excess into a second Stock, the Floor.
|
||||||
|
* The loop Water → overflow → Water carries one `−` (the outflow) → Balancing, so Water
|
||||||
|
* climbs in a straight line, then plateaus just above the brim — a weir needs a little
|
||||||
|
* head of water to discharge — while the Floor goes on filling. The equilibrium comes
|
||||||
|
* from the *spill*, not from the inflow easing off: the counterpoint to a float valve,
|
||||||
|
* which throttles the inflow instead, and to "Limits to growth", whose ceiling emerges.
|
||||||
|
*/
|
||||||
|
function bathtubOverflow(): Model {
|
||||||
|
const source = makeCloud({ x: -280, y: 0 })
|
||||||
|
const water = makeStock({ x: 0, y: 0 }, "Water")
|
||||||
|
water.initialValue = 20
|
||||||
|
water.unit = "L"
|
||||||
|
water.description =
|
||||||
|
"The water level — the tap fills it in a straight line, then it holds just above the brim as the overflow carries off everything extra."
|
||||||
|
const filling = makeFlow(
|
||||||
|
midpoint(source.position, water.position),
|
||||||
|
"filling",
|
||||||
|
source.id,
|
||||||
|
water.id,
|
||||||
|
)
|
||||||
|
// A constant tap — it never reads the level, so it keeps pouring even at the brim.
|
||||||
|
// The ceiling comes from the overflow below, not from the inflow easing off.
|
||||||
|
filling.rule = { kind: "constant", value: 5 }
|
||||||
|
filling.description =
|
||||||
|
"A constant 5 L/step from the tap (the Source cloud), running flat out. It never throttles — the ceiling is the overflow's doing, not the tap's."
|
||||||
|
const floor = makeStock({ x: 0, y: 240 }, "Floor")
|
||||||
|
floor.initialValue = 0
|
||||||
|
floor.unit = "L"
|
||||||
|
floor.description =
|
||||||
|
"Water spilled onto the floor — the second Stock the overflow collects in. Empty until the tub brims, then it fills at the spill rate."
|
||||||
|
const overflow = makeFlow(
|
||||||
|
midpoint(water.position, floor.position),
|
||||||
|
"overflow",
|
||||||
|
water.id,
|
||||||
|
floor.id,
|
||||||
|
)
|
||||||
|
// overflow = max(0, 1 × (Water − capacity)): shut below the brim, spilling the excess
|
||||||
|
// above it. factor 1 at dt 1 settles in a step without oscillating; the level rides a
|
||||||
|
// touch over capacity — the head a spillway needs to discharge its inflow.
|
||||||
|
overflow.rule = { kind: "overflow", factor: 1 }
|
||||||
|
overflow.description =
|
||||||
|
"The spillway, max(0, Water − capacity): nothing while the tub is below the brim, then it carries off the excess — the Balancing drain that holds the level."
|
||||||
|
const capacity = makeConverter({ x: 240, y: 120 }, "capacity")
|
||||||
|
capacity.rule = { kind: "constant", value: 100 }
|
||||||
|
capacity.description =
|
||||||
|
"The tub's brim (a fixed 100 L) — the threshold the overflow opens above; the level holds just over it."
|
||||||
|
return model(
|
||||||
|
"Bathtub with an overflow",
|
||||||
|
[source, water, filling, floor, overflow, capacity],
|
||||||
|
[
|
||||||
|
link(
|
||||||
|
water,
|
||||||
|
overflow,
|
||||||
|
"+",
|
||||||
|
"Water is the level in the overflow rule: the higher it rises past the brim, the harder it spills. With the outflow, this closes the Balancing loop that holds the level.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
capacity,
|
||||||
|
overflow,
|
||||||
|
"-",
|
||||||
|
"Capacity is the threshold the spill opens above (the − input): a higher brim → less spill at the same level.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ start: 0, stop: 40, dt: 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cobra effect — the perverse-incentive trap, and Meadows' "rule beating"
|
||||||
|
* (Thinking in Systems, ch. 5) in its most-told form. Colonial Delhi has too many
|
||||||
|
* cobras, so the British put a bounty on them: cash for every dead snake. The intent
|
||||||
|
* is a Balancing fix — more cobras → more killed for the reward → fewer cobras (Wild
|
||||||
|
* cobras → [+] → culling, an outflow draining the Stock). And at first it works: the
|
||||||
|
* streets empty of snakes.
|
||||||
|
*
|
||||||
|
* But the bounty pays for *dead cobras delivered*, not for *fewer wild cobras* — it
|
||||||
|
* rewards the proxy, not the goal. So the same reward funds a second thing the policy
|
||||||
|
* never intended: people breed cobras to cash in. That farm is a Reinforcing engine
|
||||||
|
* (Farmed cobras → [+] → breeding → Farmed cobras: more breeding stock, more bred), and
|
||||||
|
* the snakes raised on it are killed and turned in for the bounty too (Farmed cobras →
|
||||||
|
* [+] → harvest — the rule beating: producing dead cobras to the letter of the policy
|
||||||
|
* while making its goal worse). One lever, the bounty, wired into all three flows.
|
||||||
|
*
|
||||||
|
* The engine has no scripted policy reversal — no "the bounty is cancelled at year X" —
|
||||||
|
* so the famous release is *emergent*. Breeding (Reinforcing) outruns what the bounty
|
||||||
|
* can absorb; the farm gluts, and a glutted farm crashes the cobra's worth. Past that
|
||||||
|
* glut threshold the now-worthless surplus is dumped into the wild: releases =
|
||||||
|
* max(0, factor × (Farmed cobras − glut)), the `overflow` rule from "Bathtub with an
|
||||||
|
* overflow", here a one-sided gate shut until the farm overflows. Wild cobras have *no
|
||||||
|
* inflow of their own* (no natural breeding is modelled), so the only thing that can
|
||||||
|
* refill the wild is the farm — which leaves no doubt what brings the snakes roaring back.
|
||||||
|
*
|
||||||
|
* The shape (start 0, stop 70, dt 1): Wild cobras crash 100 → ~3 by t≈20 — the bounty
|
||||||
|
* looks like a triumph — while the unseen farm booms 5 → past the glut (200). Then the
|
||||||
|
* overflow opens, the wild population climbs back above its start by t≈24 and overshoots
|
||||||
|
* to ~404 — four times where it began — settling there for good as the farm levels at
|
||||||
|
* ~308. The fix didn't fail quietly; it left the System far worse than it found it. Kin
|
||||||
|
* to "Fixes that fail" (a fix whose own side effect defeats it), but here the side effect
|
||||||
|
* is a Stock the reward built, the brake is the overflow gate, and the damage is permanent.
|
||||||
|
*/
|
||||||
|
function cobraEffect(): Model {
|
||||||
|
// Laid out left→right: the farm engine on the left (a Source breeds Farmed cobras,
|
||||||
|
// harvest draining up to a Sink), the release bridge in the centre spilling the glut
|
||||||
|
// rightward into Wild cobras, and the bounty-driven cull running off to the right. The
|
||||||
|
// policy lever (bounty) sits up top, wired down into all three flows — cull, breeding,
|
||||||
|
// cash-out — with glut beneath the bridge it gates. Hand-placed valves keep links clear.
|
||||||
|
const bounty = makeConverter({ x: -80, y: -340 }, "bounty")
|
||||||
|
bounty.rule = { kind: "constant", value: 1 }
|
||||||
|
bounty.description =
|
||||||
|
"The reward paid per dead cobra — held at 1, a normalised policy lever, so every rate here scales with it (a bigger bounty would only run the whole story faster). One lever wired into three flows: the cull, the breeding, and the cash-out."
|
||||||
|
|
||||||
|
// The intended fix: a bounty-driven cull empties the streets of wild cobras.
|
||||||
|
const wild = makeStock({ x: 380, y: -40 }, "Wild cobras")
|
||||||
|
wild.initialValue = 100
|
||||||
|
wild.description =
|
||||||
|
"The actual problem the bounty targets — hunted down at first, then overrun once the farm's surplus is loosed on it. It has no inflow of its own, so any rebound can only be the farm's doing."
|
||||||
|
const cullSink = makeCloud({ x: 920, y: -40 })
|
||||||
|
const culling = makeFlow({ x: 660, y: -40 }, "culling", wild.id, cullSink.id)
|
||||||
|
// culling = 0.16 × Wild × bounty: the Balancing drain, ∝ the stock. Fast enough to
|
||||||
|
// crash the wild population by t≈20 — the policy's whole visible success.
|
||||||
|
culling.rule = { kind: "proportional", factor: 0.16 }
|
||||||
|
culling.description =
|
||||||
|
"Wild cobras killed for the bounty, ∝ Wild cobras × bounty — the intended Balancing fix that empties the streets at first."
|
||||||
|
|
||||||
|
// The perverse stock: a farm bred to cash in on the bounty.
|
||||||
|
const farm = makeStock({ x: -520, y: -40 }, "Farmed cobras")
|
||||||
|
farm.initialValue = 5
|
||||||
|
farm.description =
|
||||||
|
"Cobras bred to cash in on the bounty — the Stock the reward calls into being. It compounds unseen while the streets look clear, then spills its surplus into the wild."
|
||||||
|
const breedSrc = makeCloud({ x: -980, y: -200 })
|
||||||
|
const breeding = makeFlow({ x: -760, y: -200 }, "breeding", breedSrc.id, farm.id)
|
||||||
|
// breeding = 0.31 × Farm × bounty: Farm → breeding → Farm, no `−` → the Reinforcing
|
||||||
|
// engine the bounty funds without meaning to.
|
||||||
|
breeding.rule = { kind: "proportional", factor: 0.31 }
|
||||||
|
breeding.description =
|
||||||
|
"The farm breeding more cobras, ∝ Farmed cobras × bounty — the Reinforcing engine the bounty funds without intending to: more breeding stock, more bred."
|
||||||
|
const harvestSink = makeCloud({ x: 600, y: -220 })
|
||||||
|
const harvest = makeFlow({ x: 400, y: -220 }, "harvest", farm.id, harvestSink.id)
|
||||||
|
// harvest = 0.10 × Farm × bounty: farmed cobras killed and turned in — the rule beating,
|
||||||
|
// and a Balancing drain on the farm.
|
||||||
|
harvest.rule = { kind: "proportional", factor: 0.1 }
|
||||||
|
harvest.description =
|
||||||
|
"Farmed cobras killed and turned in for the bounty, ∝ Farmed cobras × bounty — the rule beating: dead cobras to the letter of the policy, while its goal slips away."
|
||||||
|
|
||||||
|
// The backfire bridge: once the farm gluts, its worthless surplus is dumped into the
|
||||||
|
// wild. A one-sided overflow gate (shut below the glut) — no scripted policy reversal
|
||||||
|
// needed; the release falls out of the farm outgrowing its own worth.
|
||||||
|
const glut = makeConverter({ x: -260, y: 260 }, "glut")
|
||||||
|
glut.rule = { kind: "constant", value: 200 }
|
||||||
|
glut.description =
|
||||||
|
"The farm size past which cobras lose their worth (a fixed 200) — the threshold the spill opens above. A structural stand-in for the day the bounty was scrapped and the snakes became worthless."
|
||||||
|
const releases = makeFlow({ x: -40, y: 60 }, "releases", farm.id, wild.id)
|
||||||
|
// releases = max(0, 0.6 × (Farm − glut)): shut while the farm is worth keeping, then it
|
||||||
|
// carries off the surplus. The overflow that overruns the wild — and, draining the farm
|
||||||
|
// above the glut, the Balancing brake that caps it.
|
||||||
|
releases.rule = { kind: "overflow", factor: 0.6 }
|
||||||
|
releases.description =
|
||||||
|
"The farm's glut spilling into the wild, max(0, 60% of the excess above the glut each step): nothing while the farm stays under it, then the worthless surplus is dumped — the overflow that overruns the wild, and the Balancing drain that caps the farm."
|
||||||
|
return model(
|
||||||
|
"The cobra effect",
|
||||||
|
[
|
||||||
|
bounty,
|
||||||
|
wild,
|
||||||
|
cullSink,
|
||||||
|
culling,
|
||||||
|
farm,
|
||||||
|
breedSrc,
|
||||||
|
breeding,
|
||||||
|
harvestSink,
|
||||||
|
harvest,
|
||||||
|
glut,
|
||||||
|
releases,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
link(
|
||||||
|
wild,
|
||||||
|
culling,
|
||||||
|
"+",
|
||||||
|
"More wild cobras → more killed for the bounty: the + that makes the cull a Balancing fix.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
bounty,
|
||||||
|
culling,
|
||||||
|
"+",
|
||||||
|
"A bigger bounty → harder hunting: the policy driving its intended effect.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
farm,
|
||||||
|
breeding,
|
||||||
|
"+",
|
||||||
|
"More farmed cobras → more breeding stock → more bred: the Reinforcing engine.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
bounty,
|
||||||
|
breeding,
|
||||||
|
"+",
|
||||||
|
"A bigger bounty → more worth breeding for: the reward funding the farm it never intended.",
|
||||||
|
),
|
||||||
|
link(farm, harvest, "+", "More farmed cobras → more turned in for cash."),
|
||||||
|
link(
|
||||||
|
bounty,
|
||||||
|
harvest,
|
||||||
|
"+",
|
||||||
|
"A bigger bounty → more worth cashing in: the reward the rule beating chases.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
farm,
|
||||||
|
releases,
|
||||||
|
"+",
|
||||||
|
"Farmed cobras is the level in the overflow rule: only the surplus past the glut spills. With the outflow, this closes the Balancing loop that caps the farm.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
glut,
|
||||||
|
releases,
|
||||||
|
"-",
|
||||||
|
"The glut is the threshold the spill opens above (the − input): below it the farm is worth keeping, and nothing is released.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// The bounty crashes Wild cobras 100 → ~3 by t≈20 (it looks like a triumph) while the
|
||||||
|
// unseen farm booms 5 → past the glut (200). Then the overflow opens: the wild climbs
|
||||||
|
// back above its start by t≈24 and overshoots to ~404 — four times where it began —
|
||||||
|
// settling there as the farm levels at ~308. The fix left the System far worse, for good.
|
||||||
|
{ start: 0, stop: 70, dt: 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World on a warming planet — the gallery's capstone: the Club of Rome's World3
|
||||||
|
* (Donella Meadows et al., *The Limits to Growth*, 1972) in miniature, with its
|
||||||
|
* persistent-pollution sector reframed as the climate channel. It is a *qualitative
|
||||||
|
* tribute*, not a port: World3 runs on hundreds of equations, table-function
|
||||||
|
* nonlinearities, and delays; here four Stocks on the gallery's four rules
|
||||||
|
* reproduce the famous *shape* — overshoot and collapse — without the apparatus.
|
||||||
|
*
|
||||||
|
* The four sectors and how they couple:
|
||||||
|
* - **Capital** is the growth engine. output = factor × Capital × availability;
|
||||||
|
* a slice is reinvested (investment, an inflow) so Capital → output → investment
|
||||||
|
* → Capital is **Reinforcing** — the economy compounds. depreciation drains it
|
||||||
|
* (**Balancing**).
|
||||||
|
* - **Resources** is the finite planet: a nonrenewable Stock with *no* inflow.
|
||||||
|
* `availability` ∝ Resources scales output, so as the reserve runs down the
|
||||||
|
* economy is choked: Resources → availability → output → combustion → Resources,
|
||||||
|
* one `−` → **Balancing**. This is the World3 limit.
|
||||||
|
* - **Atmospheric carbon** is the climate channel. The same act that runs the
|
||||||
|
* economy loads the sky: `combustion` is a single Stock→Stock Flow that drains
|
||||||
|
* Resources straight *into* carbon — burning the reserve *is* the emission. A
|
||||||
|
* slow `removal` (a Gap toward preindustrial) is the sink; tuned slow, so the
|
||||||
|
* carbon **stays** — the bathtub, the lesson that stabilising emissions ≠
|
||||||
|
* stabilising concentration.
|
||||||
|
* - **Population** grows on births (**Reinforcing**) against baseline deaths
|
||||||
|
* (**Balancing**). `warming` (a Gap reading carbon above the same preindustrial
|
||||||
|
* baseline — the constant does double duty) drives `heat deaths`, the climate's
|
||||||
|
* bite on people, and `climate damage`, its bite on Capital. That damage closes
|
||||||
|
* the long cross-sector loop output → combustion → carbon → warming → climate
|
||||||
|
* damage → Capital — so the climate is genuine *feedback*, not one-way forcing.
|
||||||
|
*
|
||||||
|
* Note what is *absent*: there is no Reinforcing tipping loop. The climate here is
|
||||||
|
* slow accumulation braked by its sink, not a runaway — faithful to World3's
|
||||||
|
* pollution sector, and a different model from a carbon-cycle one built to tip.
|
||||||
|
* The Reinforcing engines are growth (Capital) and births (Population); everything
|
||||||
|
* the planet pushes back with is Balancing. That asymmetry *is* limits-to-growth.
|
||||||
|
*
|
||||||
|
* What emerges (start 0, stop 250, dt 1): the economy booms, then overshoots the
|
||||||
|
* reserve and collapses — output peaks ≈540 at t≈47, Capital ≈1040 at t≈60, both
|
||||||
|
* falling to near nothing as Resources deplete 1000 → ~120. The carbon they burned
|
||||||
|
* climbs to ~1085 ppm and *holds* near 1000 (warming locked at ~4 °C) long after
|
||||||
|
* the emissions stop. That locked-in heat is what finishes Population: it overshoots
|
||||||
|
* to ~310 at t≈60 — the *last* sector to peak — then collapses to ~30, a third of
|
||||||
|
* where it began. Growth first, people last; the limit and the heat together.
|
||||||
|
*/
|
||||||
|
function worldOnAWarmingPlanet(): Model {
|
||||||
|
// Economy upper-left (Resources, Capital and their converters/flows); the carbon
|
||||||
|
// Stock sits centre on the same baseline as Resources, joined by the combustion
|
||||||
|
// bridge; `warming` hangs below it and radiates harm out to *both* the economy
|
||||||
|
// (up-left, climate damage) and Population (right, heat deaths). Population runs
|
||||||
|
// its own birth/death column on the right. Valves are hand-placed, not at
|
||||||
|
// midpoints, to keep every Information Link in open space.
|
||||||
|
|
||||||
|
// Resources sector (far left): a finite reserve that only depletes; its grade
|
||||||
|
// (availability) is what the economy can actually draw on.
|
||||||
|
const resources = makeStock({ x: -820, y: 60 }, "Resources")
|
||||||
|
resources.initialValue = 1000
|
||||||
|
resources.description =
|
||||||
|
"The planet's nonrenewable reserve — it has no inflow, so it only ever falls. As it runs down it chokes the economy that lives off it."
|
||||||
|
const availability = makeConverter({ x: -820, y: -140 }, "availability")
|
||||||
|
// availability = 0.001 × Resources: a 0…1 grade (starts at 1.0) that scales output.
|
||||||
|
availability.rule = { kind: "proportional", factor: 0.001 }
|
||||||
|
availability.description =
|
||||||
|
"How much of the reserve is still cheap to reach (∝ Resources, ~1.0 at the start, → 0 as it empties). It is the brake the finite planet puts on output."
|
||||||
|
|
||||||
|
// Economy: Capital is the hub — investment fills it (Reinforcing), depreciation
|
||||||
|
// and climate damage drain it (Balancing). output relays Capital × availability.
|
||||||
|
const capital = makeStock({ x: -420, y: -40 }, "Capital")
|
||||||
|
capital.initialValue = 50
|
||||||
|
capital.description =
|
||||||
|
"Industrial capital — the engine that compounds by reinvesting its own output, until the reserve it burns runs short and the heat it raises bites back."
|
||||||
|
const output = makeConverter({ x: -600, y: -200 }, "output")
|
||||||
|
// output = Capital × availability (both `+`): the economy's activity, throttled by
|
||||||
|
// how much reserve is left. It feeds investment, combustion, and (via carbon) warming.
|
||||||
|
output.rule = { kind: "proportional", factor: 1 }
|
||||||
|
output.description =
|
||||||
|
"Industrial output, Capital × availability: more capital makes more, but a depleting reserve scales it down. The relay that ties the economy to the planet."
|
||||||
|
const investSrc = makeCloud({ x: -680, y: -40 })
|
||||||
|
const investment = makeFlow({ x: -550, y: -40 }, "investment", investSrc.id, capital.id)
|
||||||
|
// investment = 12% of output, reinvested: Capital → output → investment → Capital,
|
||||||
|
// no `−` → the Reinforcing engine that drives the boom.
|
||||||
|
investment.rule = { kind: "proportional", factor: 0.12 }
|
||||||
|
investment.description =
|
||||||
|
"Output ploughed back into capital, 12% per step. More capital → more output → more investment: the Reinforcing engine of growth."
|
||||||
|
const deprSink = makeCloud({ x: -240, y: -200 })
|
||||||
|
const depreciation = makeFlow({ x: -330, y: -120 }, "depreciation", capital.id, deprSink.id)
|
||||||
|
// depreciation = 4% of Capital: wear. Below investment while the reserve lasts,
|
||||||
|
// above it once availability collapses — which is what tips Capital into decline.
|
||||||
|
depreciation.rule = { kind: "proportional", factor: 0.04 }
|
||||||
|
depreciation.description =
|
||||||
|
"Capital wearing out, 4% per step — the Balancing drain that overtakes investment once a depleted reserve starves output."
|
||||||
|
const dmgSink = makeCloud({ x: -640, y: -260 })
|
||||||
|
const climateDamage = makeFlow({ x: -540, y: -160 }, "climate damage", capital.id, dmgSink.id)
|
||||||
|
// climate damage = 0.004 × Capital × warming: the heat's bite on the economy. It
|
||||||
|
// closes the long climate→economy loop, so warming is feedback, not just forcing.
|
||||||
|
climateDamage.rule = { kind: "proportional", factor: 0.004 }
|
||||||
|
climateDamage.description =
|
||||||
|
"Capital lost to a hotter world, ∝ Capital × warming. It closes the loop from output through carbon and warming back onto Capital — the climate biting the economy that warmed it."
|
||||||
|
|
||||||
|
// The combustion bridge: a single Stock→Stock Flow draining Resources *into*
|
||||||
|
// carbon. Burning the reserve is the emission — no Source, no Sink, both ends Stocks.
|
||||||
|
const carbon = makeStock({ x: 40, y: 60 }, "Atmospheric carbon")
|
||||||
|
carbon.initialValue = 280
|
||||||
|
carbon.unit = "ppm"
|
||||||
|
carbon.description =
|
||||||
|
"Carbon in the air (ppm), 280 at the preindustrial start. Combustion fills it fast, the sink empties it slow — so it climbs, then stays. The bathtub."
|
||||||
|
const combustion = makeFlow({ x: -380, y: 60 }, "combustion", resources.id, carbon.id)
|
||||||
|
// combustion = 3% of output: the reserve burned each step, drained from Resources
|
||||||
|
// and added to carbon in one move. Conserved — what leaves the ground enters the sky.
|
||||||
|
combustion.rule = { kind: "proportional", factor: 0.03 }
|
||||||
|
combustion.description =
|
||||||
|
"The reserve burned to run the economy, ∝ output — drained from Resources straight into the air. The same act depletes the planet and loads the sky."
|
||||||
|
const removalSink = makeCloud({ x: 40, y: -140 })
|
||||||
|
const removal = makeFlow({ x: 40, y: -40 }, "removal", carbon.id, removalSink.id)
|
||||||
|
// removal = 0.001 × (carbon − preindustrial): the natural sink, a Gap toward the
|
||||||
|
// baseline. Deliberately slow, so carbon barely recedes — the locked-in warming.
|
||||||
|
removal.rule = { kind: "gap", factor: 0.001 }
|
||||||
|
removal.description =
|
||||||
|
"Nature drawing carbon back toward the preindustrial baseline — a slow Gap. So slow that once emitted, the carbon stays for the run: warming you cannot take back."
|
||||||
|
const preindustrial = makeConverter({ x: -180, y: 240 }, "preindustrial")
|
||||||
|
preindustrial.rule = { kind: "constant", value: 280 }
|
||||||
|
preindustrial.description =
|
||||||
|
"The preindustrial carbon baseline (280 ppm), doing double duty: the target the sink draws toward and the zero from which warming is measured."
|
||||||
|
|
||||||
|
const warming = makeConverter({ x: 300, y: 200 }, "warming")
|
||||||
|
// warming = 0.005 × (carbon − preindustrial): °C above preindustrial. A Gap reading
|
||||||
|
// carbon as the level and the baseline as the target; ~4 °C at the carbon peak.
|
||||||
|
warming.rule = { kind: "gap", factor: 0.005 }
|
||||||
|
warming.description =
|
||||||
|
"Warming in °C above preindustrial, ∝ (carbon − baseline) — about 4 °C at the peak. It feeds both the deaths it causes and the capital it destroys."
|
||||||
|
|
||||||
|
// Population: births compound it (Reinforcing), baseline deaths brake it (Balancing),
|
||||||
|
// and heat deaths add the climate toll that eventually overwhelms the birth engine.
|
||||||
|
const population = makeStock({ x: 660, y: 60 }, "Population")
|
||||||
|
population.initialValue = 100
|
||||||
|
population.unit = "people"
|
||||||
|
population.description =
|
||||||
|
"The people — grows on births, thinned by ordinary deaths, and finally overwhelmed by the heat the economy's carbon locked in. The last sector to peak, and to fall."
|
||||||
|
const birthSrc = makeCloud({ x: 660, y: -140 })
|
||||||
|
const births = makeFlow({ x: 660, y: -40 }, "births", birthSrc.id, population.id)
|
||||||
|
// births = 5% of Population: the Reinforcing engine, +3%/step net of baseline deaths.
|
||||||
|
births.rule = { kind: "proportional", factor: 0.05 }
|
||||||
|
births.description =
|
||||||
|
"New people, 5% of the population each step — the Reinforcing engine that grows it while the world stays cool enough to bear it."
|
||||||
|
const deathSink = makeCloud({ x: 520, y: 240 })
|
||||||
|
const deaths = makeFlow({ x: 600, y: 160 }, "deaths", population.id, deathSink.id)
|
||||||
|
// deaths = 2% of Population: ordinary mortality, the Balancing drain births outrun early.
|
||||||
|
deaths.rule = { kind: "proportional", factor: 0.02 }
|
||||||
|
deaths.description =
|
||||||
|
"Ordinary deaths, 2% of the population each step — the Balancing drain the birth engine outpaces, until the heat tips the balance."
|
||||||
|
const heatSink = makeCloud({ x: 820, y: 240 })
|
||||||
|
const heatDeaths = makeFlow({ x: 740, y: 160 }, "heat deaths", population.id, heatSink.id)
|
||||||
|
// heat deaths = 0.011 × Population × warming: the climate toll. Once warming passes
|
||||||
|
// ≈2.7 °C this outflow overtakes net births and Population turns from boom to collapse.
|
||||||
|
heatDeaths.rule = { kind: "proportional", factor: 0.011 }
|
||||||
|
heatDeaths.description =
|
||||||
|
"Deaths from a hotter world, ∝ Population × warming. Once warming passes ≈2.7 °C this overtakes net births, and the population collapses with the heat that never lifts."
|
||||||
|
|
||||||
|
return model(
|
||||||
|
"World on a warming planet",
|
||||||
|
[
|
||||||
|
resources,
|
||||||
|
availability,
|
||||||
|
capital,
|
||||||
|
output,
|
||||||
|
investSrc,
|
||||||
|
investment,
|
||||||
|
deprSink,
|
||||||
|
depreciation,
|
||||||
|
dmgSink,
|
||||||
|
climateDamage,
|
||||||
|
carbon,
|
||||||
|
combustion,
|
||||||
|
removalSink,
|
||||||
|
removal,
|
||||||
|
preindustrial,
|
||||||
|
warming,
|
||||||
|
population,
|
||||||
|
birthSrc,
|
||||||
|
births,
|
||||||
|
deathSink,
|
||||||
|
deaths,
|
||||||
|
heatSink,
|
||||||
|
heatDeaths,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
link(resources, availability, "+", "More reserve left → more of it cheap to reach."),
|
||||||
|
link(capital, output, "+", "More capital → more output: the level the economy compounds on."),
|
||||||
|
link(
|
||||||
|
availability,
|
||||||
|
output,
|
||||||
|
"+",
|
||||||
|
"More availability → more output. With Capital, output = Capital × availability — the planet throttling the economy.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
output,
|
||||||
|
investment,
|
||||||
|
"+",
|
||||||
|
"More output → more reinvested: the + that makes the growth loop Reinforcing.",
|
||||||
|
),
|
||||||
|
link(capital, depreciation, "+", "More capital → more wearing out each step."),
|
||||||
|
link(capital, climateDamage, "+", "More capital → more of it exposed to a hotter world."),
|
||||||
|
link(
|
||||||
|
warming,
|
||||||
|
climateDamage,
|
||||||
|
"+",
|
||||||
|
"More warming → more capital destroyed. This − outflow closes the climate→economy loop: feedback, not forcing.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
output,
|
||||||
|
combustion,
|
||||||
|
"+",
|
||||||
|
"More output → more reserve burned: economic activity is what emits.",
|
||||||
|
),
|
||||||
|
link(carbon, removal, "+", "More carbon above the baseline → faster (but slow) removal."),
|
||||||
|
link(
|
||||||
|
preindustrial,
|
||||||
|
removal,
|
||||||
|
"-",
|
||||||
|
"The baseline the sink draws toward (the − target): removal stops once carbon is back to it.",
|
||||||
|
),
|
||||||
|
link(carbon, warming, "+", "More carbon → more warming: carbon is the level in the gap."),
|
||||||
|
link(
|
||||||
|
preindustrial,
|
||||||
|
warming,
|
||||||
|
"-",
|
||||||
|
"The baseline warming is measured from (the − target): no carbon above it, no warming.",
|
||||||
|
),
|
||||||
|
link(
|
||||||
|
population,
|
||||||
|
births,
|
||||||
|
"+",
|
||||||
|
"More people → more births: the + that makes the population loop Reinforcing.",
|
||||||
|
),
|
||||||
|
link(population, deaths, "+", "More people → more ordinary deaths."),
|
||||||
|
link(population, heatDeaths, "+", "More people → more of them exposed to the heat."),
|
||||||
|
link(
|
||||||
|
warming,
|
||||||
|
heatDeaths,
|
||||||
|
"+",
|
||||||
|
"More warming → more heat deaths. With Population this is the climate toll that ends the boom.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// The economy overshoots the reserve and collapses (output peaks ≈540 @ t≈47,
|
||||||
|
// Capital ≈1040 @ t≈60 → ~1); Resources deplete 1000 → ~120. The carbon burned
|
||||||
|
// climbs to ~1085 ppm and holds near 1000 (warming locked at ~4 °C). Population
|
||||||
|
// overshoots last (~310 @ t≈60) then collapses to ~30 as the locked-in heat bites.
|
||||||
|
{ start: 0, stop: 250, dt: 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The gallery, ordered simplest first. */
|
/** The gallery, ordered simplest first. */
|
||||||
export const SAMPLES: Sample[] = [
|
export const SAMPLES: Sample[] = [
|
||||||
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
|
{
|
||||||
|
title: "Bathtub",
|
||||||
|
blurb: "A stock filled and drained — no feedback yet.",
|
||||||
|
category: "Primer",
|
||||||
|
build: bathtub,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Savings account",
|
title: "Savings account",
|
||||||
blurb: "Interest on a balance: a Reinforcing loop.",
|
blurb: "Interest on a balance: a Reinforcing loop.",
|
||||||
|
category: "Primer",
|
||||||
build: savings,
|
build: savings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Coffee cooling",
|
title: "Coffee cooling",
|
||||||
blurb: "Settling toward room temperature: a Balancing loop.",
|
blurb: "Settling toward room temperature: a Balancing loop.",
|
||||||
|
category: "Primer",
|
||||||
build: coffee,
|
build: coffee,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Population",
|
title: "Population",
|
||||||
blurb: "Births and deaths: Reinforcing and Balancing together.",
|
blurb: "Births and deaths: Reinforcing and Balancing together.",
|
||||||
|
category: "Primer",
|
||||||
build: population,
|
build: population,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Limits to growth",
|
title: "Limits to growth",
|
||||||
blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.",
|
blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.",
|
||||||
|
category: "Classics",
|
||||||
build: limitsToGrowth,
|
build: limitsToGrowth,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Predator and prey",
|
title: "Predator and prey",
|
||||||
blurb: "Two coupled Stocks whose loops make them oscillate.",
|
blurb: "Two coupled Stocks whose loops make them oscillate.",
|
||||||
|
category: "Classics",
|
||||||
build: predatorPrey,
|
build: predatorPrey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Epidemic",
|
title: "Epidemic",
|
||||||
blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.",
|
blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.",
|
||||||
|
category: "Classics",
|
||||||
build: epidemic,
|
build: epidemic,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tragedy of the commons",
|
title: "Tragedy of the commons",
|
||||||
blurb: "Two Reinforcing herds overgraze a renewable Stock, then starve with it: a system trap.",
|
blurb: "Two Reinforcing herds overgraze a renewable Stock, then starve with it: a system trap.",
|
||||||
|
category: "System traps",
|
||||||
build: tragedyOfTheCommons,
|
build: tragedyOfTheCommons,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tragedy of the commons, fixed",
|
title: "Tragedy of the commons, fixed",
|
||||||
blurb: "Regulate the same renewable commons with a reserve: it holds, and the herds last.",
|
blurb: "Regulate the same renewable commons with a reserve: it holds, and the herds last.",
|
||||||
|
category: "System traps",
|
||||||
build: tragedyOfTheCommonsFixed,
|
build: tragedyOfTheCommonsFixed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Escalation",
|
title: "Escalation",
|
||||||
blurb: "An arms race: one Reinforcing loop spanning two Stocks.",
|
blurb: "An arms race: one Reinforcing loop spanning two Stocks.",
|
||||||
|
category: "System traps",
|
||||||
build: escalation,
|
build: escalation,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Success to the successful",
|
||||||
|
blurb:
|
||||||
|
"Two equal researchers, one a hair ahead: the field's attention locks onto the leader and the rival fades — a winner-take-all trap.",
|
||||||
|
category: "System traps",
|
||||||
|
build: successToTheSuccessful,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Fixes that fail",
|
title: "Fixes that fail",
|
||||||
blurb: "Road building eases congestion (B) but induces the traffic that refills it (R).",
|
blurb: "Road building eases congestion (B) but induces the traffic that refills it (R).",
|
||||||
|
category: "System traps",
|
||||||
build: fixesThatFail,
|
build: fixesThatFail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Drift to low performance",
|
title: "Drift to low performance",
|
||||||
blurb: "An eroding goal leaves steady decay no floor: both slide downhill.",
|
blurb: "An eroding goal leaves steady decay no floor: both slide downhill.",
|
||||||
|
category: "System traps",
|
||||||
build: driftToLowPerformance,
|
build: driftToLowPerformance,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overshoot and collapse",
|
title: "Overshoot and collapse",
|
||||||
blurb: "A fleet overfishes past the point of no return: the stock collapses for good.",
|
blurb: "A fleet overfishes past the point of no return: the stock collapses for good.",
|
||||||
|
category: "Dynamic edge",
|
||||||
build: overshootAndCollapse,
|
build: overshootAndCollapse,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AI deskilling spiral",
|
title: "AI deskilling spiral",
|
||||||
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
|
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
|
||||||
|
category: "Dynamic edge",
|
||||||
build: aiDeskillingSpiral,
|
build: aiDeskillingSpiral,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Bathtub with an overflow",
|
||||||
|
blurb:
|
||||||
|
"A flat-out tap and a spillway: the excess spills to a second Stock and the level holds.",
|
||||||
|
category: "Mechanics & capstone",
|
||||||
|
build: bathtubOverflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "The cobra effect",
|
||||||
|
blurb:
|
||||||
|
"A bounty on dead cobras breeds a cobra farm; its glut spills into the wild, leaving four times the snakes: a perverse incentive.",
|
||||||
|
category: "Mechanics & capstone",
|
||||||
|
build: cobraEffect,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "World on a warming planet",
|
||||||
|
blurb:
|
||||||
|
"The Club of Rome's World3 in miniature, with a climate channel: growth overshoots a finite planet and the carbon it burns locks in the heat that finishes it.",
|
||||||
|
category: "Mechanics & capstone",
|
||||||
|
build: worldOnAWarmingPlanet,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) =>
|
|||||||
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
|
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case "overflow": {
|
||||||
|
// max(0, factor × (level − threshold)): a one-sided gap. The `+` input is the
|
||||||
|
// level, the `−` the threshold; it stays shut until the level passes it, so an
|
||||||
|
// overflow Flow spills only the excess. Clamping at 0 is what stops it running
|
||||||
|
// backwards below the threshold — gap can't, by design (it's bidirectional).
|
||||||
|
const level = links.find((link) => link.polarity === "+")
|
||||||
|
const threshold = links.find((link) => link.polarity === "-")
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
rule.factor *
|
||||||
|
((level ? valueOf(level.source) : 0) - (threshold ? valueOf(threshold.source) : 0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,15 @@ export interface Position {
|
|||||||
* (→ exponential growth/decay)
|
* (→ exponential growth/decay)
|
||||||
* - `gap` — `factor × (level − target)`, where the `+` input is the
|
* - `gap` — `factor × (level − target)`, where the `+` input is the
|
||||||
* level and the `−` input the target. (→ goal-seeking)
|
* level and the `−` input the target. (→ goal-seeking)
|
||||||
|
* - `overflow` — `max(0, factor × (level − threshold))`: a one-sided `gap`
|
||||||
|
* that only fires once the `+` level exceeds the `−`
|
||||||
|
* threshold. (→ a spillway / hard ceiling)
|
||||||
*/
|
*/
|
||||||
export type Rule =
|
export type Rule =
|
||||||
| { kind: "constant"; value: number }
|
| { kind: "constant"; value: number }
|
||||||
| { kind: "proportional"; factor: number }
|
| { kind: "proportional"; factor: number }
|
||||||
| { kind: "gap"; factor: number }
|
| { kind: "gap"; factor: number }
|
||||||
|
| { kind: "overflow"; factor: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The run parameters for a simulation: integrate from `start` to `stop` in steps
|
* The run parameters for a simulation: integrate from `start` to `stop` in steps
|
||||||
|
|||||||
Reference in New Issue
Block a user