Compare commits

14 Commits

Author SHA1 Message Date
Julien Calixte
0c8a0c294f feat(editor): browse samples in a categorized modal gallery
Replace the cramped Samples dropdown with a modal that lays the gallery
out as a card grid, sectioned by tier, so the growing set stays scannable
and the learning order is visible at once.
2026-06-22 23:47:16 +02:00
Julien Calixte
37e3a1e89f feat(samples): tag each sample with a pedagogical category 2026-06-22 23:47:10 +02:00
Julien Calixte
914cf01763 refactor(samples): lay the cobra effect out left-to-right
Move Farmed cobras to the left so the story reads in order (farm engine
-> release bridge -> wild -> cull) and the release bridge no longer
doubles back across the canvas. Positions only; dynamics unchanged.
2026-06-22 23:33:14 +02:00
Julien Calixte
fe17bc8fae feat(samples): add "Success to the successful" competitive-exclusion trap
Two equal researchers and one conserved prize (the field's attention):
a single gap-driven Flow tilts it to whoever is a hair ahead, so a 51-49
near-tie locks in to ~100-0. Escalation's zero-sum twin -- the same
two-Stock all-Reinforcing structure, concentrating instead of exploding.
2026-06-22 23:33:06 +02:00
Julien Calixte
34bc8fa70b style: longer description 2026-06-22 23:19:01 +02:00
Julien Calixte
81425f8f3d style(adr-0004): realign the rate-rules table columns 2026-06-22 23:01:33 +02:00
Julien Calixte
d36f58977e feat(samples): add "The cobra effect" perverse-incentive trap
A bounty on dead cobras (a Balancing cull) funds a Reinforcing cobra
farm; once the farm gluts, the overflow rule spills its worthless
surplus into the wild, overshooting to 4x the starting population.

The engine has no time-triggered events, so the historical bounty
cancellation is modelled emergently: breeding outruns the bounty, the
farm gluts, and the one-sided overflow gate dumps the surplus. Sits
after "Bathtub with an overflow" since it reuses that rule.
2026-06-22 23:00:56 +02:00
Julien Calixte
62f6c5cda1 feat(samples): add "World on a warming planet" capstone
A miniature of the Club of Rome's World3 with its pollution sector
reframed as a climate channel: four coupled Stocks where Reinforcing
capital and population overshoot a finite Resource and the carbon they
burn locks in the warming that finishes them. A qualitative tribute to
the overshoot-and-collapse shape on the existing four rules, not a port
of the equations.
2026-06-21 20:06:35 +02:00
Julien Calixte
9ddff65072 docs(adr-0004): record the overflow rule in the vocabulary
The additive growth the ADR anticipated, now realised: the rule table, why
Overflow is a deliberate threshold exception to "the shape emerges", and why it
is a separate kind rather than a clamp on the (bidirectional) Gap rule.
2026-06-21 18:40:51 +02:00
Julien Calixte
faf8500fa5 feat(samples): add "Bathtub with an overflow" companion
The bathtub given a hard ceiling: a flat-out constant tap and an overflow spillway
that carries whatever rises past the brim into a second Stock, the floor. Water
climbs in a straight line then plateaus just above capacity while the floor fills —
an equilibrium from the spill, not from the inflow easing off. The gallery's one
declared ceiling, a counterpoint to the emergent one in "Limits to growth".
2026-06-21 18:31:39 +02:00
Julien Calixte
f691b99ca5 feat(simulation): add the overflow rule, a one-sided gap for hard ceilings
A fourth Rule kind: overflow = max(0, factor × (level − threshold)). It stays
shut until its `+` level passes its `−` threshold, so an outflow on it spills only
the excess — a spillway / hard ceiling the smooth rules can't draw.

Gap can't stand in for it: it's signed and bidirectional (coffee cooling relies on
that), so below the threshold a gap outflow runs backwards. Clamping at zero is the
whole point, and it can't be applied globally without breaking gap. This is the
additive vocabulary growth ADR-0004 anticipates — a new kind in the union plus a
case in the evaluator — alongside touch-ups to the rule validator and the inspector.
2026-06-21 18:31:39 +02:00
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
10 changed files with 1191 additions and 130 deletions

View File

@@ -22,20 +22,33 @@ typed-in formula.** Each instantaneous element picks one rule and a plain number
or two — never an expression:
| Rule | Value | Reads (via Information Links) | Emergent behaviour |
| ---------------- | --------------------------- | ----------------------------------------- | -------------------------- |
| ---------------- | -------------------------------------- | -------------------------------------------- | -------------------------- |
| **Constant** | a fixed number | nothing | linear Stock change |
| **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay |
| **Gap** | `factor × (level target)` | the `+` input is _level_, `` is _target_ | goal-seeking / asymptotic |
| **Overflow** | `max(0, factor × (level threshold))` | the `+` input is _level_, `` is _threshold_ | a spillway / hard ceiling |
The famous curves are _compositions_ of these over the structure — a logistic
S-curve is Proportional growth meeting a Gap-driven ceiling (limits-to-growth);
goal-seeking decay is a lone Gap (coffee cooling). The user sets up a local rule;
the global shape **emerges**. That emergence _is_ the lesson.
**Overflow is the one _declared_ limit.** Constant/Proportional/Gap are smooth and
their ceilings _emerge_ (the S-curve is two of them meeting); Overflow instead is a
threshold — `max(0, …)` that stays shut until a level crosses it, then spills the
excess down an outflow (a bathtub brimming onto the floor). It earns a rule of its
own because Gap _cannot_ stand in: Gap is signed and bidirectional (coffee re-warms
if it drops below the room), so a Gap outflow runs _backwards_ below its target, and
that clamp can't be applied to Gap globally without breaking the goal-seekers. So
Overflow is a deliberate exception to "the shape emerges" — a hard ceiling you
_declare_, for the real limit that is a wall, not a slope (and the rate-side sibling
of the non-negative-Stock floor the integrator already enforces).
**Polarity does double duty.** The `+`/`` already captured for loop
classification (ADR-0001) also selects each operand's role: Proportional reads
its `+` inputs; Gap reads its `+` input as the level and its `` input as the
target. One gesture, two payoffs — no new per-link data.
target, and Overflow the same with its `` input as the threshold. One gesture,
two payoffs — no new per-link data.
## Considered Options
@@ -70,5 +83,7 @@ target. One gesture, two payoffs — no new per-link data.
- The vocabulary starts deliberately small (Constant / Proportional / Gap —
enough for linear, exponential, and goal-seeking, and for the coffee and
savings samples). Growing it is additive: a new `kind` in the union plus a case
in the evaluator. Multi-input products (e.g. `Population × fertility`) are a
later increment, not a phase-2 blocker.
in the evaluator — as **Overflow** later bore out (one union member, one
evaluator case, plus matching touch-ups to the rule validator and the
inspector). Multi-input products (e.g. `Population × fertility`) are a later
increment, not a phase-2 blocker.

View File

@@ -29,7 +29,7 @@ import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
import type { Sample } from "@/model/samples"
import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
@@ -38,6 +38,7 @@ import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue"
import SampleBrowser from "./SampleBrowser.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
@@ -55,6 +56,9 @@ const edges = computed(() => graph.value.edges)
// the Model and recomputes reactively while open.
const showResults = ref(false)
// The sample-browser modal (opened from the header "Samples" button).
const browserOpen = ref(false)
// The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives).
usePlayback()
@@ -270,12 +274,12 @@ function onDragOver(event: DragEvent): void {
*/
function loadSample(sample: Sample): void {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
// Cancelled: leave the model untouched and the browser open to pick again.
return
}
fitAfterInit = true
store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
;(document.activeElement as HTMLElement | null)?.blur()
browserOpen.value = false
}
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
@@ -397,20 +401,7 @@ onBeforeUnmount(() => {
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span>
<div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-40 mt-1 max-h-80 w-72 flex-nowrap gap-1 overflow-y-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</li>
</ul>
</div>
<button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
<button
class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }"
@@ -504,5 +495,7 @@ onBeforeUnmount(() => {
the canvas to the space above, where the diagram re-fits (see the
`dimensions` watcher in <script>). -->
<ResultsPanel v-if="showResults" @close="showResults = false" />
<SampleBrowser v-model:open="browserOpen" @select="loadSample" />
</div>
</template>

View File

@@ -14,9 +14,7 @@ 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>(() => {
@@ -34,33 +32,6 @@ 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>
@@ -70,13 +41,5 @@ const description = computed<string | 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

@@ -115,6 +115,8 @@ const RULE_HINT: Record<Rule["kind"], string> = {
constant: "A fixed number — no inputs.",
proportional: "rate = factor × its “+” inputs.",
gap: "rate = factor × (level target): the “+” input is the level, the “−” the target.",
overflow:
"rate = max(0, factor × (level threshold)): spills only once the “+” level passes the “−” threshold.",
}
</script>
@@ -167,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<option value="constant">Constant</option>
<option value="proportional">Proportional</option>
<option value="gap">Gap</option>
<option value="overflow">Overflow</option>
</select>
</label>
@@ -204,7 +207,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Description</span>
<textarea
rows="3"
rows="6"
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
:value="(element ?? link)?.description ?? ''"
placeholder="Why this element is here…"

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
/**
* Sample browser — a modal gallery of the curated example models, laid out as a
* card grid sectioned by pedagogical tier (Primer → … → Mechanics & capstone).
* As the gallery grows this beats the old narrow dropdown: the whole set stays
* scannable in one scroll and the tiers show how the samples relate.
*
* Presentational only — it never touches the Model store. Picking a card emits
* `select`; the Editor owns the confirm/replace/fit logic (one place for it).
* Built on the native <dialog> so Esc, the backdrop click and focus-trapping come
* for free; `open` drives showModal()/close() and the native close event keeps the
* parent's state in sync.
*/
import { computed, useTemplateRef, watch } from "vue"
import { type Sample, SAMPLE_CATEGORIES, SAMPLES } from "@/model/samples"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ "update:open": [boolean]; select: [Sample] }>()
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
// Mirror the `open` prop onto the native dialog (showModal gives us the focus trap
// and inert backdrop a plain div can't).
watch(
() => props.open,
(open) => {
const el = dialog.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
},
)
// Fired by Esc, the backdrop form, the ✕, or our own close() — fold them all into
// one update:open so the parent's state can't drift from the dialog's.
function onClose(): void {
if (props.open) emit("update:open", false)
}
// The categories in display order, each with its samples (SAMPLES is already in
// pedagogical order, so first-appearance order is preserved). Empty tiers drop out.
const sections = computed(() =>
SAMPLE_CATEGORIES.map((category) => ({
category,
samples: SAMPLES.filter((sample) => sample.category === category),
})).filter((section) => section.samples.length > 0),
)
</script>
<template>
<dialog ref="dialog" class="modal" @close="onClose">
<div class="modal-box w-11/12 max-w-3xl">
<div class="mb-4 flex items-center gap-2">
<h3 class="text-base font-semibold">Browse samples</h3>
<span class="text-xs text-base-content/50">{{ SAMPLES.length }} models</span>
<form method="dialog" class="ml-auto">
<button class="btn btn-circle btn-ghost btn-sm" aria-label="Close"></button>
</form>
</div>
<section v-for="section in sections" :key="section.category" class="mb-5 last:mb-0">
<h4 class="mb-2 text-xs font-semibold tracking-wide text-base-content/50 uppercase">
{{ section.category }}
</h4>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="sample in section.samples"
:key="sample.title"
type="button"
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-3 text-left transition hover:border-primary hover:shadow-md"
@click="emit('select', sample)"
>
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</div>
</section>
</div>
<!-- Click outside the box to dismiss (submits the dialog fires @close). -->
<form method="dialog" class="modal-backdrop">
<button aria-label="Close">close</button>
</form>
</dialog>
</template>

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,6 +138,8 @@ 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,
@@ -101,10 +149,13 @@ function options(width: number): uPlot.Options {
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 },
@@ -112,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 }],
}
}
@@ -176,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

@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
function isRule(value: unknown): value is Rule {
if (!isObject(value)) return false
if (value.kind === "constant") return isFiniteNumber(value.value)
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor)
if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
return isFiniteNumber(value.factor)
return false
}

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,19 @@ function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) =>
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
)
}
case "overflow": {
// max(0, factor × (level threshold)): a one-sided gap. The `+` input is the
// level, the `` the threshold; it stays shut until the level passes it, so an
// overflow Flow spills only the excess. Clamping at 0 is what stops it running
// backwards below the threshold — gap can't, by design (it's bidirectional).
const level = links.find((link) => link.polarity === "+")
const threshold = links.find((link) => link.polarity === "-")
return Math.max(
0,
rule.factor *
((level ? valueOf(level.source) : 0) - (threshold ? valueOf(threshold.source) : 0)),
)
}
}
}

View File

@@ -42,11 +42,15 @@ export interface Position {
* (→ exponential growth/decay)
* - `gap` — `factor × (level target)`, where the `+` input is the
* level and the `` input the target. (→ goal-seeking)
* - `overflow` — `max(0, factor × (level threshold))`: a one-sided `gap`
* that only fires once the `+` level exceeds the ``
* threshold. (→ a spillway / hard ceiling)
*/
export type Rule =
| { kind: "constant"; value: number }
| { kind: "proportional"; factor: number }
| { kind: "gap"; factor: number }
| { kind: "overflow"; factor: number }
/**
* The run parameters for a simulation: integrate from `start` to `stop` in steps