Compare commits

...

6 Commits

Author SHA1 Message Date
Julien Calixte
37bbdf5650 fix(samples): make "Drift to low performance" actually drift down
The model had no downward force on Performance, so its only flow
(improvement) could only pull it up. Performance rose 40 -> 60 while
the Standard fell 80 -> 60, contradicting the "slide downhill" story.

Add a constant decay outflow on Performance and seat it just below the
goal. Now both ratchet down in lockstep (Performance 70 -> 10, Standard
80 -> 20), a fixed gap apart — the genuine eroding-goals trap. Rewrite
the doc-comment, layout note, overview line, and blurb to match.
2026-06-21 13:16:28 +02:00
Julien Calixte
67c16a8d44 fix(samples): lower fishery natural-death rate to 0.01
Drops the Allee threshold to ~30 and lifts carrying capacity to ~1170, so
the stock is hardier and only collapses once heavily overfished. The
overshoot-and-collapse-to-extinction arc holds: Fish drift up, the fleet
overshoots (Boats peak ~700), and the catch drives the stock past the
threshold to a permanent zero by t=150.
2026-06-21 13:12:59 +02:00
Julien Calixte
e7a1bfb5af feat(samples): rework "Overshoot and collapse" as a renewable fishery
The non-renewable Resource (a Stock with no inflow) becomes a renewable
fishery with a true point of no return — an Allee threshold. Spawning
scales with density (~Fish^2) while natural deaths are linear (~Fish), so
below a critical density the deaths win and the stock slides to an
extinction it never recovers from; crowding deaths (~Fish^3) cap a healthy
stock at carrying capacity. A reinvesting fleet (Boats, Reinforcing)
overshoots the renewal rate and drags the fish under the threshold, then
starves and scraps itself.

The negative-positive-negative regrowth curve is the one shape the
proportional rule can't draw alone, so two relay Converters build it
(density to lift spawning to ~Fish^2, crowding for the ~Fish^3 ceiling) —
the Limits-to-growth crowding trick, doubled. At 16 nodes this is the
gallery's largest model and the only one with a Converter feeding a
Converter.

Tuned against the engine: Fish hold near 1000, cross the threshold (~200)
at t~40 as the catch overshoots, then go extinct and stay there; Boats
overshoot to ~450 and collapse back near their start by t=150. No
divergence; loops classify as expected (R: fleet reinvestment, birth
engine; B: natural/crowding/catch drains, scrapping).
2026-06-21 13:04:18 +02:00
Julien Calixte
59e3aafa63 feat(editor): dock the sim panel and refit the canvas to the rest
The results panel floated over the canvas and hid the lower part of the
diagram. Dock it beneath the canvas instead, and refit the view when it
opens or closes so the whole diagram stays framed in the height that
remains. The refit fires on Vue Flow's `dimensions` (post-measure), not
on the toggle, so it frames against the settled size.
2026-06-21 12:41:57 +02:00
Julien Calixte
2ae7cf87d7 fix(chart): disable click-drag-to-zoom on the read-only sim chart 2026-06-20 16:47:10 +02:00
Julien Calixte
69fec5fe2e feat(samples): add "AI deskilling spiral" gallery sample
The thirteenth sample points the language at a live debate: a classic trap,
Shifting the burden to the intervenor (Thinking in Systems, ch. 5), with AI as
the intervenor. Technical debt drives AI reliance, AI churns debt and lets
skills atrophy, and a thinner-skilled team refactors less — so the loop
Technical debt -> AI reliance -> atrophy -> Expertise -> refactoring ->
Technical debt is Reinforcing: addiction, not a fix. A learning Balancing brake
(practice toward a skill ceiling) is tuned to lose.

Maps the brief onto valid roles: Expertise and Technical debt are the stocks;
code quality and lead time read off them (inverse of debt / inverse of
expertise); model price lives in the AI-reliance factor. Buildable on the
existing rule vocabulary. Tuned against the simulator: over t=0..50 Expertise
slides 70 -> ~6 and Technical debt spirals 20 -> ~150, stopped (like
"Escalation") before the loop runs off-chart. The loop detector classifies the
four loops as expected (2 R, 2 B).
2026-06-20 15:28:51 +02:00
4 changed files with 278 additions and 83 deletions

