Compare commits

..

11 Commits

Author SHA1 Message Date
Julien Calixte
46d3279a3c feat(samples): add "Tragedy of the commons, fixed" companion
Rework the trap so its Pasture is a renewable commons (logistic
regrowth) with herd mortality: unregulated breeding overshoots the
renewal rate, grazes the grass to bare dirt, and the herds then starve
with it — resource and users ruined together, not just the grass.

Add the fixed companion sharing that exact renewable Pasture. Stocking
is regulated against an agreed reserve, so the commons holds and the
herds settle at a sustainable size — more cattle than the trap's boom
leaves alive. The only difference between the pair is regulation, not
regrowth, so the comparison is honest.
2026-06-21 16:52:23 +02:00
Julien Calixte
15e9149095 feat(chart): split multi-magnitude tracks across two y-axes
Tracks whose peak values span more than an order of magnitude now plot
on a second right-hand axis, so a small track is no longer flattened
against the baseline by a large one. Also labels the legend's time
column "Time" (was uPlot's default "Value") and gives the toggleable
track rows a pointer cursor so the built-in hide/show is discoverable.
2026-06-21 16:02:10 +02:00
Julien Calixte
889c7361ed refactor(editor): drop duplicate description from gloss card
The Inspector already shows the selected element's description as an
editable field, and it co-renders with the gloss card on selection.
Showing the same text read-only in the gloss card was redundant, so the
card returns to its single job: defining the element's kind.
2026-06-21 15:28:42 +02:00
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
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
8 changed files with 1119 additions and 221 deletions

View File

@@ -24,7 +24,7 @@ import {
VueFlow,
type XYPosition,
} 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 { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
@@ -74,6 +74,7 @@ const {
getSelectedEdges,
viewport,
vueFlowRef,
dimensions,
fitView,
} = useVueFlow("meadows")
@@ -89,6 +90,26 @@ onNodesInitialized(() => {
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
// post-measure fit a load uses; fitting synchronously in onRestore would frame
// against unmeasured 0×0 nodes, landing the restored model jammed top-left.
@@ -460,7 +481,6 @@ onBeforeUnmount(() => {
<LoopOverlay />
<GlossPanel />
<Inspector />
<ResultsPanel v-if="showResults" @close="showResults = false" />
<!-- 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
@@ -479,5 +499,10 @@ onBeforeUnmount(() => {
</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>
</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,70 +120,96 @@ 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"
>
<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>
</div>
<!-- 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>
</div>
<!-- Stock: the quantity it starts from, and its unit. -->
<template v-if="element.kind === 'stock'">
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Initial value</span>
<input
type="number"
class="input input-sm input-bordered mt-1 w-full"
:value="element.initialValue ?? ''"
placeholder="—"
@change="onInitial"
/>
</label>
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Unit</span>
<input
type="text"
class="input input-sm input-bordered mt-1 w-full"
:value="element.unit ?? ''"
placeholder="e.g. °C, people, $"
@change="onUnit"
/>
</label>
<!-- Stock: the quantity it starts from, and its unit. -->
<template v-if="element.kind === 'stock'">
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Initial value</span>
<input
type="number"
class="input input-sm input-bordered mt-1 w-full"
:value="element.initialValue ?? ''"
placeholder="—"
@change="onInitial"
/>
</label>
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Unit</span>
<input
type="text"
class="input input-sm input-bordered mt-1 w-full"
:value="element.unit ?? ''"
placeholder="e.g. °C, people, $"
@change="onUnit"
/>
</label>
</template>
<!-- Flow / Converter: pick a rule, then its number. -->
<template v-else>
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Rule</span>
<select
class="select select-sm select-bordered mt-1 w-full"
:value="element.rule?.kind ?? ''"
@change="onKind"
>
<option value="" disabled>Choose a rule</option>
<option value="constant">Constant</option>
<option value="proportional">Proportional</option>
<option value="gap">Gap</option>
</select>
</label>
<label v-if="element.rule" class="mt-2 block">
<span class="text-xs text-base-content/60">
{{ element.rule.kind === "constant" ? "Value" : "Factor" }}
</span>
<input
type="number"
step="any"
class="input input-sm input-bordered mt-1 w-full"
:value="ruleNumber(element.rule)"
@change="onParam"
/>
</label>
<p v-if="element.rule" class="mt-2 text-xs leading-snug text-base-content/50">
{{ RULE_HINT[element.rule.kind] }}
</p>
</template>
</template>
<!-- Flow / Converter: pick a rule, then its number. -->
<template v-else>
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Rule</span>
<select
class="select select-sm select-bordered mt-1 w-full"
:value="element.rule?.kind ?? ''"
@change="onKind"
>
<option value="" disabled>Choose a rule</option>
<option value="constant">Constant</option>
<option value="proportional">Proportional</option>
<option value="gap">Gap</option>
</select>
</label>
<label v-if="element.rule" class="mt-2 block">
<span class="text-xs text-base-content/60">
{{ element.rule.kind === "constant" ? "Value" : "Factor" }}
</span>
<input
type="number"
step="any"
class="input input-sm input-bordered mt-1 w-full"
:value="ruleNumber(element.rule)"
@change="onParam"
/>
</label>
<p v-if="element.rule" class="mt-2 text-xs leading-snug text-base-content/50">
{{ RULE_HINT[element.rule.kind] }}
<!-- 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

@@ -59,9 +59,7 @@ const chart = computed(() => {
</script>
<template>
<div
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="shrink-0 border-t border-base-300 bg-base-100 p-3">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold">Behaviour over time</span>
<span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span>

View File

@@ -76,8 +76,54 @@ function themeColor(name: string, fallback: string): string {
return value || fallback
}
/**
* Peak |value| of a track as an order of magnitude (log10), or null when the
* track is empty or flat-zero — there is no magnitude to compare.
*/
function magnitude(track: (number | null)[]): number | null {
let peak = 0
for (const v of track) {
if (v !== null && Number.isFinite(v)) {
const a = Math.abs(v)
if (a > peak) peak = a
}
}
return peak > 0 ? Math.log10(peak) : null
}
// Split tracks onto a second y-axis once they span more than this many orders of
// magnitude: below it a single shared axis reads fine; above it the smaller track
// is flattened against the baseline by the larger one.
const SPLIT_DECADES = 1
/**
* Assign each series a y-scale: "y" (left axis) or "y2" (right axis). Tracks are
* split at their widest magnitude gap when that gap exceeds SPLIT_DECADES, with the
* smaller-magnitude tracks moving to "y2"; otherwise every track stays on "y".
* Flat-zero tracks have no magnitude, so they stay on the primary axis.
*/
function scaleKeys(): ("y" | "y2")[] {
const tracks = props.data.slice(1) as (number | null)[][]
const mags = tracks.map(magnitude)
const known = mags.filter((m): m is number => m !== null).sort((a, b) => a - b)
if (known.length < 2) return tracks.map(() => "y")
// The widest gap between adjacent magnitudes is the natural cut between "big" and
// "small" tracks; its midpoint is the threshold each track is measured against.
let gap = 0
let cut = 0
for (let i = 1; i < known.length; i++) {
if (known[i] - known[i - 1] > gap) {
gap = known[i] - known[i - 1]
cut = (known[i] + known[i - 1]) / 2
}
}
if (gap < SPLIT_DECADES) return tracks.map(() => "y")
return mags.map((m) => (m !== null && m < cut ? "y2" : "y"))
}
function trackSig(): string {
return props.series.map((s) => `${s.label}|${s.stroke}`).join(",")
const scales = scaleKeys()
return props.series.map((s, i) => `${s.label}|${s.stroke}|${scales[i]}`).join(",")
}
function options(width: number): uPlot.Options {
@@ -92,16 +138,24 @@ function options(width: number): uPlot.Options {
ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
font: `11px ${family}`,
}
const scales = scaleKeys()
const split = scales.includes("y2")
return {
width,
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 } },
series: [
{},
// x is simulation time, so the live legend's first column reads "Time"
// rather than uPlot's default "Value".
{ label: "Time" },
...props.series.map(
(s): uPlot.Series => ({
(s, i): uPlot.Series => ({
label: s.label,
scale: scales[i],
stroke: s.stroke,
width: 2,
points: { show: false },
@@ -109,7 +163,15 @@ function options(width: number): uPlot.Options {
}),
),
],
axes: [axis, { ...axis, size: 52 }],
// When tracks span orders of magnitude the small ones move to a right-hand
// axis ("y2"); its grid is suppressed so the two grids don't clash.
axes: split
? [
axis,
{ ...axis, size: 52 },
{ ...axis, scale: "y2", side: 1, size: 52, grid: { show: false } },
]
: [axis, { ...axis, size: 52 }],
}
}
@@ -173,4 +235,12 @@ watch(() => props.marker, syncMarker)
:deep(.u-legend .u-value) {
font-variant-numeric: tabular-nums;
}
/* Each track row toggles its line on click (⌘/Ctrl-click isolates it); the first
row is the Time readout and has no such handler. */
:deep(.u-legend .u-series) {
cursor: pointer;
}
:deep(.u-legend .u-series:first-child) {
cursor: default;
}
</style>

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
}

File diff suppressed because it is too large Load Diff

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,