Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
26d3c4493a feat(samples): annotate every sample's nodes and links
Fill in the per-element descriptions across all 13 gallery samples, so
selecting any node or link explains why it is there — a self-guided
tour of each model. The link() helper gains an optional description arg.
2026-06-21 15:19:36 +02:00
Julien Calixte
4a0498ee55 feat(editor): give each node and link a description
A free-text "why this element is here" note on Stocks, Flows,
Converters, and Information Links — editable in the Inspector next to
the unit, shown read-only in the gloss card below the generic kind
definition on selection. The Inspector now also opens for Information
Links, which previously had no editing surface.
2026-06-21 15:19:31 +02:00
6 changed files with 538 additions and 118 deletions

View File

@@ -14,7 +14,9 @@ import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue"
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
import type { EdgeData, NodeData } from "@/model/projection"
import { useModelStore } from "@/store/model"
const store = useModelStore()
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
const gloss = computed<GlossEntry | null>(() => {
@@ -32,6 +34,33 @@ const gloss = computed<GlossEntry | null>(() => {
}
return null
})
/**
* The selected element's own "why it's here" note (G4) — distinct from the
* generic `gloss` above, which defines the *kind*. Read live from the store so
* it tracks edits, and resolves the same selection a pipe/info edge stands in for.
*/
const description = computed<string | null>(() => {
const nodes = getSelectedNodes.value
const edges = getSelectedEdges.value
if (nodes.length === 1 && edges.length === 0) {
const node = store.model.nodes.find((n) => n.id === nodes[0].id)
return node && node.kind !== "cloud" ? (node.description ?? null) : null
}
if (edges.length === 1 && nodes.length === 0) {
const edge = edges[0]
const kind = (edge.data as EdgeData | undefined)?.kind
if (kind === "pipe") {
const flow = store.model.nodes.find((n) => n.id === edge.id.split("::")[0])
return flow && flow.kind !== "cloud" ? (flow.description ?? null) : null
}
if (kind === "info") {
return store.model.infoLinks.find((l) => l.id === edge.id)?.description ?? null
}
}
return null
})
</script>
<template>
@@ -41,5 +70,13 @@ const gloss = computed<GlossEntry | null>(() => {
>
<div class="text-sm font-semibold">{{ gloss.term }}</div>
<p class="mt-0.5 text-xs leading-snug text-base-content/70">{{ gloss.short }}</p>
<!-- This element's own rationale, below a divider so it reads apart from the
generic definition above (the "tour" note for the loaded sample). -->
<p
v-if="description"
class="mt-2 border-t border-base-300 pt-2 text-xs leading-snug text-base-content/80"
>
{{ description }}
</p>
</div>
</template>

View File

@@ -14,20 +14,25 @@
import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue"
import type { EdgeData } from "@/model/projection"
import type { ConverterNode, FlowNode, Rule, StockNode } from "@/model/types"
import type { ConverterNode, FlowNode, InformationLink, Rule, StockNode } from "@/model/types"
import { useModelStore } from "@/store/model"
const store = useModelStore()
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
/** The single selected element's id — a node directly, or a Flow via its pipe edge. */
/**
* The single selected element's id — a node directly, a Flow via its pipe edge,
* or an Information Link via its info edge (the edge id *is* the link id).
*/
const selectedId = computed<string | null>(() => {
const nodes = getSelectedNodes.value
const edges = getSelectedEdges.value
if (nodes.length === 1 && edges.length === 0) return nodes[0].id
if (edges.length === 1 && nodes.length === 0) {
const edge = edges[0]
if ((edge.data as EdgeData | undefined)?.kind === "pipe") return edge.id.split("::")[0]
const kind = (edge.data as EdgeData | undefined)?.kind
if (kind === "pipe") return edge.id.split("::")[0]
if (kind === "info") return edge.id
}
return null
})
@@ -41,8 +46,21 @@ const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
return null
})
/** The live Information Link behind the selection, when a link (not a node) is selected. */
const link = computed<InformationLink | null>(() => {
const id = selectedId.value
if (!id) return null
return store.model.infoLinks.find((l) => l.id === id) ?? null
})
const KIND_LABEL = { stock: "Stock", flow: "Flow", converter: "Converter" } as const
/** A link's endpoints are always named nodes — it never touches a Cloud (validation.ts). */
function nodeName(id: string): string {
const node = store.model.nodes.find((n) => n.id === id)
return node && "name" in node ? node.name : ""
}
/** Every rule carries exactly one number (a value or a factor); read it uniformly. */
function ruleNumber(rule?: Rule): number {
if (!rule) return 0
@@ -68,6 +86,13 @@ function onUnit(event: Event): void {
store.setUnit(el.id, (event.target as HTMLInputElement).value)
}
/** Write the description for whichever element (node or link) is selected. */
function onDescription(event: Event): void {
const id = element.value?.id ?? link.value?.id
if (!id) return
store.setDescription(id, (event.target as HTMLTextAreaElement).value)
}
function onKind(event: Event): void {
const el = element.value
if (el?.kind !== "flow" && el?.kind !== "converter") return
@@ -95,9 +120,11 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<template>
<div
v-if="element"
v-if="element || link"
class="absolute top-3 right-3 z-20 w-60 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-md backdrop-blur"
>
<!-- A named node: Stock (value + unit) or Flow/Converter (rule). -->
<template v-if="element">
<div class="flex items-baseline gap-2">
<span class="text-sm font-semibold">{{ element.name }}</span>
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
@@ -160,5 +187,29 @@ const RULE_HINT: Record<Rule["kind"], string> = {
{{ RULE_HINT[element.rule.kind] }}
</p>
</template>
</template>
<!-- An Information Link: its endpoints and polarity, then its description. -->
<template v-else-if="link">
<div class="flex items-baseline gap-2">
<span class="text-sm font-semibold">Information Link</span>
<span class="text-xs text-base-content/50">{{ link.polarity === "+" ? "+" : "" }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
{{ nodeName(link.source) }} {{ nodeName(link.target) }}
</p>
</template>
<!-- Shared across nodes and links: the "why this element is here" note (G4). -->
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Description</span>
<textarea
rows="3"
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
:value="(element ?? link)?.description ?? ''"
placeholder="Why this element is here…"
@change="onDescription"
/>
</label>
</div>
</template>

View File

@@ -99,6 +99,14 @@ function nodeError(value: unknown, index: number): string | null {
) {
return `${at}.rule must be a valid rule (constant/proportional/gap)`
}
// The selection-time rationale (display only); any named node may carry one.
if (
kind !== "cloud" &&
value.description !== undefined &&
typeof value.description !== "string"
) {
return `${at}.description must be a string`
}
return null
}
@@ -110,6 +118,9 @@ function linkError(value: unknown, index: number): string | null {
if (typeof value.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string`
if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"`
if (value.description !== undefined && typeof value.description !== "string") {
return `${at}.description must be a string`
}
return null
}

View File

@@ -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, "+")], {
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

View File

@@ -78,6 +78,8 @@ export interface StockNode extends BaseNode {
initialValue?: number
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
unit?: string
/** Why this Stock exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/**
@@ -95,6 +97,8 @@ export interface FlowNode extends BaseNode {
target: string
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule
/** Why this Flow exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/**
@@ -106,6 +110,8 @@ export interface ConverterNode extends BaseNode {
name: string
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule
/** Why this Converter exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/**
@@ -131,6 +137,8 @@ export interface InformationLink {
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
target: string
polarity: Polarity
/** Why this link exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/** One saved document: the unit of save / export / reopen. */

View File

@@ -128,6 +128,28 @@ export const useModelStore = defineStore("model", () => {
else node.unit = next
}
/**
* Set (or clear) the free-text description on a named node or an Information
* Link — the "why this element exists" note surfaced on selection. Empty
* clears it; no-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setDescription(id: string, text: string): void {
const next = text.trim() || undefined
const node = findNode(id)
if (node && node.kind !== "cloud") {
if (node.description === next) return
record()
if (next === undefined) delete node.description
else node.description = next
return
}
const link = model.value.infoLinks.find((l) => l.id === id)
if (!link || link.description === next) return
record()
if (next === undefined) delete link.description
else link.description = next
}
/**
* Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004:
* one of the fixed rules, never a formula). No-op when unchanged.
@@ -291,6 +313,7 @@ export const useModelStore = defineStore("model", () => {
renameNode,
setInitialValue,
setUnit,
setDescription,
setRule,
setSimSpec,
removeNode,