Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [design/2026-02-21-incremental-compilation.md](design/2026-02-21-incremental-compilation.md) -- Historical design record of the salsa pipeline: symbolic bytecode, per-variable tracking, LTM integration (the pipeline as it stands is in `src/simlin-engine/CLAUDE.md` and the compiler-unification plan)
- [design/conveyors.md](design/conveyors.md) -- XMILE conveyor support: complete specification of syntax, per-DT simulation semantics, leakage/initialization formulas, spread inputs, arrays, and engine integration
- [design/engine-performance.md](design/engine-performance.md) -- Engine compile/simulate profile (C-LEARN), how to measure a change (the instruction, LTM, artifact, sweep and dump channels), implemented optimizations, and remaining proposals
- [design/layout-quality.md](design/layout-quality.md) -- Diagram layout quality: the rate-based metric over the drawn scene (terms, weights, defect overlays, calibration against taste checks and visual judgments), the on-demand eval harness (graded corpus, production timing, taste battery, run comparison), and the improvement loop
- [design/ltm--loops-that-matter.md](design/ltm--loops-that-matter.md) -- LTM implementation design: data structures, synthetic variables, module handling, array/element-level support, and post-simulation loop discovery (candidate generation, retention against the loop universe, ranking and the coverage-aware cap)
- [design/ltm-always-on.md](design/ltm-always-on.md) -- Proposed always-on LTM constraints: faithful counterfactuals, immutable run analysis, completeness and numerical validity, cheaper instrumentation and storage, bounded discovery, and acceptance evidence
- [design/mdl-parser.md](design/mdl-parser.md) -- Vensim MDL parser design history and implementation notes
Expand Down
177 changes: 177 additions & 0 deletions docs/design/layout-quality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Layout quality: the metric and the eval harness

Simlin generates stock-and-flow diagrams for models that have none: an agent
building a model over MCP, a notebook user patching a model from Python, an
imported equation file. This document describes how diagram quality is
measured -- the layout-quality metric (`src/simlin-engine/src/layout/metrics.rs`),
its taste checks (`layout/taste.rs`), and the on-demand eval harness
(`src/simlin-engine/examples/layout_eval/`) -- and the loop for improving the
layout algorithm against them.

## The metric

`compute_layout_metrics(view)` scores a diagram as a vector of terms, each `0`
when ideal, and `weighted_cost` collapses them with `MetricWeights::default()`.
`generate_best_layout` picks the cheapest of several seeds, so the metric is
production code, not just an evaluation tool.

### The scene

Every term is computed over the geometry the renderer actually draws: node
shapes at their drawn size (a flow valve is the 9px circle `render_flow` draws),
flow pipes as 4px-thick segments, links as the exact polylines
`diagram::connector` draws (arcs sampled along their circle), and labels at the
boxes `diagram::label` measures. The declutter pass (`layout/declutter.rs`)
avoids the same obstacles the metric charges: it pushes apart the footprints
the metric finds overlapping, and chooses each label's side by the metric's own
charge for that label (`metrics::LabelScene`), so what the optimizer removes is
what the score counts.

### Terms

The defect terms are RATES (means over the nodes, labels, or connectors they
concern). A model's cost therefore does not grow with its size, one defect costs
`weight / n` in a model of `n` things, the trade-off between terms is the same
for a 10-variable model as for a 300-variable one, and the corpus aggregate is
not dominated by the largest models.

| term | measures | weight |
|---|---|---|
| `node_overlap` | mean covered fraction of each node's shape by other shapes | 3.5 |
| `label_overlap` | mean covered fraction of each label by other labels and shapes | 3.5 |
| `node_connector_overlap` | fraction of connector length under non-incident shapes or pipes (a false causal link) | 2.0 |
| `label_connector_overlap` | mean over labels of connector length -- links and other flows' pipes -- through the text, relative to the box's smaller side; a node's own links count half | 1.5 |
| `crossings` | connector crossings per connector | 1.0 |
| `crowding` | clearance deficits `(1 - gap/8px)^2` between non-cloud footprints per node, plus links too short to show their arrow per link | 1.0 |
| `long_connectors` | mean excess of links beyond 3x the median link length | 0.5 |
| `sprawl` | mean connector length over characteristic node size | 0.25 |
| `loop_compactness` | isoperimetric penalty of feedback-loop polygons | 0.4 |
| `flow_bends` | bends per flow pipe | 0.15 |
| `misalignment` | fraction of nodes sharing no row or column with a nearby node | 0.1 |
| `loop_straightness` | bow shortfall of loop connectors | 0.1 |
| `edge_length_cv`, `aspect_penalty` | reported, unweighted | 0 |

