Compare commits

...

4 Commits

Author SHA1 Message Date
Julien Calixte
f0d207c4d5 feat(model): make limits-to-growth a runnable two-flow logistic
Recast the S-curve as a Reinforcing inflow plus a crowding-driven die-off that
grows with Yeast², using only the existing proportional rule. Yeast now climbs
20 → ~1000 as a true sigmoid, and the detector still classifies it R + B — the
balancing loop stays visible, which a single "logistic" rule would have hidden.
Drops the carrying-capacity converter (a faithful one needs a divide rule).
2026-06-20 14:03:43 +02:00
Julien Calixte
34df540e4a feat(editor): add a simulation panel and an element inspector
- ResultsPanel: a Simulate panel that charts every Stock over time (hand-built
  SVG), with start/stop/dt run controls and a divergence warning
- Inspector: edit a selected Stock's initial value or a Flow/Converter's rule
- store: undoable setInitialValue / setRule / setSimSpec actions
2026-06-20 13:56:19 +02:00
Julien Calixte
40dc9fba43 feat(model): equip the sample gallery with runnable values
Give 10 of 11 samples initial values, rules, and a run window so they simulate
on load: Bathtub (linear), Savings/Escalation/Fixes-that-fail (Reinforcing),
Coffee (Balancing), Population, Epidemic (SIR), Tragedy (overshoot/collapse),
Drift (eroding goal), Predator-prey (damped). Limits to growth is left
diagram-only — its S-curve needs a saturating rule the vocabulary lacks.
2026-06-20 13:56:19 +02:00
Julien Calixte
5b6e830778 feat(model): simulate models from rules over information links
Phase 2 brings Models to life. ADR-0004: behaviour comes from a small fixed
vocabulary of rules (Constant / Proportional / Gap) read over the inbound
Information Links, not free-form formulas — valid by construction.

- types: Rule union, SimSpec, initialValue; replaces the unused equation field
- simulation: forward-Euler engine, dependency-ordered evaluation, algebraic-loop
  detection, non-negative stocks, and a divergence guard
- io: validate and round-trip the new fields (F8)
2026-06-20 13:56:06 +02:00
10 changed files with 909 additions and 26 deletions

View File

