diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1e70a7a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,105 @@ +# meadows + +The ubiquitous language for **meadows** — a web app for building systems-thinking +models (stock-and-flow diagrams with feedback loops) in the spirit of Donella +Meadows' *Thinking in Systems*. The diagram is the product now; numeric simulation +is a planned later phase, so the language is chosen to be simulation-ready. + +## Language + +**System**: +The real-world thing being studied — an interconnected set of elements producing +a behaviour over time. It is the *referent*, never the artifact in the app. +_Avoid_: model, diagram, map (those are the artifact, not the real thing). + +**Model**: +One saved document — a simplified representation of a System, made of Stocks, +Flows, Converters, and Information Links. The unit of save / export / reopen. +_Avoid_: system, diagram, map, canvas, sheet, project. + +**Stock**: +An accumulation that holds a quantity over time — the system's memory (e.g. water +in a bathtub, money in an account, a population). +_Avoid_: Box, level, container, node. + +**Flow**: +A rate that moves quantity into (**inflow**) or out of (**outflow**) a Stock — +the only element that can change a Stock's value; drawn as a pipe with a valve. +Inflow/outflow is one Flow's direction relative to a given Stock, not a separate +element type (a stock→stock Flow is an outflow for one and an inflow for the +other). _Avoid_: faucet, drain, pipe, rate (that's the Flow's value), arrow. + +**Converter**: +A stateless helper value, recomputed each instant from its inputs (or a fixed +constant), that feeds Flows or other Converters via Information Links — e.g. +`birth rate = fertility × 0.01`. A constant is a Converter with no inputs. +_Avoid_: variable (Stocks and Flows are variables too), function, auxiliary, +parameter, node. + +**Information Link**: +A connector showing that one element's value influences a Flow's or Converter's +value; carries no quantity, never changes a Stock. _Avoid_: arrow, dependency, +edge, connector. + +**Source** / **Sink**: +The model boundary on an open Flow end — a **Source** is where a Flow originates +outside the model (unlimited supply), a **Sink** is where it disappears. Drawn as +a cloud, auto-rendered when a Flow has no Stock at one end. _Avoid_: cloud (that's +the symbol, not the term), external, environment. + +**Polarity**: +The sign on an Information Link (and inherent to a Flow): `+` when more cause +means more effect, `−` when more cause means less effect. The input that lets +loops be classified. _Avoid_: sign, direction (direction is inflow/outflow), +weight. + +**Feedback Loop**: +A closed cycle in the wiring (Stock → … → Flow → same Stock); **detected** from +structure, never stored or hand-drawn. Classified by the product of polarities +around the cycle. _Avoid_: cycle (internal term), circle, circuit. + +**Reinforcing**: +A Feedback Loop with an even number of `−` polarities — it amplifies change (the +snowball); labelled `R`. _Avoid_: positive, self-reinforcing, vicious/virtuous. + +**Balancing**: +A Feedback Loop with an odd number of `−` polarities — it counteracts change and +seeks a goal (the thermostat); labelled `B`. _Avoid_: negative, stabilizing, +goal-seeking. + +## Relationships + +- A **Stock** is the only stateful element — it accumulates (∫ flows) and has + memory; it changes **only** through **Flows**. +- A **Flow** and a **Converter** are stateless — recomputed each instant from + their inputs; neither has memory. +- An **Information Link** feeds a **Stock**, **Flow**, or **Converter** value + into a **Flow** or **Converter** (never into a Stock — Stocks change only via + Flows), and carries a **Polarity**. +- A **Feedback Loop** is a detected cycle in the wiring; it is **Reinforcing** + when it has an even number of `−` polarities, **Balancing** when odd. + +## Example dialogue + +> **Dev:** "For a population model, **Population** is a **Stock**, and **births** +> is a **Flow** into it — an inflow. Where do the births come from?" +> **Modeller:** "From outside the model — so the Flow's other end is a **Source** +> cloud. Same with **deaths**: an outflow from Population to a **Sink**." +> **Dev:** "And births depend on how many people there are?" +> **Modeller:** "Right — an **Information Link** from Population to the births +> Flow, with `+` **Polarity**: more people, more births. That closes a cycle, so +> the app detects a **Feedback Loop**. Even number of `−` links — zero here — so +> it's **Reinforcing**." +> **Dev:** "And `birth rate = fertility × 0.01`?" +> **Modeller:** "That's a **Converter** — stateless, recomputed each step. The +> Population Stock is the only thing that remembers." + +## Flagged ambiguities + +- "arrow" was used for two distinct things — resolved into **Flow** (moves + material, changes a Stock) and **Information Link** (carries influence only). +- "function" was the user's word for the stateless helper — resolved to + **Converter** (avoids collision with programming "function" and with the + stateful Stock). +- "positive / negative feedback" — resolved to **Reinforcing** / **Balancing** + (Meadows' preferred terms; `+/−` is reserved for link **Polarity**). diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..31a6e60 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,172 @@ +# meadows — Design (QFD) + +Goal-driven design for the **meadows** editor, phase 1 (diagram; numeric +simulation is a later phase). Scope: data model, canvas/rendering, interaction +model, and persistence for building stock-and-flow **Models**. It relies on +[CONTEXT.md](./CONTEXT.md) for vocabulary and +[ADR-0001](./docs/adr/0001-detected-feedback-loops.md) (loops detected from a +polarity-annotated graph, not stored). "Simulation-ready" is a constraint here, +not a goal: nothing in the data model should need a rewrite when simulation lands. + +Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none. + +--- + +## 1. Goals — the WHATs + +| ID | Goal | Weight | Source | +|-----|-----------------------------------------------|:------:|---------------------------------| +| G1 | Build a model with near-zero friction | 9 | original request; CONTEXT.md | +| G2 | Feedback structure surfaces as insight (live) | 8 | original request; ADR-0001 | +| G3 | Models are safe and portable | 7 | original request | + +## 2. Functions — the HOWs + +| ID | Function | Dir | Target (now) | Target (future) | +|-----|----------------------------------------------------------------------|:---:|--------------------------------------|------------------------| +| F1 | Place a node in one gesture (palette → canvas, auto-named) | ↓ | 1 click/drag, 0 dialogs | — | +| F2 | Connect by direct manipulation (auto source/sink cloud on open end) | ↓ | 1 drag, 0 menus | — | +| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements | +| F4 | Enforce valid structure inline | → | 0 invalid models persistable | — | +| F5 | Detect & classify loops live (R/B) on every structural change | ↓ | ≤16 ms recompute @ ≤150 elements | ≤16 ms @ ≥300 | +| F6 | Capture link polarity frictionlessly | ↓ | default on create, 1-click toggle | — | +| F7 | Autosave locally, continuously | → | debounced ≤500 ms; survive reload | — | +| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations | +| F9 | Undo/redo every model change | ↑ | ≥50 steps, all structural ops | — | + +## 3. Cascade — Goals → Functions → How → Components + +- **G1** Build a model with near-zero friction _W:9_ + - **F1** Place a node in one gesture _↓ 1 click/drag_ + - **How**: Vue Flow drag-from-palette → store action creates the node + - **Component**: C1 Vue Flow canvas · C2 custom node components · C6 projection + - **F2** Connect by direct manipulation (auto cloud on open end) _↓ 1 drag_ + - **How**: drag from a node handle; open end auto-spawns a Cloud + - **Component**: C2 node handles · C3 edge components · C5 store actions · C7 structure guard + - **F3** Render & manipulate smoothly _↑ 60 fps @ ≤150_ + - **How**: Vue Flow viewport (GPU transforms); Model→VueFlow projection memoised + - **Component**: C1 Vue Flow canvas · C6 projection + - **F9** Undo/redo _↑ ≥50 steps_ + - **How**: ring buffer of deep-cloned Model snapshots in the store + - **Component**: C5 model store (history) +- **G2** Feedback structure surfaces as insight (live) _W:8_ + - **F5** Detect & classify loops live _↓ ≤16 ms @ ≤150_ + - **How**: derive directed graph → Tarjan SCC → capped Johnson → polarity product (R/B) + - **How**: SCC-only — _rejected (loses per-loop insight), see T3_ + - **Component**: C8 loop engine · C11 loop overlay + - **F6** Capture link polarity frictionlessly _↓ default + toggle_ + - **How**: InfoLink defaults to `+` on create; 1-click `+/−` toggle on the edge badge + - **Component**: C3 edge components · C5 store actions + - **F4** Enforce valid structure inline _→ 0 invalid persistable_ + - **How**: guard *guides* (disallow invalid handle targets) rather than erroring after + - **Component**: C7 structure guard · C4 domain types +- **G3** Models are safe and portable _W:7_ + - **F7** Autosave locally, continuously _→ ≤500 ms debounce_ + - **How**: store subscribes to Model changes → debounced `repository.save()` + - **Component**: C5 store · C9 ModelRepository (IndexedDB/idb) + - **F8** Round-trip Models as versioned JSON _→ lossless_ + - **How**: serialise Model (`version` field) → download; import → validate → load + - **How**: PouchDB/cloud sync — _deferred (needs a backend), see Tensions_ + - **Component**: C10 import/export · C4 domain types + - **F9** (also serves G3 as a safety net) → C5 model store + +## 4. House — Goals × Functions + +Cells: strength 9/3/1. Importance Σ = Σ(weight × strength). + +| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | +|----------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:| +| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 | +| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 | +| G3 (7) | | | | 1 | | | 9 | 9 | 9 | +| **Σ** | 81 | 105| 89 | 58 | 81 | 99 | 72 | 63 | 98 | + +**Top engineering priorities (from importance):** the matrix puts **F2 (connect, 105)**, **F6 (polarity, 99)** and **F9 (undo, 98)** above the headline detector **F5 (81)**. The lesson: loop insight is only as good as its *inputs* and *ergonomics* — getting wiring (F2) and polarity capture (F6) effortless matters as much as the detection itself, and a reliable safety net (F9) underwrites fearless building. F4 (58) is real but lowest-leverage — keep it lightweight (guide, don't nag). + +## 5. Roof — Function × Function tradeoffs + +`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict. + +| | F2 | F3 | F4 | F5 | F6 | F9 | +|--------|:--:|:--:|:--:|:--:|:--:|:--:| +| **F2** | — | | × | | ◎ | | +| **F3** | | — | | ⊗ | | × | +| **F4** | | | — | ○ | | | +| **F5** | | | | — | ◎ | | +| **F6** | | | | | — | | +| **F9** | | | | | | — | + +**Conflicts that actually shape the design:** +- **F5 ⊗ F3** — live loop enumeration on every change vs the 60 fps frame budget. The design-shaping conflict. Mitigated by the **cap** (≤200 cycles) + running detection **off the render frame** (debounce / idle callback / worker). Owned by ADR-0001 + the §7 budget. +- **F2 × F4** — guardrails can add friction to connecting. Mitigated by *guiding* the gesture (invalid handles refuse the drop) instead of erroring afterwards. +- **F3 × F9** — snapshot cloning on every change costs memory/time. Bounded by small models + a fixed-size ring buffer; revisit with structural sharing only if profiling demands. +- **F2 ◎ F6, F5 ◎ F6** — polarity is captured *in* the connect gesture and *is* the detector's input; these reinforce, so build them together. + +## 6. Components & Function → Component map + +| ID | Component | ADR | +|-----|-----------------------------------------------------|----------| +| C1 | Vue Flow canvas (`@vue-flow/core` + addons) | ADR-0002 | +| C2 | Custom node components (Stock/Flow/Converter/Cloud) | ADR-0002 | +| C3 | Custom edge components (InfoLink badge, Flow pipe) | ADR-0002 | +| C4 | Domain `Model` + types (`model/types.ts`) | ADR-0003 | +| C5 | Model store (Pinia) + snapshot history | — | +| C6 | Model ↔ Vue Flow projection (`projection.ts`) | ADR-0002 | +| C7 | Structure guard (`validation.ts`) | ADR-0003 | +| C8 | Loop engine (`loops.ts`: SCC + capped Johnson) | ADR-0001 | +| C9 | `ModelRepository` (IndexedDB via `idb`) | — | +| C10 | Import/Export (versioned JSON `io.ts`) | — | +| C11 | Loop overlay UI (R/B badges, hover highlight) | ADR-0001 | +| C12 | Palette + chrome (DaisyUI toolbar, model list) | — | + +| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | +|-----|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:---:|:---:|:---:| +| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 | +| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | | +| F3 | 9 | 3 | 3 | | | 9 | | | | | | | +| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | | +| F5 | | | | 3 | 1 | | | 9 | | | 9 | | +| F6 | | | 9 | 3 | 3 | | | 1 | | | | | +| F7 | | | | | 9 | | | | 9 | | | | +| F8 | | | | 3 | | | | | 1 | 9 | | | +| F9 | | | | | 9 | | | | | | | | + +## 7. Critical performance budget + +| Rank | Function | Target | Watched on | If we miss it | +|------|----------|----------------------------|-------------------------------------|-----------------------------------------------------------| +| 1 | F5 | ≤16 ms recompute @ ≤150 | bench on synthetic dense graphs | enforce cycle cap; move detection off-frame (idle/worker); debounce | +| 2 | F3 | 60 fps pan/zoom/drag @ ≤150| frame profiling in DevTools | memoise projection; simplify node DOM; virtualise off-screen nodes | +| 3 | F2 | 1 drag, 0 menus | manual UX walkthrough | simplify handle model; reduce connect steps | +| 4 | F7 | ≤500 ms debounce, 0 loss | reload-after-edit integration test | flush on `visibilitychange`/`beforeunload` | +| 5 | F8 | export→import identity | property test (round-trip) | pin schema; add migration on `version` bump | + +## 8. Tradeoffs — Got / Paid / ADR + +| ID | Tradeoff | Got | Paid | ADR | +|-----|--------------------------------------------|--------------------------------------------------|-----------------------------------------------------|----------| +| T1 | Vue Flow over hand-rolled SVG | fast F1–F3, batteries-included editor mechanics | a dependency owning the view layer + a Model↔VueFlow projection to maintain | ADR-0002 | +| T2 | Flow-as-node + materialised Clouds | uniform graph for loops/render; info-links can target flows; sim-clean | Cloud lifecycle management; a node that renders as a pipe | ADR-0003 | +| T3 | Enumerate loops with a cap | per-loop R/B insight (G2) within the frame budget | capped completeness on pathological graphs ("+N more") | ADR-0001 | +| T4 | IndexedDB(`idb`) behind `ModelRepository` | size headroom + swappable engine (sync later) | async wrapper + a small dependency; no sync today | — | +| T5 | Snapshot undo over command pattern | simplicity and correctness | memory per snapshot (bounded by ring buffer + small models) | — | + +### Tensions being watched (unresolved by design) + +- **Cross-device sync / sharing.** Not built; G3 is met by local persistence + JSON export. **Trigger to revisit:** a real multi-device or collaboration need → add a `PouchdbModelRepository` or API-backed repository behind the existing interface. +- **Auto-layout.** Manual placement only in phase 1. **Trigger to revisit:** users report large models becoming unreadable → add an optional layout pass (e.g. ELK/dagre) over the projection. + +## 9. Inconsistencies spotted and fixed + +- **Brief vocabulary vs glossary.** The original request said "functions," "arrows," "faucets"; DESIGN uses the resolved CONTEXT.md terms verbatim — **Converter**, **Flow**/**Information Link**, faucet dropped. +- **G3 ambiguity.** "Safe and portable" initially read as possible cloud sync; clarified — G3 is satisfied by **local persistence + JSON export**, not sync (PouchDB deferred, see Tensions). +- **Storage default drift.** Initial recommendation was localStorage; resolved to **IndexedDB via `idb`**, behind a `ModelRepository` interface so the engine choice is no longer hard-to-reverse. + +--- + +## How to keep this honest + +- When a new ADR lands → add its components to §6 and re-score affected rows. +- When a spike / measurement returns numbers → update §7 `Target` / `Watched on`. +- WHATs change rarely; HOWs change with each release; matrices are recomputed when either side changes. +- If a section becomes empty after edits, delete it — empty sections lie. diff --git a/docs/adr/0001-detected-feedback-loops.md b/docs/adr/0001-detected-feedback-loops.md new file mode 100644 index 0000000..3cee29e --- /dev/null +++ b/docs/adr/0001-detected-feedback-loops.md @@ -0,0 +1,26 @@ +# Feedback loops are detected from a polarity-annotated graph, not stored + +A **Feedback Loop** in a **Model** is a cycle in the wiring (Stock → Information +Link(s)/Flow → same Stock). We chose to **derive** loops by detecting cycles and +classifying each as **Reinforcing** (even number of `−` polarities) or +**Balancing** (odd), rather than storing them as first-class objects the user +hand-labels. This makes loops *discovered insight* that can never disagree with +the actual structure — the point of a systems-thinking tool. + +## Considered Options + +- **Hand-labeled annotation** — user drops a loop badge and tags it R/B. Simplest, + but the label carries no structural truth and can contradict the wiring. +- **Hybrid** — app detects the cycle exists; user assigns R/B. Removes the + inconsistency risk only partially and makes the user do reasoning the app can do. +- **Detected from structure (chosen)** — app finds cycles and classifies them. + +## Consequences + +- Every **Information Link** must carry a **Polarity** (`+`/`−`); inflows/outflows + carry inherent polarity. This is a required field from the diagram phase on. +- There is **no `Loop` entity** in the data model — loops are computed, not + persisted. A future reader seeing polarity on every link and no stored loop + should look here. +- Requires cycle-detection + sign-product logic. The same polarity data is + reused when numeric simulation is added later. diff --git a/docs/adr/0002-vue-flow-substrate.md b/docs/adr/0002-vue-flow-substrate.md new file mode 100644 index 0000000..7baa7a6 --- /dev/null +++ b/docs/adr/0002-vue-flow-substrate.md @@ -0,0 +1,22 @@ +# Vue Flow is the editor substrate; the domain Model is the source of truth + +The editor is built on **Vue Flow** (`@vue-flow/core`) for pan/zoom, node +dragging, handles, edge-drawing, and selection, with Stocks/Flows/Converters/ +Clouds as custom Vue node components. We rejected hand-rolling an SVG editor (too +slow to the friction goals F1–F3) and heavier kits (AntV X6, mxGraph — not +Vue-3-native). + +The constraint that makes this safe: the **domain `Model` is the single source of +truth; the Vue Flow graph is a derived projection of it, never the reverse.** All +writes go through store actions that mutate the `Model`; the Vue Flow view is +re-derived. The future simulator reads the `Model` and has no knowledge of Vue +Flow. + +## Consequences + +- A **projection layer** (Model → Vue Flow nodes/edges, and Vue Flow events → + store actions) must be built and kept thin. This is the price of the dependency. +- Swapping the rendering substrate later means rewriting only the projection + + node/edge components, not the domain model or the simulator. +- A reader wondering "why is there a sync/projection layer instead of editing Vue + Flow state directly?" should look here: it protects the simulate-later constraint. diff --git a/docs/adr/0003-flow-as-node-materialised-clouds.md b/docs/adr/0003-flow-as-node-materialised-clouds.md new file mode 100644 index 0000000..b5f1f8b --- /dev/null +++ b/docs/adr/0003-flow-as-node-materialised-clouds.md @@ -0,0 +1,20 @@ +# A Flow is a node; Source/Sink clouds are materialised nodes + +In the domain `Model`, a **Flow** is a *node* carrying `source` and `target` +references (each to a Stock or Cloud), not an edge. The pipe you see is *rendered* +from those references. The **Source/Sink** boundary is a **materialised Cloud +node**, so every Flow connects node→node uniformly — no `null` ends. + +We rejected modelling a Flow as an edge (it breaks "an Information Link targets a +Flow," since edges can't be edge endpoints, and it muddies the simulator) and +rejected `null`-ended flows (special-casing in every graph walk and in rendering). + +## Consequences + +- The only stored edge type is the **Information Link**. Flows and Clouds are + nodes. A reader seeing a "node that renders as a pipe" and "phantom cloud nodes" + should look here. +- **Cloud lifecycle** must be auto-managed: spawn a Cloud when a Flow is left + open-ended; remove it when the Flow attaches to a Stock; never leave orphans. +- Loop detection and the future simulator both walk a uniform node graph + (info-links + flow→stock edges), with no boundary special cases.