`crowding` and `sprawl` pull in opposite directions and together set a finite
optimum spacing: spread until neighbors have air, no further. A flow and the
stock or cloud its pipe attaches to are joined by construction, so their SHAPES
being close is exempt from crowding and from the declutter's overlap check --
their labels are not.

### Defects

`analyze_layout(view)` returns the metrics together with every defect behind
them, located on the diagram. It runs the same code as
`compute_layout_metrics` with a recording sink, so an overlay drawn from the
defects can never disagree with the score.

### Calibration

A weight is only as good as the judgments it reproduces. The weights are
checked against two kinds of judged pairs:

- **Taste checks.** `layout::taste::degrade` applies edits every modeler would
call regressions -- crowd the diagram (`Cramp`), spread it (`Inflate`),
scatter free nodes (`Jitter`, `Shuffle`), park the most-used parameter across
the diagram (`Exile`), drop one node on another (`Stack`), flatten curved
links (`StraightenLinks`) -- moving only what a person drags by hand and
keeping each link's bow. The metric must charge each. The unit test
`test_metric_penalizes_every_degradation_of_the_exemplars` pins this on the
shipped default projects; the harness runs the battery over every corpus
reference and production layout.
- **Visual judgments.** Reference-versus-production pairs where a person, looking
at the renders, finds one clearly better. The reference-pair unit tests pin
the default projects' hand-drawn diagrams beating a generated layout.

The committed weights are rounded priors that a log-space fit against those
pairs (squared hinge on a 2% margin, anchored at `crossings = 1`) moved by under
15%. Judgments the terms cannot reproduce at any weights point at missing
terms, not at weights -- today that is structural taste: chains laid out in
rows, parameters beside their consumers, the arrangement that makes a
hand-drawn diagram read as organized rather than merely uncluttered.
`StraightenLinks` is deliberately not required: flattening a non-loop link is
style, and only loops care.

## The eval harness

```
cargo run --release -p simlin-engine --features png_render,file_io --example layout_eval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enable the required layout_eval feature

The documented command cannot launch this example because src/simlin-engine/Cargo.toml declares the target's required-features as png_render, file_io, and layout_eval, while this command activates only the first two. Cargo's run --help confirms that --features selects the features to activate, so users following the new harness documentation will be told the target is unavailable; add layout_eval to the feature list here and in the duplicate usage text.

AGENTS.md reference: AGENTS.md:L122-L122

Useful? React with 馃憤聽/ 馃憥.

```