@@ -19,6 +19,9 @@ Deployed at https://meadows.apoena.dev
substrate; the domain Model is the source of truth.
- [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a
node; Source/Sink clouds are materialised nodes.
- [ADR-0004](./docs/adr/0004-rate-rules-not-formulas.md) — simulation behaviour
comes from a small fixed vocabulary of rules over Information Links, not
free-form formulas.
## Stack

View File

@@ -0,0 +1,74 @@
# Behaviour comes from rules over Information Links, not free-form formulas
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
Numeric simulation (phase 2) makes a **Model** _alive_: Stocks accumulate over
time, Flows and Converters recompute each instant. Two coupled decisions shape
how a Model carries the numbers, and both trade expressive power for **valid by
construction** — the right trade for a tool that exists to _popularise_ systems
thinking, not to compete with Vensim.
## Decision
**1. The Information Link _is_ the declared dependency.** A Flow's or Converter's
inputs are exactly the elements that link into it. There is no separate "equation
references" namespace to keep in sync — the wiring you draw _is_ the wiring the
simulator reads. The same signed graph the loop detector walks (ADR-0001) is the
graph the simulator integrates, so the loops you _see_ classified R/B are the
loops you _run_. They can never disagree.
**2. A Flow/Converter computes from a small fixed vocabulary of `Rule`s, not a
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 |
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.
**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.
## Considered Options
- **Free-form expression strings** (`birth rate = Population × fertility`) —
maximally expressive, and what `equation?: string` originally anticipated.
Rejected: needs a parser + a sandbox (never `eval`/`new Function`), invites
broken-formula and name-resolution errors (auto-names contain spaces), and lets
a learner _paint_ a curve instead of discovering it from structure.
- **Pick the output curve** (label a Stock "exponential" / "logarithmic") —
rejected: it is the answer, not the cause, and it breaks the moment feedback
decides the shape. "Logarithmic" in particular has no honest local rule; what
people mean by it is asymptotic approach — which _is_ the Gap rule.
- **Rules over Information Links (chosen)** — no parser, valid by construction,
and it teaches structure → behaviour.
## Consequences
- The domain types gain a `Rule` union on Flow/Converter (replacing the unused
`equation?: string`), an optional `initialValue` on Stock, and an optional
`SimSpec` (`start` / `stop` / `dt`) on the Model. All optional and additive, so
existing saved Models still load (F8); they are simply not _simulatable_ until
equipped.
- **Algebraic loops are an error.** A cycle in the wiring is legitimate feedback
**iff it passes through a Stock** — the Stock supplies last-step state and so
breaks the within-step dependency. A cycle among only Flows/Converters has no
Stock to break it: the simulator cannot order it and rejects it. The simulator
reuses the cycle machinery to detect this; it is a new _sim-readiness_ check,
distinct from structural validity (validation.ts).
- A reader seeing `{ kind: "gap", factor }` on a Flow and wondering where its
operands come from should look here: they are the Flow's inbound Information
Links, picked by Polarity.
- 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.

View File

@@ -33,8 +33,10 @@ import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
import GlossPanel from "./GlossPanel.vue"
import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
@@ -48,6 +50,10 @@ const graph = computed(() => project(store.model))
const nodes = computed(() => graph.value.nodes)
const edges = computed(() => graph.value.edges)
// The simulation results panel (phase 2). Toggled from the header; the panel runs
// the Model and recomputes reactively while open.
const showResults = ref(false)
// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
@@ -358,6 +364,13 @@ onBeforeUnmount(() => {
</li>
</ul>
</div>
<button
class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }"
@click="showResults = !showResults"
>
Simulate
</button>
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
<input
@@ -420,6 +433,8 @@ onBeforeUnmount(() => {
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
<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

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
/**
* Inspector (phase 2, ADR-0004) — equips the *selected* element with the numbers
* a simulation needs, so a Model becomes more than samples: a Stock gets its
* initial value, a Flow/Converter its rule. Editing is deliberately small —
* choose a rule from the fixed vocabulary and type one or two numbers; there is
* no formula box, so the Model stays valid by construction.
*
* It reads selection from the shared Vue Flow instance (as GlossPanel does), then
* resolves the *live* domain node from the store so edits round-trip through
* undoable store actions. Operands for a rule are the element's inbound
* Information Links picked by Polarity — set those by wiring, not here.
*/
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 { 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. */
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]
}
return null
})
/** The live, editable domain node behind the selection (Clouds are not editable). */
const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
const id = selectedId.value
if (!id) return null
const node = store.model.nodes.find((n) => n.id === id)
if (node?.kind === "stock" || node?.kind === "flow" || node?.kind === "converter") return node
return null
})
const KIND_LABEL = { stock: "Stock", flow: "Flow", converter: "Converter" } as const
/** Every rule carries exactly one number (a value or a factor); read it uniformly. */
function ruleNumber(rule?: Rule): number {
if (!rule) return 0
return rule.kind === "constant" ? rule.value : rule.factor
}
function buildRule(kind: Rule["kind"], n: number): Rule {
return kind === "constant" ? { kind, value: n } : { kind, factor: n }
}
function onInitial(event: Event): void {
const el = element.value
if (el?.kind !== "stock") return
const raw = (event.target as HTMLInputElement).value.trim()
if (raw === "") return store.setInitialValue(el.id, undefined)
const n = Number(raw)
if (Number.isFinite(n)) store.setInitialValue(el.id, n)
}
function onKind(event: Event): void {
const el = element.value
if (el?.kind !== "flow" && el?.kind !== "converter") return
const kind = (event.target as HTMLSelectElement).value as Rule["kind"] | ""
if (!kind) return
// Carry the existing number across a kind change; default a fresh rule sensibly.
const n = el.rule ? ruleNumber(el.rule) : kind === "constant" ? 0 : 1
store.setRule(el.id, buildRule(kind, n))
}
function onParam(event: Event): void {
const el = element.value
if ((el?.kind !== "flow" && el?.kind !== "converter") || !el.rule) return
const n = Number((event.target as HTMLInputElement).value)
store.setRule(el.id, buildRule(el.rule.kind, Number.isFinite(n) ? n : 0))
}
/** One-line reminder of where a rule reads its operands (they come from links). */
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.",
}
</script>
<template>
<div
v-if="element"
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>
<!-- Stock: the quantity it starts from. -->
<label v-if="element.kind === 'stock'" 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>
<!-- 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>
</div>
</template>

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
/**
* Results panel (phase 2, ADR-0004) — what makes the Model *alive*: it runs the
* simulation and traces each Stock's value over time. Stocks are the system's
* memory, so their trajectories are the behaviour worth watching (the savings
* balance snowballs; the coffee settles toward room temperature).
*
* It reads the source-of-truth Model straight from the store and recomputes
* reactively, so editing a rule and reopening re-runs. When the Model is not yet
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
* a checklist for bringing a diagram to life. No charting dependency: the plot is
* a hand-built SVG (the same lean-substrate choice as the rest of the editor).
*/
import { computed } from "vue"
import { checkSimReady, simulate } from "@/model/simulation"
import { DEFAULT_SIM_SPEC, type SimSpec, type StockNode } from "@/model/types"
import { useModelStore } from "@/store/model"
defineEmits<{ close: [] }>()
const store = useModelStore()
const problems = computed(() => checkSimReady(store.model))
/** The current run window; falls back to the default until the Model carries one. */
const spec = computed<SimSpec>(() => store.model.sim ?? DEFAULT_SIM_SPEC)
function onSpec(key: keyof SimSpec, event: Event): void {
const n = Number((event.target as HTMLInputElement).value)
if (!Number.isFinite(n) || (key === "dt" && n <= 0)) return
store.setSimSpec({ ...spec.value, [key]: n })
}
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
// SVG canvas in its own coordinate space; it stretches to the panel width, and
// non-scaling strokes keep lines crisp under that non-uniform scale.
const W = 600
const H = 200
const PAD = 12
const chart = computed(() => {
if (problems.value.length > 0) return null
const run = simulate(store.model)
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
const all = stocks.flatMap((s) => run.series.get(s.id) ?? [])
if (all.length === 0 || run.times.length < 2) return null
let min = Math.min(...all)
let max = Math.max(...all)
if (min === max) {
// A flat trajectory: pad so the line lands mid-panel instead of on an edge.
min -= 1
max += 1
}
const n = run.times.length
const x = (i: number) => PAD + (i / (n - 1)) * (W - 2 * PAD)
const y = (v: number) => PAD + (1 - (v - min) / (max - min)) * (H - 2 * PAD)
const lines = stocks.map((s, i) => {
const values = run.series.get(s.id) ?? []
return {
id: s.id,
name: s.name,
color: COLORS[i % COLORS.length],
points: values.map((v, j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" "),
last: values[values.length - 1] ?? 0,
}
})
return { lines, min, max, t0: run.times[0], t1: run.times[n - 1], diverged: run.diverged }
})
/** Compact numbers for axis ticks and the legend (e.g. 7039.99 → "7040"). */
function fmt(value: number): string {
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
}
</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="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>
<div class="ml-auto flex items-center gap-2 text-xs text-base-content/60">
<label class="flex items-center gap-1">
from
<input
type="number"
class="input input-xs input-bordered w-16"
:value="spec.start"
@change="onSpec('start', $event)"
/>
</label>
<label class="flex items-center gap-1">
to
<input
type="number"
class="input input-xs input-bordered w-16"
:value="spec.stop"
@change="onSpec('stop', $event)"
/>
</label>
<label class="flex items-center gap-1">
step
<input
type="number"
step="any"
min="0"
class="input input-xs input-bordered w-16"
:value="spec.dt"
@change="onSpec('dt', $event)"
/>
</label>
<button
type="button"
class="btn btn-circle btn-ghost btn-xs"
aria-label="Close"
@click="$emit('close')"
>
</button>
</div>
</div>
<!-- Stopped early because values ran past what a float can hold. -->
<p v-if="chart?.diverged" class="mt-2 text-xs text-warning">
Values grew beyond what can be plotted and the run stopped early try a smaller step, a
smaller factor, or a Balancing loop to rein it in.
</p>
<!-- Sim-ready: the plot, with a legend of final values. -->
<div v-if="chart" class="mt-2 flex items-stretch gap-3">
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-40 flex-1 rounded-md bg-base-200/40"
role="img"
aria-label="Stock values over time"
>
<text :x="PAD" :y="PAD" class="fill-base-content/40 text-[10px]">{{ fmt(chart.max) }}</text>
<text :x="PAD" :y="H - PAD / 2" class="fill-base-content/40 text-[10px]">
{{ fmt(chart.min) }}
</text>
<polyline
v-for="line in chart.lines"
:key="line.id"
:points="line.points"
fill="none"
:stroke="line.color"
stroke-width="2"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<ul class="flex w-44 flex-col gap-1 self-center text-xs">
<li v-for="line in chart.lines" :key="line.id" class="flex items-center gap-2">
<span class="size-2.5 shrink-0 rounded-full" :style="{ backgroundColor: line.color }" />
<span class="truncate">{{ line.name }}</span>
<span class="ml-auto font-mono text-base-content/60">{{ fmt(line.last) }}</span>
</li>
</ul>
</div>
<!-- Not sim-ready: what to fill in to bring it to life. -->
<div v-else class="mt-2 text-sm">
<p class="text-base-content/70">To bring this model to life, set:</p>
<ul class="mt-1 list-inside list-disc text-base-content/60">
<li v-for="(problem, i) in problems" :key="i">{{ problem }}</li>
</ul>
</div>
</div>
</template>

View File

@@ -21,6 +21,8 @@ import {
type NodeKind,
type Polarity,
type Position,
type Rule,
type SimSpec,
} from "./types"
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */
@@ -49,6 +51,24 @@ function isPolarity(value: unknown): value is Polarity {
return value === "+" || value === "-"
}
/** A Rule must be one of the fixed kinds with its numeric parameter (ADR-0004). */
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)
return false
}
function isSimSpec(value: unknown): value is SimSpec {
return (
isObject(value) &&
isFiniteNumber(value.start) &&
isFiniteNumber(value.stop) &&
isFiniteNumber(value.dt) &&
value.dt > 0
)
}
/** Validate one node by kind, returning an error string or null when valid. */
function nodeError(value: unknown, index: number): string | null {
if (!isObject(value)) return `nodes[${index}] is not an object`
@@ -65,6 +85,17 @@ function nodeError(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`
}
// Simulation fields (ADR-0004) are optional, but if present must be well-formed.
if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) {
return `${at}.initialValue must be a finite number`
}
if (
(kind === "flow" || kind === "converter") &&
value.rule !== undefined &&
!isRule(value.rule)
) {
return `${at}.rule must be a valid rule (constant/proportional/gap)`
}
return null
}
@@ -104,6 +135,9 @@ export function parseModel(text: string): ParseResult {
if (typeof data.name !== "string") return { ok: false, error: "name must be a string" }
if (!Array.isArray(data.nodes)) return { ok: false, error: "nodes must be an array" }
if (!Array.isArray(data.infoLinks)) return { ok: false, error: "infoLinks must be an array" }
if (data.sim !== undefined && !isSimSpec(data.sim)) {
return { ok: false, error: "sim must be {start, stop, dt} numbers with dt > 0" }
}
for (let i = 0; i < data.nodes.length; i++) {
const error = nodeError(data.nodes[i], i)
@@ -134,6 +168,13 @@ export function parseModel(text: string): ParseResult {
return {
ok: true,
model: { version: MODEL_VERSION, id: data.id, name: data.name, nodes, infoLinks: links },
model: {
version: MODEL_VERSION,
id: data.id,
name: data.name,
nodes,
infoLinks: links,
...(data.sim !== undefined && { sim: data.sim as SimSpec }),
},
}
}

View File

@@ -15,9 +15,9 @@
* Beyond that primer, three classic models go a step further — each adds one
* structure the first four never show, so they read as a second tier:
*
* 5. Limits to growth — two loops (R and B) fighting over a single Flow, plus a
* constant Converter (carrying capacity) that feeds a loop
* without being part of it.
* 5. Limits to growth — a Reinforcing inflow and a Balancing outflow on one
* Stock, with a Converter (crowding) relaying the density
* that brakes growth: the S-curve.
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
*
@@ -44,6 +44,7 @@ import {
MODEL_VERSION,
type ModelNode,
type Polarity,
type SimSpec,
} from "./types"
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
@@ -58,8 +59,13 @@ function link(source: ModelNode, target: ModelNode, polarity: Polarity): Informa
return { id: newId("link"), source: source.id, target: target.id, polarity }
}
function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]): Model {
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks }
function model(
name: string,
nodes: ModelNode[],
infoLinks: InformationLink[],
sim?: SimSpec,
): Model {
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks, sim }
}
/**
@@ -70,6 +76,7 @@ function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]):
function bathtub(): Model {
const source = makeCloud({ x: -280, y: 0 })
const water = makeStock({ x: 0, y: 0 }, "Water")
water.initialValue = 20
const sink = makeCloud({ x: 280, y: 0 })
const filling = makeFlow(
midpoint(source.position, water.position),
@@ -78,7 +85,15 @@ function bathtub(): Model {
water.id,
)
const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.id)
return model("Bathtub", [source, water, sink, filling, emptying], [])
// 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 }
emptying.rule = { kind: "constant", value: 3 }
return model("Bathtub", [source, water, sink, filling, emptying], [], {
start: 0,
stop: 40,
dt: 1,
})
}
/**
@@ -92,13 +107,21 @@ function savings(): Model {
// visible Reinforcing loop instead of overlapping the inflow pipe.
const source = makeCloud({ x: -240, y: -80 })
const balance = makeStock({ x: 120, y: 40 }, "Balance")
balance.initialValue = 1000
const interest = makeFlow(
midpoint(source.position, balance.position),
"interest",
source.id,
balance.id,
)
return model("Savings account", [source, balance, interest], [link(balance, interest, "+")])
// 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, "+")], {
start: 0,
stop: 40,
dt: 1,
})
}
/**
@@ -109,13 +132,19 @@ function savings(): Model {
*/
function coffee(): Model {
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
coffee.initialValue = 90
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 }
const room = makeConverter({ x: 0, y: -160 }, "room temperature")
room.rule = { kind: "constant", value: 20 }
return model(
"Coffee cooling",
[coffee, sink, cooling, room],
[link(coffee, cooling, "+"), link(room, cooling, "-")],
{ start: 0, stop: 60, dt: 1 },
)
}
@@ -132,11 +161,23 @@ function population(): Model {
// Valves are placed by hand, not at the midpoint, to hold the steps.
const source = makeCloud({ x: -360, y: -240 })
const fertility = makeConverter({ x: -360, y: -40 }, "fertility")
fertility.rule = { kind: "constant", value: 0.03 }
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 }
const people = makeStock({ x: 0, y: 0 }, "Population")
people.initialValue = 100
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 }
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 }
return model(
"Population",
[source, people, sink, births, deaths, fertility, lifeExpectancy],
@@ -146,34 +187,46 @@ function population(): Model {
link(people, deaths, "+"),
link(lifeExpectancy, deaths, "-"),
],
{ start: 0, stop: 100, dt: 1 },
)
}
/**
* Limits to growth — the S-curve, and the first model where two loops fight over
* one Flow. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
* Reinforcing loop), but the fuller the vat the more crowding holds growth back
* (Yeast → [+] → crowding → [] → growth: a Balancing loop). Carrying capacity is
* a *constant* Converter — no inputs — that sets how soon crowding bites; it feeds
* the balancing loop without sitting on any cycle.
* Limits to growth — the S-curve, where a Reinforcing engine meets a Balancing
* brake. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
* Reinforcing inflow), but crowding rises with the population (Yeast → [+] →
* crowding) and drives a die-off that grows with the *square* of the Yeast
* (Yeast, crowding → [+] → die-off → drains Yeast: a Balancing outflow). Growth
* wins early, the die-off wins late, so Yeast settles where they balance (≈1000)
* — the classic sigmoid, with *both* loops visible to the detector. (A named
* "carrying capacity" would want a divide rule we don't have yet; here the ceiling
* falls out of the growth and die-off rates.)
*/
function limitsToGrowth(): Model {
const source = makeCloud({ x: -280, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
yeast.initialValue = 20
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
// crowding rides above the pipe; carrying capacity stacks above the Source on the
// left, so the `capacity → crowding` link is a clean horizontal hop along the top.
const capacity = makeConverter({ x: -280, y: -160 }, "carrying capacity")
const crowding = makeConverter({ x: -40, y: -160 }, "crowding")
// growth = 30% of Yeast (its `+` input): the Reinforcing engine.
growth.rule = { kind: "proportional", factor: 0.3 }
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 }
// 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 }
return model(
"Limits to growth",
[source, yeast, growth, crowding, capacity],
[source, yeast, growth, sink, dieOff, crowding],
[
link(yeast, growth, "+"),
link(yeast, crowding, "+"),
link(crowding, growth, "-"),
link(capacity, crowding, "-"),
link(yeast, dieOff, "+"),
link(crowding, dieOff, "+"),
],
{ start: 0, stop: 40, dt: 1 },
)
}
@@ -192,6 +245,7 @@ function predatorPrey(): Model {
// cross-stock loop traces a circuit through the open centre.
const preySource = makeCloud({ x: -480, y: -140 })
const rabbits = makeStock({ x: -80, y: -140 }, "Rabbits")
rabbits.initialValue = 100
const preySink = makeCloud({ x: 320, y: -140 })
const rabbitBirths = makeFlow(
midpoint(preySource.position, rabbits.position),
@@ -199,14 +253,19 @@ function predatorPrey(): Model {
preySource.id,
rabbits.id,
)
// rabbits breed in proportion to themselves (Reinforcing) …
rabbitBirths.rule = { kind: "proportional", factor: 0.08 }
const predation = makeFlow(
midpoint(rabbits.position, preySink.position),
"predation",
rabbits.id,
preySink.id,
)
// … and are thinned by predation = rabbits × foxes (both `+`): the coupling term.
predation.rule = { kind: "proportional", factor: 0.004 }
const foxSource = makeCloud({ x: -480, y: 140 })
const foxes = makeStock({ x: -80, y: 140 }, "Foxes")
foxes.initialValue = 20
const foxSink = makeCloud({ x: 320, y: 140 })
const foxBirths = makeFlow(
midpoint(foxSource.position, foxes.position),
@@ -214,12 +273,17 @@ function predatorPrey(): Model {
foxSource.id,
foxes.id,
)
// foxes are born in proportion to the rabbits available to eat …
foxBirths.rule = { kind: "proportional", factor: 0.02 }
const foxDeaths = makeFlow(
midpoint(foxes.position, foxSink.position),
"fox deaths",
foxes.id,
foxSink.id,
)
// … 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 }
return model(
"Predator and prey",
[
@@ -241,6 +305,7 @@ function predatorPrey(): Model {
link(rabbits, foxBirths, "+"),
link(foxes, foxDeaths, "+"),
],
{ start: 0, stop: 120, dt: 0.25 },
)
}
@@ -255,21 +320,32 @@ function predatorPrey(): Model {
*/
function epidemic(): Model {
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
susceptible.initialValue = 990
const infected = makeStock({ x: 0, y: 0 }, "Infected")
infected.initialValue = 10
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
recovered.initialValue = 0
const infection = makeFlow(
midpoint(susceptible.position, infected.position),
"infection",
susceptible.id,
infected.id,
)
// infection = infectivity × Susceptible × Infected (proportional reads all three
// `+` 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 }
const recovery = makeFlow(
midpoint(infected.position, recovered.position),
"recovery",
infected.id,
recovered.id,
)
// recovery = 15% of the Infected each step (its one `+` input).
recovery.rule = { kind: "proportional", factor: 0.15 }
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 }
return model(
"Epidemic",
[susceptible, infected, recovered, infection, recovery, infectivity],
@@ -279,6 +355,7 @@ function epidemic(): Model {
link(infected, recovery, "+"),
link(infectivity, infection, "+"),
],
{ start: 0, stop: 60, dt: 1 },
)
}
@@ -295,17 +372,22 @@ function epidemic(): Model {
*/
function tragedyOfTheCommons(): Model {
const pasture = makeStock({ x: 0, y: 0 }, "Pasture")
pasture.initialValue = 1000
// 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
const growthA = makeFlow(
midpoint(sourceA.position, herdA.position),
"growth A",
sourceA.id,
herdA.id,
)
// 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 }
const sinkA = makeCloud({ x: -200, y: 240 })
const grazingA = makeFlow(
midpoint(pasture.position, sinkA.position),
@@ -313,14 +395,19 @@ function tragedyOfTheCommons(): Model {
pasture.id,
sinkA.id,
)
// 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 }
const sourceB = makeCloud({ x: 640, y: 0 })
const herdB = makeStock({ x: 360, y: 0 }, "Herd B")
herdB.initialValue = 10
const growthB = makeFlow(
midpoint(sourceB.position, herdB.position),
"growth B",
sourceB.id,
herdB.id,
)
growthB.rule = { kind: "proportional", factor: 0.0003 }
const sinkB = makeCloud({ x: 200, y: 240 })
const grazingB = makeFlow(
midpoint(pasture.position, sinkB.position),
@@ -328,6 +415,7 @@ function tragedyOfTheCommons(): Model {
pasture.id,
sinkB.id,
)
grazingB.rule = { kind: "proportional", factor: 0.06 }
return model(
"Tragedy of the commons",
[pasture, sourceA, herdA, growthA, sinkA, grazingA, sourceB, herdB, growthB, sinkB, grazingB],
@@ -339,6 +427,7 @@ function tragedyOfTheCommons(): Model {
link(herdB, grazingB, "+"),
link(pasture, growthB, "+"),
],
{ start: 0, stop: 60, dt: 1 },
)
}
@@ -357,24 +446,31 @@ function escalation(): Model {
// cross there, where the R badge lands, so the whole loop reads at a glance.
const blueSource = makeCloud({ x: -560, y: -120 })
const blueArsenal = makeStock({ x: 280, y: -120 }, "Blue arsenal")
blueArsenal.initialValue = 10
const blueBuildup = makeFlow(
midpoint(blueSource.position, blueArsenal.position),
"Blue buildup",
blueSource.id,
blueArsenal.id,
)
// 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 }
const redSource = makeCloud({ x: -560, y: 120 })
const redArsenal = makeStock({ x: 280, y: 120 }, "Red arsenal")
redArsenal.initialValue = 12
const redBuildup = makeFlow(
midpoint(redSource.position, redArsenal.position),
"Red buildup",
redSource.id,
redArsenal.id,
)
redBuildup.rule = { kind: "proportional", factor: 0.1 }
return model(
"Escalation",
[blueSource, blueArsenal, blueBuildup, redSource, redArsenal, redBuildup],
[link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")],
{ start: 0, stop: 40, dt: 1 },
)
}
@@ -396,13 +492,22 @@ function fixesThatFail(): Model {
// the diagonal back to Congestion. Placed by hand, not midpoint, to hold the column.
const source = makeCloud({ x: -420, y: 120 })
const congestion = makeStock({ x: 300, y: 120 }, "Congestion")
congestion.initialValue = 50
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 }
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 }
return model(
"Fixes that fail",
[source, congestion, driving, sink, roadBuilding],
[link(congestion, roadBuilding, "+"), link(roadBuilding, driving, "+")],
{ start: 0, stop: 30, dt: 1 },
)
}
@@ -422,13 +527,18 @@ function driftToLowPerformance(): Model {
// Reinforcing spiral cross in the open centre, where the R badge lands.
const source = makeCloud({ x: -560, y: 120 })
const performance = makeStock({ x: -160, y: 120 }, "Performance")
performance.initialValue = 40
const improvement = makeFlow(
midpoint(source.position, performance.position),
"improvement",
source.id,
performance.id,
)
// improvement closes the gap upward: 10% of (Standard Performance), pulling
// Performance toward the Standard.
improvement.rule = { kind: "gap", factor: 0.1 }
const standard = makeStock({ x: 160, y: -120 }, "Standard")
standard.initialValue = 80
const sink = makeCloud({ x: 560, y: -120 })
const slippage = makeFlow(
midpoint(standard.position, sink.position),
@@ -436,6 +546,10 @@ function driftToLowPerformance(): Model {
standard.id,
sink.id,
)
// slippage erodes the *same* gap from the other side: the Standard drifts down
// toward actual Performance. Both gaps close, so they meet — the Standard has
// sagged from 80 to the middle, the eroding-goal trap.
slippage.rule = { kind: "gap", factor: 0.1 }
return model(
"Drift to low performance",
[source, performance, improvement, standard, sink, slippage],
@@ -445,6 +559,7 @@ function driftToLowPerformance(): Model {
link(standard, slippage, "+"),
link(performance, slippage, "-"),
],
{ start: 0, stop: 60, dt: 1 },
)
}

228
src/model/simulation.ts Normal file
View File

@@ -0,0 +1,228 @@
/**
* Simulation engine (ADR-0004) — what makes a Model *alive*. Pure data in, time
* series out; it knows nothing of Vue or the canvas, so it is trivially testable
* and runs off any reactive frame.
*
* The system-dynamics loop, one step of `dt`:
* 1. Evaluate the instantaneous network — every Converter and Flow — in
* dependency order, reading the *current* Stock values. A Converter/Flow
* depends on the elements that link into it (ADR-0004: the Information Link
* *is* the dependency); Stocks are state, available without ordering.
* 2. Integrate every Stock *simultaneously* (forward Euler):
* `stock += dt × (Σ inflow rates Σ outflow rates)`. All net rates are read
* from the same pre-update state, so updating one Stock never feeds another
* within the same step.
*
* A cycle in the wiring is legitimate feedback only if it passes through a Stock
* (the Stock supplies last-step state and breaks the within-step dependency). A
* cycle among only Converters/Flows is an **algebraic loop** — unorderable, and
* rejected by `evaluationOrder` / surfaced by `checkSimReady`.
*/
import {
type ConverterNode,
DEFAULT_SIM_SPEC,
type FlowNode,
type InformationLink,
type Model,
type Rule,
type SimSpec,
} from "./types"
/** Thrown when a Model cannot be simulated as wired (e.g. an algebraic loop). */
export class SimulationError extends Error {
constructor(message: string) {
super(message)
this.name = "SimulationError"
}
}
/** A completed run: aligned `times` and per-element value tracks. */
export interface Run {
/** The time at each recorded sample, `start … stop` in steps of `dt`. */
times: number[]
/** nodeId → its value at each time index (Stocks, Flows, Converters; not Clouds). */
series: Map<string, number[]>
/** True if the run ran past representable numbers and stopped early (see below). */
diverged: boolean
}
/** A Converter or Flow — the stateless, instantaneous elements an order applies to. */
type Instant = ConverterNode | FlowNode
/**
* Order the instantaneous network so each element is computed after everything
* it reads. Throws `SimulationError` on an algebraic loop (a Converter/Flow cycle
* with no Stock to break it). Stocks are excluded — they are state, not computed.
*/
export function evaluationOrder(model: Model): Instant[] {
const instant = model.nodes.filter(
(node): node is Instant => node.kind === "flow" || node.kind === "converter",
)
const ids = new Set(instant.map((node) => node.id))
// deps[x] = the instantaneous elements x reads (links from another Flow/Converter).
const deps = new Map<string, Set<string>>(instant.map((node) => [node.id, new Set<string>()]))
for (const link of model.infoLinks) {
if (ids.has(link.source) && ids.has(link.target)) deps.get(link.target)?.add(link.source)
}
const order: Instant[] = []
const resolved = new Set<string>()
while (order.length < instant.length) {
const next = instant.find(
(node) =>
!resolved.has(node.id) && [...(deps.get(node.id) ?? [])].every((d) => resolved.has(d)),
)
if (!next) {
const names = instant
.filter((node) => !resolved.has(node.id))
.map((node) => node.name)
.join(", ")
throw new SimulationError(
`Algebraic loop: ${names} depend on each other with no Stock to break the cycle.`,
)
}
resolved.add(next.id)
order.push(next)
}
return order
}
/** Evaluate one Rule given a way to read the values feeding in via `links`. */
function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) => number): number {
switch (rule.kind) {
case "constant":
return rule.value
case "proportional": {
// factor × the product of every `+`-polarity input (one input → factor × it).
let value = rule.factor
for (const link of links) if (link.polarity === "+") value *= valueOf(link.source)
return value
}
case "gap": {
// factor × (level target): the `+` input is the level, the `` the target.
const level = links.find((link) => link.polarity === "+")
const target = links.find((link) => link.polarity === "-")
return (
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
)
}
}
}
/**
* Run the Model and return aligned time series. Assumes a sim-ready Model
* (see `checkSimReady`); a missing rule evaluates to 0 and a missing initial
* value to 0 rather than throwing, so a half-built Model still produces a plot.
* Throws `SimulationError` only on an algebraic loop, which has no defined order.
*/
export function simulate(model: Model, spec: SimSpec = model.sim ?? DEFAULT_SIM_SPEC): Run {
const order = evaluationOrder(model)
const nodeById = new Map(model.nodes.map((node) => [node.id, node]))
const inbound = new Map<string, InformationLink[]>()
for (const link of model.infoLinks) {
const list = inbound.get(link.target)
if (list) list.push(link)
else inbound.set(link.target, [link])
}
const stocks = model.nodes.filter((node) => node.kind === "stock")
const flows = model.nodes.filter((node): node is FlowNode => node.kind === "flow")
const stockValues = new Map<string, number>(stocks.map((s) => [s.id, s.initialValue ?? 0]))
const times: number[] = []
const series = new Map<string, number[]>()
for (const node of model.nodes) if (node.kind !== "cloud") series.set(node.id, [])
let diverged = false
// dt ≤ 0 would never advance (and 0 diverges); a non-positive step means no run.
const steps = spec.dt > 0 ? Math.max(0, Math.floor((spec.stop - spec.start) / spec.dt)) : 0
for (let i = 0; i <= steps; i++) {
// A run-away Reinforcing loop over a long horizon can exceed what a float
// holds. Stop at the last valid sample and flag it rather than plotting NaN.
if (!stocks.every((s) => Number.isFinite(stockValues.get(s.id) ?? 0))) {
diverged = true
break
}
// 1. Evaluate the instantaneous network from the current Stock values.
const computed = new Map<string, number>()
const valueOf = (id: string): number => {
const node = nodeById.get(id)
if (!node) return 0
if (node.kind === "stock") return stockValues.get(id) ?? 0
if (node.kind === "cloud") return 0
return computed.get(id) ?? 0
}
for (const node of order) {
computed.set(
node.id,
node.rule ? evalRule(node.rule, inbound.get(node.id) ?? [], valueOf) : 0,
)
}
// Record this sample (Stocks at their current value, Flows/Converters as just computed).
times.push(spec.start + i * spec.dt)
for (const s of stocks) series.get(s.id)?.push(stockValues.get(s.id) ?? 0)
for (const node of order) series.get(node.id)?.push(computed.get(node.id) ?? 0)
if (i >= steps) break
// 2. Integrate every Stock simultaneously (forward Euler) — but with
// non-negative stocks: an outflow can't drain more than its source holds this
// step. Scale a stock's competing outflows together if they would overdraw,
// and apply the scaled rate to both ends so quantity is conserved. You can't
// infect more people than are susceptible — and that floor is exactly what
// stops a bilinear model (S × I) from flipping sign and diverging.
const rate = new Map<string, number>(flows.map((f) => [f.id, computed.get(f.id) ?? 0]))
for (const s of stocks) {
const available = stockValues.get(s.id) ?? 0
const drains = flows.filter((f) => f.source === s.id && (rate.get(f.id) ?? 0) > 0)
const totalOut = drains.reduce((sum, f) => sum + (rate.get(f.id) ?? 0), 0)
if (totalOut * spec.dt > available) {
const scale = available / (totalOut * spec.dt)
for (const f of drains) rate.set(f.id, (rate.get(f.id) ?? 0) * scale)
}
}
const next = new Map(stockValues)
for (const s of stocks) {
let net = 0
for (const flow of flows) {
const r = rate.get(flow.id) ?? 0
if (flow.target === s.id) net += r // an inflow fills it
if (flow.source === s.id) net -= r // an outflow drains it
}
next.set(s.id, (stockValues.get(s.id) ?? 0) + spec.dt * net)
}
for (const [id, value] of next) stockValues.set(id, value)
}
return { times, series, diverged }
}
/**
* What stands between this Model and a run, as human-readable lines (empty array
* = ready). Distinct from structural validity (validation.ts): a Model can be a
* perfectly valid diagram yet not carry the numbers a simulation needs.
*/
export function checkSimReady(model: Model): string[] {
const problems: string[] = []
for (const node of model.nodes) {
if (node.kind === "stock" && node.initialValue === undefined) {
problems.push(`${node.name} has no initial value.`)
}
if ((node.kind === "flow" || node.kind === "converter") && !node.rule) {
problems.push(`${node.name} has no rule yet.`)
}
}
try {
evaluationOrder(model)
} catch (error) {
if (error instanceof SimulationError) problems.push(error.message)
else throw error
}
return problems
}

View File

@@ -31,6 +31,37 @@ export interface Position {
y: number
}
/**
* How a Flow's rate or a Converter's value is computed — a small fixed
* vocabulary, *not* a free-form formula, so a Model stays valid by construction
* and teaches structure → behaviour (ADR-0004). Operands are read from the
* element's inbound Information Links; Polarity selects each operand's role.
*
* - `constant` — a fixed number; reads nothing. (→ linear Stock change)
* - `proportional` — `factor ×` the product of its `+`-polarity inputs.
* (→ exponential growth/decay)
* - `gap` — `factor × (level target)`, where the `+` input is the
* level and the `` input the target. (→ goal-seeking)
*/
export type Rule =
| { kind: "constant"; value: number }
| { kind: "proportional"; factor: number }
| { kind: "gap"; factor: number }
/**
* The run parameters for a simulation: integrate from `start` to `stop` in steps
* of `dt` (forward Euler). Optional on a Model; absent means the diagram phase or
* a not-yet-simulated Model — the engine falls back to `DEFAULT_SIM_SPEC`.
*/
export interface SimSpec {
start: number
stop: number
dt: number
}
/** Sensible default run when a Model carries no `sim` of its own. */
export const DEFAULT_SIM_SPEC: SimSpec = { start: 0, stop: 100, dt: 1 }
interface BaseNode {
id: string
position: Position
@@ -60,8 +91,8 @@ export interface FlowNode extends BaseNode {
source: string
/** Node id of the Stock or Cloud the Flow feeds into. */
target: string
/** Rate expression, recomputed each instant. Optional in the diagram phase. */
equation?: string
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule
}
/**
@@ -71,8 +102,8 @@ export interface FlowNode extends BaseNode {
export interface ConverterNode extends BaseNode {
kind: "converter"
name: string
/** Expression or constant. Optional in the diagram phase. */
equation?: string
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule
}
/**
@@ -107,4 +138,6 @@ export interface Model {
name: string
nodes: ModelNode[]
infoLinks: InformationLink[]
/** Run parameters for simulation (ADR-0004). Optional; absent → not yet simulated. */
sim?: SimSpec
}

View File

@@ -24,7 +24,15 @@ import {
nextName,
} from "@/model/factory"
import { detectLoops } from "@/model/loops"
import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types"
import type {
ConverterNode,
Model,
ModelNode,
Position,
Rule,
SimSpec,
StockNode,
} from "@/model/types"
import { canConnect, intentFor } from "@/model/validation"
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
@@ -96,6 +104,47 @@ export const useModelStore = defineStore("model", () => {
node.name = name
}
/**
* Set (or clear) a Stock's initial value — the quantity the simulator starts
* from (ADR-0004). `undefined` un-equips it, sending the Model back to "not yet
* simulatable". No-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setInitialValue(id: string, value: number | undefined): void {
const node = findNode(id)
if (!node || node.kind !== "stock" || node.initialValue === value) return
record()
if (value === undefined) delete node.initialValue
else node.initialValue = value
}
/**
* 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.
*/
function setRule(id: string, rule: Rule | undefined): void {
const node = findNode(id)
if (!node || (node.kind !== "flow" && node.kind !== "converter")) return
if (JSON.stringify(node.rule) === JSON.stringify(rule)) return
record()
if (rule === undefined) delete node.rule
else node.rule = rule
}
/** Set the run parameters (start / stop / dt). No-op when unchanged. */
function setSimSpec(spec: SimSpec): void {
const current = model.value.sim
if (
current &&
current.start === spec.start &&
current.stop === spec.stop &&
current.dt === spec.dt
) {
return
}
record()
model.value.sim = spec
}
/**
* Create whatever source→target means under the structure guard: an
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
@@ -229,6 +278,9 @@ export const useModelStore = defineStore("model", () => {
beginInteraction,
moveNode,
renameNode,
setInitialValue,
setRule,
setSimSpec,
removeNode,
removeInfoLink,
toggleLinkPolarity,