View File

@@ -24,7 +24,7 @@ import {
VueFlow, VueFlow,
type XYPosition, type XYPosition,
} from "@vue-flow/core" } from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue" import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue"
import { useAutosave } from "@/composables/useAutosave" 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"
@@ -74,6 +74,7 @@ const {
getSelectedEdges, getSelectedEdges,
viewport, viewport,
vueFlowRef, vueFlowRef,
dimensions,
fitView, fitView,
} = useVueFlow("meadows") } = useVueFlow("meadows")
@@ -89,6 +90,26 @@ onNodesInitialized(() => {
fitView({ padding: 0.2 }) fitView({ padding: 0.2 })
}) })
// Docking the results panel (Simulate) shrinks the canvas; closing it gives the
// space back. Re-fit either way so the whole diagram stays framed in whatever
// height remains. Vue Flow re-measures the pane a frame after the panel
// mounts/unmounts and republishes the size via `dimensions`; fitting on *that*
// signal (not straight after the toggle) frames against the settled size, so the
// diagram never lands jammed against the new edge. The flag scopes the fit to
// panel toggles, leaving ordinary window resizes to Vue Flow's own handling.
let fitOnResize = false
watch(showResults, () => {
fitOnResize = true
})
watch(
() => [dimensions.value.width, dimensions.value.height],
() => {
if (!fitOnResize) return
fitOnResize = false
fitView({ padding: 0.2 })
},
)
// Restore the last document on mount and persist every change (F7). Arm the same // Restore the last document on mount and persist every change (F7). Arm the same
// post-measure fit a load uses; fitting synchronously in onRestore would frame // post-measure fit a load uses; fitting synchronously in onRestore would frame
// against unmeasured 0×0 nodes, landing the restored model jammed top-left. // against unmeasured 0×0 nodes, landing the restored model jammed top-left.
@@ -460,7 +481,6 @@ onBeforeUnmount(() => {
<LoopOverlay /> <LoopOverlay />
<GlossPanel /> <GlossPanel />
<Inspector /> <Inspector />
<ResultsPanel v-if="showResults" @close="showResults = false" />
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its <!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
@@ -479,5 +499,10 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
<!-- Docked beneath the canvas (not floating over it): opening Simulate shrinks
the canvas to the space above, where the diagram re-fits (see the
`dimensions` watcher in <script>). -->
<ResultsPanel v-if="showResults" @close="showResults = false" />
</div> </div>
</template> </template>

View File

@@ -59,9 +59,7 @@ const chart = computed(() => {
</script> </script>
<template> <template>
<div <div class="shrink-0 border-t border-base-300 bg-base-100 p-3">
class="absolute inset-x-3 bottom-3 z-30 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-lg backdrop-blur"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-semibold">Behaviour over time</span> <span class="text-sm font-semibold">Behaviour over time</span>
<span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span> <span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span>

View File

@@ -95,7 +95,10 @@ function options(width: number): uPlot.Options {
return { return {
width, width,
height: props.height, height: props.height,
cursor: { y: false }, // No y crosshair, and no click-drag-to-zoom (uPlot's drag.setScale defaults
// on): the chart is a read-only readout — the playhead drives time, not a
// drag-selected zoom region.
cursor: { y: false, drag: { x: false, y: false } },
scales: { x: { time: false } }, scales: { x: { time: false } },
series: [ series: [
{}, {},

View File

@@ -30,16 +30,25 @@
* no brake in the structure: an arms race. * no brake in the structure: an arms race.
* 10. Fixes that fail — a fix drains the symptom Stock (B) while its side * 10. 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.
* 11. Drift to low performance — a goal that erodes toward actual performance, so a * 11. Drift to low performance — a goal that erodes toward actual performance, so the
* effort it drives never overcomes a steady decay: a
* Reinforcing loop ratchets both downward. * Reinforcing loop ratchets both downward.
* *
* Last, 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:
* *
* 12. Overshoot and collapse — a Reinforcing engine running on a *non-renewable* * 12. Overshoot and collapse — a Reinforcing harvester on a *renewable* Resource with
* Stock (the first with no inflow): it overshoots the * an extinction threshold (an Allee floor): a fleet
* limit instead of settling at it, the dark twin of * overshoots the renewal rate and pushes the fishery past
* "Limits to growth" — the ceiling erodes, so it crashes. * the point of no return. The dark twin of "Limits to
* growth" — the limit doesn't hold, it collapses for good.
*
* Last, the language pointed at a live debate — a classic trap (Shifting the burden to
* the intervenor, ch. 5) wearing today's clothes:
*
* 13. AI deskilling spiral — handing the burden of code quality to AI atrophies the
* Expertise that holds quality up, so the team leans on AI
* harder and Technical debt spirals: addiction, not a fix.
* *
* These are plain data built from the same tested constructors the store uses * 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()`
@@ -528,22 +537,27 @@ function fixesThatFail(): Model {
} }
/** /**
* Drift to low performance — the eroding-goals trap. The Standard you hold yourself * Drift to low performance — the eroding-goals trap. Performance is under steady
* to is not fixed: it slips toward whatever you are actually delivering. Improvement * erosion (a constant `decay` leak: entropy, wear, rising demands), and the only
* is driven by the gap (Standard → [+] and Performance → [] → improvement), and so * thing fighting it is improvement, driven by the gap to the Standard you hold
* is slippage of the Standard (Standard → [+] and Performance → [] → slippage). The * yourself to (Standard → [+] and Performance → [] → improvement). Were the
* two local Balancing loops look healthy, but together they close a Reinforcing * Standard fixed, improvement would find a floor — Performance would settle a little
* spiral — Standard → improvement → Performance → slippage → Standard, two `` → R: * below the goal (here, at 60) and hold. But the Standard is not fixed: it slips
* let Performance dip and the Standard follows it down, easing the gap, easing the * toward whatever you are actually delivering (Standard → [+] and Performance → []
* effort, so Performance drifts lower still. That R badge is the trap. * → slippage), so there is no floor. Every notch Performance loses, the Standard
* follows down, weakening improvement, and decay carries Performance lower still —
* the loop Standard → improvement → Performance → slippage → Standard, two `` → R.
* That R badge is the trap: both ratchet downhill in lockstep (Performance 70 → 10,
* Standard 80 → 20), the effort forever just losing to decay.
*/ */
function driftToLowPerformance(): Model { function driftToLowPerformance(): Model {
// Performance sits low-left (fed by improvement from a Source); Standard sits // Performance sits low-left (fed by improvement from a Source, leaked away by
// high-right (drained by slippage to a Sink). The two long links that close the // decay to a Sink straight below it); Standard sits high-right (drained by
// Reinforcing spiral cross in the open centre, where the R badge lands. // slippage to a Sink). The two long links that close the Reinforcing loop cross
// in the open centre, where the R badge lands.
const source = makeCloud({ x: -560, y: 120 }) const source = makeCloud({ x: -560, y: 120 })
const performance = makeStock({ x: -160, y: 120 }, "Performance") const performance = makeStock({ x: -160, y: 120 }, "Performance")
performance.initialValue = 40 performance.initialValue = 70
const improvement = makeFlow( const improvement = makeFlow(
midpoint(source.position, performance.position), midpoint(source.position, performance.position),
"improvement", "improvement",
@@ -551,24 +565,35 @@ function driftToLowPerformance(): Model {
performance.id, performance.id,
) )
// improvement closes the gap upward: 10% of (Standard Performance), pulling // improvement closes the gap upward: 10% of (Standard Performance), pulling
// Performance toward the Standard. // Performance toward the Standard — the one force resisting decay.
improvement.rule = { kind: "gap", factor: 0.1 } improvement.rule = { kind: "gap", factor: 0.1 }
// decay leaks Performance away at a steady 2/step: the ever-present downward
// pressure the gap-driven improvement has to offset. Without it, two gap-closing
// flows would just meet in the middle; this is what makes the goal's erosion bite.
const decaySink = makeCloud({ x: -160, y: 400 })
const decay = makeFlow(
midpoint(performance.position, decaySink.position),
"decay",
performance.id,
decaySink.id,
)
decay.rule = { kind: "constant", value: 2 }
const standard = makeStock({ x: 160, y: -120 }, "Standard") const standard = makeStock({ x: 160, y: -120 }, "Standard")
standard.initialValue = 80 standard.initialValue = 80
const sink = makeCloud({ x: 560, y: -120 }) const slippageSink = makeCloud({ x: 560, y: -120 })
const slippage = makeFlow( const slippage = makeFlow(
midpoint(standard.position, sink.position), midpoint(standard.position, slippageSink.position),
"slippage", "slippage",
standard.id, standard.id,
sink.id, slippageSink.id,
) )
// slippage erodes the *same* gap from the other side: the Standard drifts down // slippage erodes the Standard toward actual Performance, also at 10% of the gap.
// toward actual Performance. Both gaps close, so they meet — the Standard has // It is what removes the floor: the Standard sags after Performance instead of
// sagged from 80 to the middle, the eroding-goal trap. // holding above it, so the gap stays open at a fixed 10 while both slide down.
slippage.rule = { kind: "gap", factor: 0.1 } slippage.rule = { kind: "gap", factor: 0.1 }
return model( return model(
"Drift to low performance", "Drift to low performance",
[source, performance, improvement, standard, sink, slippage], [source, performance, improvement, decay, decaySink, standard, slippageSink, slippage],
[ [
link(standard, improvement, "+"), link(standard, improvement, "+"),
link(performance, improvement, "-"), link(performance, improvement, "-"),
@@ -580,67 +605,206 @@ function driftToLowPerformance(): Model {
} }
/** /**
* Overshoot and collapse — the dark twin of "Limits to growth". The same * Overshoot and collapse — the dark twin of "Limits to growth", on a *renewable*
* Reinforcing engine runs, but the limit here is a *non-renewable* Resource that * Resource with a point of no return. A fishery (Fish) regrows on its own, but
* only depletes: a Stock with no inflow, the first in the gallery. An economy * reproduction needs fish to find each other: spawning scales with density
* (Capital) lives off it — extraction grows with both the Resource left and the * (spawning = factor × Fish × density, density ∝ Fish, so ~Fish²), while natural
* Capital deployed (Resource, Capital → [+] → extraction), and the revenue is * deaths are merely linear (natural deaths = factor × Fish). Above a critical
* reinvested as new Capital (extraction → [+] → investment → Capital), so the loop * density the quadratic births win and the stock climbs to its carrying capacity
* Capital → extraction → investment → Capital carries no `` → Reinforcing. Capital * (crowding deaths ~Fish³ cap it there); *below* it the linear deaths win and the
* climbs and extraction accelerates, but every unit burned is gone for good, so the * stock slides to an extinction it cannot climb back from — the Allee threshold,
* Resource crosses the break-even level, the engine starves, and depreciation * the renewable resource's hidden floor.
* (Capital → [+] → depreciation, a Balancing drain) takes Capital down: it peaks, *
* then collapses. Contrast "Predator and prey", whose prey regrows and so settles * A fishing fleet (Boats) reinvests its catch into more boats
* into oscillation — a finite Resource cannot, so it overshoots and crashes instead. * (Boats → catch → fleet growth → Boats, no `` → Reinforcing), so the catch
* (catch = factor × Fish × Boats) accelerates and overshoots the renewal rate,
* dragging Fish under the threshold. Once there it is too late: even as the catch
* starves and the fleet scraps itself (Boats → [+] → scrapping, a Balancing drain),
* the Fish are gone for good and never recover. Contrast "Predator and prey", whose
* prey regrows from any level and so oscillates forever — here the prey has a floor
* it cannot climb back from, so a Reinforcing harvester collapses it permanently.
*
* The Allee curve is the one shape the proportional rule cannot draw alone (it needs
* net regrowth to go negativepositivenegative), so two relays build it: `density`
* (∝ Fish) lifts spawning to ~Fish², and `crowding` (∝ Fish²) lifts crowding deaths
* to ~Fish³ — the same crowding trick as "Limits to growth", doubled. The gallery's
* largest model, and the only one that needs a Converter feeding a Converter.
*/ */
function overshootAndCollapse(): Model { function overshootAndCollapse(): Model {
// Resource on the left drains only downward (no inflow). Capital on the right runs // Fish (left) carries the whole renewal engine: a spawning inflow from the top, and
// a full Source → investment → Capital → depreciation → Sink column. The two // three drains — natural deaths, crowding deaths, and the catch. Boats (right) runs a
// coupling links — Capital → extraction and extraction → investment — cross in the // Source → fleet growth → Boats → scrappingSink column. The two coupling links —
// open centre, where the R badge lands. // Boats → catch and catch → fleet growth — cross the open centre, where the R badge lands.
const resource = makeStock({ x: -240, y: 0 }, "Resource") const fish = makeStock({ x: -420, y: 0 }, "Fish")
resource.initialValue = 1000 fish.initialValue = 1000
const extractionSink = makeCloud({ x: -240, y: 360 }) fish.unit = "tonnes"
const extraction = makeFlow({ x: -240, y: 160 }, "extraction", resource.id, extractionSink.id) // density ∝ Fish: how easily fish meet to spawn. Relays Fish into the births term so
// extraction = factor × Resource × Capital (both `+`): more capital extracts // spawning reads as ~Fish² — the Allee mechanism (sparse fish breed slowly).
// faster, scarcer resource slower. The bilinear term the non-negative floor tames. const density = makeConverter({ x: -700, y: -80 }, "density")
extraction.rule = { kind: "proportional", factor: 0.0004 } density.rule = { kind: "proportional", factor: 1 }
const capital = makeStock({ x: 240, y: 0 }, "Capital") // crowding ∝ Fish² (Fish × density): the overcrowding pressure that lifts crowding
capital.initialValue = 5 // deaths to ~Fish³, so the stock plateaus at its carrying capacity. A Converter read
const investmentSource = makeCloud({ x: 240, y: -360 }) // by a Converter — the only such wiring in the gallery.
const investment = makeFlow({ x: 240, y: -160 }, "investment", investmentSource.id, capital.id) const crowding = makeConverter({ x: -700, y: 80 }, "crowding")
// investment = factor × extraction (its one `+` input): the revenue reinvested — crowding.rule = { kind: "proportional", factor: 1 }
// a Flow feeding a Flow, the edge that closes the Reinforcing loop through Capital. const spawnSource = makeCloud({ x: -420, y: -320 })
investment.rule = { kind: "proportional", factor: 0.5 } const spawning = makeFlow({ x: -420, y: -160 }, "spawning", spawnSource.id, fish.id)
const depreciationSink = makeCloud({ x: 240, y: 360 }) // spawning = factor × Fish × density (~Fish²): the Reinforcing birth engine that
const depreciation = makeFlow({ x: 240, y: 160 }, "depreciation", capital.id, depreciationSink.id) // needs a crowd — it falls away faster than deaths as the Fish thin out.
// depreciation = factor × Capital (its `+` input): the Balancing drain that wins spawning.rule = { kind: "proportional", factor: 0.00036 }
// once the Resource can no longer feed investment. const deathSink = makeCloud({ x: -720, y: 280 })
depreciation.rule = { kind: "proportional", factor: 0.04 } const naturalDeaths = makeFlow({ x: -580, y: 180 }, "natural deaths", fish.id, deathSink.id)
// natural deaths = factor × Fish (linear): the Balancing drain that *wins* below the
// Allee threshold, where ~Fish² spawning can no longer keep up — and extinction follows.
naturalDeaths.rule = { kind: "proportional", factor: 0.01 }
const crowdSink = makeCloud({ x: -420, y: 320 })
const crowdingDeaths = makeFlow({ x: -420, y: 160 }, "crowding deaths", fish.id, crowdSink.id)
// crowding deaths = factor × Fish × crowding (~Fish³): the steep Balancing ceiling
// that holds the healthy stock at carrying capacity.
crowdingDeaths.rule = { kind: "proportional", factor: 3e-7 }
const catchSink = makeCloud({ x: -90, y: 220 })
const catching = makeFlow({ x: -255, y: 90 }, "catch", fish.id, catchSink.id)
// catch = factor × Fish × Boats (both `+`): more boats and more fish both lift the
// haul. This is what overshoots the renewal rate and pulls Fish under the threshold.
catching.rule = { kind: "proportional", factor: 0.0004 }
const boats = makeStock({ x: 420, y: 0 }, "Boats")
boats.initialValue = 5
const fleetSource = makeCloud({ x: 420, y: -320 })
const fleetGrowth = makeFlow({ x: 420, y: -160 }, "fleet growth", fleetSource.id, boats.id)
// fleet growth = factor × catch (its one `+` input): the revenue reinvested — a Flow
// feeding a Flow, the edge that closes the Reinforcing loop through Boats.
fleetGrowth.rule = { kind: "proportional", factor: 0.5 }
const scrapSink = makeCloud({ x: 420, y: 320 })
const scrapping = makeFlow({ x: 420, y: 160 }, "scrapping", boats.id, scrapSink.id)
// scrapping = factor × Boats (its `+` input): the Balancing drain that takes the fleet
// down once the catch can no longer feed fleet growth.
scrapping.rule = { kind: "proportional", factor: 0.04 }
return model( return model(
"Overshoot and collapse", "Overshoot and collapse",
[ [
resource, fish,
extractionSink, density,
extraction, crowding,
capital, spawnSource,
investmentSource, spawning,
investment, deathSink,
depreciationSink, naturalDeaths,
depreciation, crowdSink,
crowdingDeaths,
catchSink,
catching,
boats,
fleetSource,
fleetGrowth,
scrapSink,
scrapping,
], ],
[ [
link(resource, extraction, "+"), link(fish, density, "+"),
link(capital, extraction, "+"), link(fish, crowding, "+"),
link(extraction, investment, "+"), link(density, crowding, "+"),
link(capital, depreciation, "+"), 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, "+"),
], ],
// Capital starts at 5, overshoots to ~250 by t≈39, and collapses back near its // Fish drift up toward carrying capacity (~1170) while the fleet compounds, then the
// starting level by t=150 — the full boom-and-bust arc, no dead tail. // catch overshoots the renewal rate and pulls them past the Allee threshold (~30) at
// t≈43, after which they go extinct and stay there; Boats overshoot to ~700 (t≈37)
// and collapse back near their start by t=150.
{ start: 0, stop: 150, dt: 1 }, { start: 0, stop: 150, dt: 1 },
) )
} }
/**
* AI deskilling spiral — a classic trap in today's clothes: "Shifting the burden to
* the intervenor" (Thinking in Systems, ch. 5), where leaning on an outside fixer
* atrophies your own capacity to solve the problem, so you depend on the fixer ever
* more. Here the intervenor is AI. Technical debt drives reliance on it (the more cruft
* and delivery pressure, the more you reach for the model: Technical debt → [+] → AI
* reliance); AI churns out plausible code that adds debt (AI reliance → [+] → debt
* accrual) and lets skills lapse (AI reliance → [+] → atrophy, draining Expertise); and
* a thinner-skilled team refactors less (Expertise → [+] → refactoring, the Balancing
* payoff that now weakens). The loop Technical debt → AI reliance → atrophy → Expertise
* → refactoring → Technical debt carries two `` (the two outflows) → Reinforcing: the
* spiral. The hopeful brake is learning — practice pulling Expertise back toward its
* ceiling (skill ceiling → [+], Expertise → [] → learning: a Balancing loop) — but
* tuned here it loses to the spiral. (Code quality and lead time aren't nodes: quality
* reads as the inverse of Technical debt, lead time as the inverse of Expertise;
* "model price" lives in the AI-reliance factor — cheaper models, higher reliance.)
*/
function aiDeskillingSpiral(): Model {
// Two lanes — Expertise on top, Technical debt below — each a full Source → inflow →
// Stock → outflow → Sink. The AI reliance Converter sits between them, reading the
// debt below and feeding both atrophy (top lane) and debt accrual: the couplings that
// close the Reinforcing spiral cross the open centre.
const skillCeiling = makeConverter({ x: -360, y: -300 }, "skill ceiling")
skillCeiling.rule = { kind: "constant", value: 100 }
const learningSource = makeCloud({ x: -540, y: -120 })
const expertise = makeStock({ x: -180, y: -120 }, "Expertise")
expertise.initialValue = 70
const learning = makeFlow({ x: -360, y: -120 }, "learning", learningSource.id, expertise.id)
// learning = factor × (skill ceiling Expertise): practice pulls skill back up — the
// Balancing brake. The further from mastery, the harder you study.
learning.rule = { kind: "gap", factor: 0.04 }
const atrophySink = makeCloud({ x: 300, y: -120 })
const atrophy = makeFlow({ x: 80, y: -120 }, "atrophy", expertise.id, atrophySink.id)
// atrophy = factor × AI reliance: the more you offload to AI, the faster unused skills
// lapse — the side effect that makes this an addiction, not a fix.
atrophy.rule = { kind: "proportional", factor: 0.6 }
const accrualSource = makeCloud({ x: -540, y: 120 })
const debt = makeStock({ x: -180, y: 120 }, "Technical debt")
debt.initialValue = 20
const accrual = makeFlow({ x: -360, y: 120 }, "debt accrual", accrualSource.id, debt.id)
// debt accrual = factor × AI reliance: AI emits plausible code faster than anyone
// reviews it, so debt grows the more you lean on it.
accrual.rule = { kind: "proportional", factor: 0.9 }
const refactorSink = makeCloud({ x: 300, y: 120 })
const refactoring = makeFlow({ x: 80, y: 120 }, "refactoring", debt.id, refactorSink.id)
// refactoring = factor × Expertise × Technical debt: skilled teams pay debt down in
// proportion to how much there is — the Balancing payoff the spiral starves.
refactoring.rule = { kind: "proportional", factor: 0.0009 }
const reliance = makeConverter({ x: -180, y: 0 }, "AI reliance")
// AI reliance = factor × Technical debt: the factor is how cheap and available models
// are — lower model price, higher reliance per unit of debt.
reliance.rule = { kind: "proportional", factor: 0.1 }
return model(
"AI deskilling spiral",
[
skillCeiling,
learningSource,
expertise,
learning,
atrophySink,
atrophy,
accrualSource,
debt,
accrual,
refactorSink,
refactoring,
reliance,
],
[
link(skillCeiling, learning, "+"),
link(expertise, learning, "-"),
link(reliance, atrophy, "+"),
link(debt, reliance, "+"),
link(reliance, accrual, "+"),
link(expertise, refactoring, "+"),
link(debt, refactoring, "+"),
],
// Expertise slides 70 → ~6 and Technical debt spirals 20 → ~150 over the window —
// the Reinforcing loop clearly taking off, stopped (like "Escalation") before it
// runs away off-chart.
{ start: 0, stop: 50, dt: 1 },
)
}
/** The gallery, ordered simplest first. */ /** 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.", build: bathtub },
@@ -691,12 +855,17 @@ export const SAMPLES: Sample[] = [
}, },
{ {
title: "Drift to low performance", title: "Drift to low performance",
blurb: "Goals erode toward actual: a Reinforcing slide downhill.", blurb: "An eroding goal leaves steady decay no floor: both slide downhill.",
build: driftToLowPerformance, build: driftToLowPerformance,
}, },
{ {
title: "Overshoot and collapse", title: "Overshoot and collapse",
blurb: "A growth engine burns a finite Resource: it peaks, then crashes.", blurb: "A fleet overfishes past the point of no return: the stock collapses for good.",
build: overshootAndCollapse, build: overshootAndCollapse,
}, },
{
title: "AI deskilling spiral",
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
build: aiDeskillingSpiral,
},
] ]