Knobs (environment variables): `LAYOUT_EVAL_MODELS` (corpus keys),
`LAYOUT_EVAL_TIERS` (`small,medium,large`), `LAYOUT_EVAL_EXTRA` (`key=path` ad
hoc models), `LAYOUT_EVAL_SEEDS` (default 25), `LAYOUT_EVAL_OUT` (default
`target/layout-eval`), `LAYOUT_EVAL_COMPARE` (a previous run's output dir),
`LAYOUT_EVAL_WRITE_BASELINE`, `LAYOUT_EVAL_DECLUTTER=0`,
`LAYOUT_EVAL_REPLAY_STEPS` (0 skips the replay).

For each corpus model it:

- sweeps the seeds, scoring each layout (the algorithm's quality distribution,
summarized benchstat-style in `layout::eval_stats`);
- runs `generate_best_layout` once, timed -- the layout a user gets and what it
costs;
- replays building the model from empty over a few edits
(`LAYOUT_EVAL_REPLAY_STEPS`, default 4) -- each stock-flow chain arriving
whole, then the other variables nearest the backbone first -- syncing the
diagram after every edit the way MCP `edit_model` and pysimlin's patch sync
do (`generate_best_layout` while the view is empty, `incremental_layout`
after), and scores the final diagram: what an agent or notebook user ends up
looking at, which can differ sharply from a fresh layout because incremental
layout preserves everything already placed;
- renders the hand-drawn reference, the production and incremental layouts,
and the median and worst seeds to PNG (small diagrams upscaled so labels are
legible), with `*_defects.png` overlays for the reference, production, and
incremental diagrams and a `*.view.json` of every rendered view;
- runs the taste battery on the reference and the production layout.

It writes `metrics.json` (per-term breakdowns, timings, taste checks),
`corpus.json` (per-seed samples), and `index.html` (a contact sheet plus the
corpus-wide taste matrix).

### The corpus and its references

`examples/layout_eval/corpus.rs` grades how far each model's shipped diagram can
be trusted:

- **Curated**: one view authored in Stella or Simlin, whose drawing conventions
the renderer reproduces; its score is directly comparable to a generated
layout's.
- **Imported**: one Vensim view. Its arrangement is a trustworthy exemplar, but
Vensim draws a variable as its wrapped name, so the label geometry our renderer
imposes is not what the author saw; label-dependent terms over it are not
comparable.
- **MultiView**: several views the importer stacks into one diagram with group
boxes -- an exemplar of decomposing a large model, not one comparable diagram.
- **None**: no shipped diagram.

The corpus spans textbook models, the default projects, AI-built models, module
and array models, and large published models, in three size tiers.

### Comparing runs

`LAYOUT_EVAL_COMPARE=<dir>` diffs a run against an earlier run's `corpus.json`,
re-scored under the current weights: per-model Mann-Whitney verdicts over the
seed samples, and a paired Wilcoxon signed-rank test over the models' shifted-log
median ratios for the aggregate. The contact sheet shows the earlier run's term
values beside the current ones. The committed baseline
(`examples/layout_eval_baseline.json`, see its README) is diffed the same way on
every run.

## The improvement loop

1. Run the harness on the current code into one directory, and on the changed
code into another with `LAYOUT_EVAL_COMPARE` pointing at the first.
2. Read the verdicts and per-term deltas, and the production timings.
3. Look at the production renders and their defect overlays. A defect the eye
finds that no mark covers is a blind spot in the metric; a mark over
something that reads fine is a false positive. Either means the metric needs
work before its number can be trusted.
4. Keep a change only when the numbers improve AND the pictures do. A change
that improves the number while the picture gets worse means the metric is
wrong, not the diagram.
5. After landing an improvement, re-seed the committed baseline.

The hand-drawn references are ground truth for arrangement. A generated layout
scoring better than an exemplary Curated reference is a prompt to look, not a
win: either the generator truly beat the author, or the metric is missing what
the author got right.
2 changes: 1 addition & 1 deletion src/simlin-engine/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Opt-in: a model that declares units on no variable gets no unit diagnostics. `un
## Analysis, layout, diagrams

- `analysis.rs::analyze_model` bundles compilation, LTM discovery, and dominant-period selection into `ModelAnalysis`; a model that cannot compile returns `Ok` with `analysis_error` set so "could not analyze" is distinct from "no loops".
- `layout/` generates and incrementally updates diagram layouts (force-directed placement, crossing reduction, a calibrated quality metric; deterministic per seed). Incremental layout never rewrites an element the patch did not touch: position and `label_side` come back byte for byte (`layout_label_tests.rs` enumerates the arms), a label side is chosen only for elements created in that pass, and a new connector that runs through an existing label is accepted rather than re-optimizing its neighbours -- hand placement wins, and re-optimizing is exactly the churn that snaps a notebook user's dragged label somewhere else on the next edit. A label the layout wraps carries the stored two-character `\n` escape (`text::LABEL_LINE_BREAK`, the form the TypeScript editor's `encodeNameNewlines` produces), never a raw newline.
- `layout/` generates and incrementally updates diagram layouts (force-directed placement, crossing reduction, a calibrated quality metric; deterministic per seed). The metric and the eval harness that measures layouts against it are described in [layout quality](/docs/design/layout-quality.md). The incremental path lives in `layout/incremental.rs`: chains an edit adds whole are laid out as chains beside the diagram, a new stock hung off a drawn chain continues its row, and what the edit added is decluttered around the fixed diagram (`declutter::declutter_part`). Incremental layout never rewrites an element the patch did not touch: position and `label_side` come back byte for byte (`layout_label_tests.rs` enumerates the arms), a label side is chosen only for elements created in that pass, and a new connector that runs through an existing label is accepted rather than re-optimizing its neighbours -- hand placement wins, and re-optimizing is exactly the churn that snaps a notebook user's dragged label somewhere else on the next edit. A label the layout wraps carries the stored two-character `\n` escape (`text::LABEL_LINE_BREAK`, the form the TypeScript editor's `encodeNameNewlines` produces), never a raw newline.
- `diagram/` renders SVG/PNG and exposes the exact geometry the layout metric scores.

## Tests
Expand Down
Loading
Loading