diff --git a/docs/README.md b/docs/README.md index 8c6eb514e..7af92c730 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/design/layout-quality.md b/docs/design/layout-quality.md new file mode 100644 index 000000000..44be2ad5b --- /dev/null +++ b/docs/design/layout-quality.md @@ -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 +``` + +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=` 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. diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index b23179b6e..dddf5240a 100644 --- a/src/simlin-engine/CLAUDE.md +++ b/src/simlin-engine/CLAUDE.md @@ -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 diff --git a/src/simlin-engine/examples/layout_eval.rs b/src/simlin-engine/examples/layout_eval.rs deleted file mode 100644 index f7349fcf7..000000000 --- a/src/simlin-engine/examples/layout_eval.rs +++ /dev/null @@ -1,1235 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -//! Layout-quality evaluation sweep (on-demand; NOT part of `cargo test`). -//! -//! Lays out a curated corpus of models across many seeds, scores each layout -//! with the layout-quality metric, renders best/median/worst (and any -//! hand-authored reference) to PNG, and writes a metrics table (JSON), an HTML -//! contact-sheet, and a baseline diff -- all under a gitignored `target/` dir. -//! -//! This is a thin imperative shell over the metric core -//! (`layout::metrics::compute_layout_metrics`) and the statistics core -//! (`layout::eval_stats`). It loads each model via the public `open_xmile` / -//! `open_vensim` loaders (like `examples/backend_bench.rs`), runs -//! `generate_layout_with_config` per seed, scores, summarizes, renders, and -//! emits artifacts. -//! -//! Usage: -//! cargo run --release -p simlin-engine --features png_render,file_io --example layout_eval -//! LAYOUT_EVAL_MODELS=teacup,sir cargo run ... --example layout_eval -//! -//! Env knobs: -//! LAYOUT_EVAL_MODELS comma list of corpus keys to run (default: all) -//! LAYOUT_EVAL_SEEDS number of seeds M to sample (default: 25) -//! LAYOUT_EVAL_OUT output directory (default: repo-root target/layout-eval) -//! LAYOUT_EVAL_WRITE_BASELINE 1 -> write this run's report to the committed -//! baseline JSON (see below) instead of diffing. -//! -//! Baseline diff: a committed `examples/layout_eval_baseline.json` (a serialized -//! `CorpusReport`) records a reference run. A normal run reads it back, runs -//! `compare(baseline, candidate)`, and embeds the per-model + aggregate deltas -//! (with Mann-Whitney U p-values / significance verdicts) into `metrics.json` -//! and the `index.html` header. With `LAYOUT_EVAL_WRITE_BASELINE=1` the run -//! instead overwrites that baseline file (re-seed it after the metric weights -//! change). If the file is absent a normal run skips the diff with a note. -//! -//! Requires `--features png_render,file_io`: `png_render` for `render_png`, and -//! `file_io` so Vensim corpus models that reference external data can load. - -use std::collections::BTreeSet; -use std::env; -use std::fmt::Write as _; -use std::io::BufReader; - -use rayon::prelude::*; -use serde::Serialize; -use simlin_engine::diagram::{PngRenderOpts, render_png}; -use simlin_engine::layout::LAYOUT_SEEDS; -use simlin_engine::layout::config::LayoutConfig; -use simlin_engine::layout::eval_stats::{ - Comparison, CorpusReport, MetricSample, ModelStats, compare, -}; -use simlin_engine::layout::generate_layout_with_config; -use simlin_engine::layout::metrics::{LayoutMetrics, MetricWeights, compute_layout_metrics}; -use simlin_engine::{datamodel, open_vensim, open_xmile}; - -/// The model name the layout pipeline and renderer operate on. `Project::get_model` -/// maps "main" to the single/main model (matching `tests/integration/layout.rs`). -const MAIN_MODEL: &str = "main"; - -/// Default number of seeds to sample per model when `LAYOUT_EVAL_SEEDS` is unset. -const DEFAULT_SEEDS: u64 = 25; - -/// Path (relative to `CARGO_MANIFEST_DIR` = `src/simlin-engine`) of the committed -/// baseline `CorpusReport`. This file lives in the SOURCE TREE by design (it is -/// checked in and diffed against on every normal run), unlike every other -/// artifact, which is written under the gitignored `target/` output dir. -const BASELINE_REL_PATH: &str = "examples/layout_eval_baseline.json"; - -// ── Corpus ───────────────────────────────────────────────────────────────── - -#[derive(Clone, Copy)] -enum Format { - Xmile, - Vensim, -} - -struct ModelSpec { - key: &'static str, - /// Path relative to CARGO_MANIFEST_DIR (src/simlin-engine). - rel_path: &'static str, - format: Format, -} - -use Format::{Vensim, Xmile}; - -/// The curated corpus. Paths are relative to `CARGO_MANIFEST_DIR` -/// (`src/simlin-engine`); every entry is verified to exist on disk and load. -const CORPUS: &[ModelSpec] = &[ - // canonical small - ModelSpec { - key: "teacup", - rel_path: "../../test/test-models/samples/teacup/teacup.stmx", - format: Xmile, - }, - ModelSpec { - key: "sir", - rel_path: "../../test/test-models/samples/SIR/SIR.stmx", - format: Xmile, - }, - ModelSpec { - key: "logistic_growth", - rel_path: "../../test/logistic_growth_ltm/logistic_growth.stmx", - format: Xmile, - }, - // default_projects: the app's curated, hand-laid-out built-in projects. - // These are the primary "good layout" taste anchors for Phase 4 calibration. - ModelSpec { - key: "fishbanks", - rel_path: "../../default_projects/fishbanks/model.xmile", - format: Xmile, - }, - ModelSpec { - key: "dp_logistic_growth", - rel_path: "../../default_projects/logistic-growth/model.xmile", - format: Xmile, - }, - ModelSpec { - key: "population", - rel_path: "../../default_projects/population/model.xmile", - format: Xmile, - }, - ModelSpec { - key: "reliability", - rel_path: "../../default_projects/reliability/model.xmile", - format: Xmile, - }, - // modules - ModelSpec { - key: "hares_and_foxes", - rel_path: "../../test/modules_hares_and_foxes/modules_hares_and_foxes.stmx", - format: Xmile, - }, - // multipoint connectors - ModelSpec { - key: "multipoint", - rel_path: "../../test/test-models/samples/display/multipoint-connection.stmx", - format: Xmile, - }, - // aliases - ModelSpec { - key: "alias1", - rel_path: "../../test/alias1/alias1.stmx", - format: Xmile, - }, - // LTM / loop models - ModelSpec { - key: "cross_element", - rel_path: "../../test/cross_element_ltm/cross_element.stmx", - format: Xmile, - }, - ModelSpec { - key: "arrayed_pop", - rel_path: "../../test/arrayed_population_ltm/arrayed_population.stmx", - format: Xmile, - }, - // ai-information reference set (human vs AI; used by Phase 4 calibration) - ModelSpec { - key: "ai_pure_human", - rel_path: "../../test/ai-information/PureHumanModel.stmx", - format: Xmile, - }, - ModelSpec { - key: "ai_pure_ai", - rel_path: "../../test/ai-information/PureAIModel.stmx", - format: Xmile, - }, - ModelSpec { - key: "ai_edited", - rel_path: "../../test/ai-information/GeneratedByAIThenEdited.stmx", - format: Xmile, - }, - ModelSpec { - key: "ai_modules_arrays", - rel_path: "../../test/ai-information/WithModulesAndArrays.stmx", - format: Xmile, - }, - // large metasd Vensim - ModelSpec { - key: "wrld3_03", - rel_path: "../../test/metasd/WRLD3-03/wrld3-03.mdl", - format: Vensim, - }, - ModelSpec { - key: "beer_game", - rel_path: "../../test/metasd/beer-game/RealBeer4-Sterman13.mdl", - format: Vensim, - }, - ModelSpec { - key: "wonderland", - rel_path: "../../test/metasd/wonderland/Wonderland3.mdl", - format: Vensim, - }, - // multi-view metasd Vensim: hand-authored references that decompose a big - // model into ~25-50-variable views connected by ghost/alias variables. - // These are the primary exemplars for what readable layouts of large - // models look like (and, later, for alias-generation calibration). - ModelSpec { - key: "scirev", - rel_path: "../../test/metasd/scientific-revolution/scirev8.mdl", - format: Vensim, - }, - ModelSpec { - key: "thyroid", - rel_path: "../../test/metasd/thyroid-dynamics/thyroid-2008-d.mdl", - format: Vensim, - }, - ModelSpec { - key: "covid19", - rel_path: "../../test/metasd/covid19-us-homer/homer v8/Covid19US v8.mdl", - format: Vensim, - }, - ModelSpec { - key: "industrial_dynamics", - rel_path: "../../test/metasd/industrial-dynamics/IDch15/IDch15d.mdl", - format: Vensim, - }, -]; - -/// Resolve a corpus-relative path against the crate manifest dir. -fn abs_path(rel: &str) -> String { - format!("{}/{}", env!("CARGO_MANIFEST_DIR"), rel) -} - -/// Load one corpus model, dispatching on its declared format: XMILE through a -/// buffered reader + `open_xmile`, Vensim `.mdl` through a string + `open_vensim` -/// (mirrors `examples/backend_bench.rs`). Returns a human-readable error on any -/// I/O or parse failure so the caller can WARN-and-skip (AC3.6). -fn load_model(spec: &ModelSpec) -> Result { - let path = abs_path(spec.rel_path); - match spec.format { - Format::Xmile => { - let file = - std::fs::File::open(&path).map_err(|e| format!("failed to open {path}: {e}"))?; - let mut reader = BufReader::new(file); - open_xmile(&mut reader).map_err(|e| format!("failed to parse {path}: {e:?}")) - } - Format::Vensim => { - let contents = std::fs::read_to_string(&path) - .map_err(|e| format!("failed to read {path}: {e}"))?; - open_vensim(&contents).map_err(|e| format!("failed to parse {path}: {e:?}")) - } - } -} - -/// Count the view elements in the model's as-loaded main view -- the diagram -/// the later tasks score and render. A model with no hand-authored view yields -/// 0 here (its layout is generated from scratch in Task 2). -fn loaded_element_count(project: &datamodel::Project) -> usize { - reference_view(project) - .map(|sf| sf.elements.len()) - .unwrap_or(0) -} - -/// Borrow the model's as-loaded main `StockFlow` view if it is a hand-authored -/// reference: a non-empty view carrying non-empty `elements`. A model loaded -/// without a saved diagram (its layout is generated from scratch in the sweep) -/// has no such view, so this returns `None` and the caller skips the reference -/// render. -fn reference_view(project: &datamodel::Project) -> Option<&datamodel::StockFlow> { - let model = project.get_model(MAIN_MODEL)?; - match model.views.first() { - Some(datamodel::View::StockFlow(sf)) if !sf.elements.is_empty() => Some(sf), - _ => None, - } -} - -// ── Env knobs ──────────────────────────────────────────────────────────────── - -/// The set of corpus keys to run. `LAYOUT_EVAL_MODELS` is a comma list of keys; -/// unset/empty means the whole corpus. Unknown keys are reported and dropped so -/// a typo does not silently run nothing without explanation. -fn selected_keys() -> Vec<&'static str> { - let Ok(raw) = env::var("LAYOUT_EVAL_MODELS") else { - return CORPUS.iter().map(|s| s.key).collect(); - }; - let requested: Vec<&str> = raw - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .collect(); - if requested.is_empty() { - return CORPUS.iter().map(|s| s.key).collect(); - } - let mut keys = Vec::new(); - for want in requested { - match CORPUS.iter().find(|s| s.key == want) { - Some(spec) => keys.push(spec.key), - None => eprintln!("WARN: unknown model key {want:?}; skipping"), - } - } - keys -} - -/// Number of seeds M to sample per model (`LAYOUT_EVAL_SEEDS`, default 25). -fn seed_count() -> u64 { - env::var("LAYOUT_EVAL_SEEDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_SEEDS) -} - -/// The seeds to sample: the union of the production best-of-k proxy -/// (`LAYOUT_SEEDS`) and `0..m`, deduped and sorted. Including `LAYOUT_SEEDS` -/// guarantees the best-of-k production proxy is always computable regardless of -/// `m`. -fn seed_set(m: u64) -> Vec { - let mut seeds: BTreeSet = (0..m).collect(); - seeds.extend(LAYOUT_SEEDS); - seeds.into_iter().collect() -} - -/// The output directory (`LAYOUT_EVAL_OUT`, default repo-root -/// `target/layout-eval`, derived from `CARGO_MANIFEST_DIR`). -fn out_dir() -> String { - env::var("LAYOUT_EVAL_OUT") - .unwrap_or_else(|_| format!("{}/../../target/layout-eval", env!("CARGO_MANIFEST_DIR"))) -} - -/// Whether to (re)seed the committed baseline instead of diffing against it. -/// True when `LAYOUT_EVAL_WRITE_BASELINE` is set to a truthy value (`1`/`true`, -/// case-insensitive). Any other value -- and an unset variable -- means a normal -/// diffing run. -fn write_baseline_requested() -> bool { - matches!( - env::var("LAYOUT_EVAL_WRITE_BASELINE") - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str(), - "1" | "true" - ) -} - -/// Absolute path of the committed baseline `CorpusReport` JSON. Resolved against -/// `CARGO_MANIFEST_DIR` so it always points at the source-tree file regardless -/// of the working directory the example runs from. -fn baseline_path() -> String { - format!("{}/{}", env!("CARGO_MANIFEST_DIR"), BASELINE_REL_PATH) -} - -/// A/B knob: `LAYOUT_EVAL_DECLUTTER=0` disables the deterministic declutter -/// pass so a run can be compared against the pre-declutter behavior. Any other -/// value (or unset) leaves it on (the production default). -fn declutter_enabled() -> bool { - !matches!( - env::var("LAYOUT_EVAL_DECLUTTER").unwrap_or_default().trim(), - "0" | "false" - ) -} - -// ── Per-model seed sweep ───────────────────────────────────────────────────── - -/// Lay out `project`'s main model once for each `seed`, score each layout, and -/// summarize the samples into a `ModelStats`. -/// -/// The per-seed layouts run in parallel via rayon (mirroring -/// `generate_best_layout`'s `par_iter` over seeds). The parallel results are -/// collapsed back into `seeds`-order before being summarized, so the sample -/// vector -- and every statistic derived from it -- is invariant to rayon's -/// scheduling: parallelism introduces no nondeterminism here. -/// -/// `generate_layout_with_config` is deterministic per seed (fix #633): the same -/// `(model, seed)` pair produces the identical layout on repeated calls within -/// and across processes, so the reported median/spread are reproducible. -/// -/// A seed whose layout fails to generate is dropped with a WARN (a single bad -/// seed must not sink the whole model's sweep). A model whose layout fails on -/// EVERY seed yields an empty `samples` vector here; the caller -/// (`process_model`) treats that zero-usable-samples case as a model-level -/// failure and skips the model (`WARN: skipping {key}: ...`), so a model that -/// never lays out is omitted from the report rather than reported as a -/// degenerate all-zero entry (AC3.6). -fn sweep_model(key: &str, project: &datamodel::Project, seeds: &[u64]) -> ModelStats { - // Compute one (seed, sample) per seed in parallel, then sort back into seed - // order so the sample vector -- and therefore every statistic derived from - // it -- is independent of rayon's scheduling. - let mut indexed: Vec<(u64, MetricSample)> = seeds - .par_iter() - .filter_map(|&seed| { - let cfg = LayoutConfig { - annealing_random_seed: seed, - declutter: declutter_enabled(), - ..LayoutConfig::default() - }; - match generate_layout_with_config(project, MAIN_MODEL, cfg.clone(), None) { - Ok(view) => { - let metrics = compute_layout_metrics(&view, &cfg); - let weighted_cost = metrics.weighted_cost(&MetricWeights::default()); - Some(( - seed, - MetricSample { - seed, - metrics, - weighted_cost, - }, - )) - } - Err(err) => { - eprintln!("WARN: {key} seed {seed} failed to lay out: {err}"); - None - } - } - }) - .collect(); - - indexed.sort_by_key(|(seed, _)| *seed); - let samples: Vec = indexed.into_iter().map(|(_, sample)| sample).collect(); - - ModelStats::from_samples(key.to_string(), samples, &LAYOUT_SEEDS) -} - -// ── Rendering ──────────────────────────────────────────────────────────────── - -/// One rendered diagram: the PNG filename written under the out dir (relative, -/// so the Task-4 `index.html` can reference it with a sibling ``) and -/// the metric breakdown of the view that was rendered. The seed is `Some` for a -/// generated render (best/median/worst) and `None` for the as-loaded reference. -/// -/// `seed`, `metrics`, and `weighted_cost` are read by Task 4: the report builder -/// serializes them into `metrics.json` and the contact-sheet's per-render -/// breakdown table. They are kept as data here (rather than dropped and -/// recomputed) so the report builder is a pure read over this struct. -struct Render { - /// Filename of the PNG, relative to the out dir (e.g. `sir_best.png`). - file: String, - /// The seed that produced the generated view (`None` for the reference). - seed: Option, - /// Per-term metrics of the rendered view. - metrics: LayoutMetrics, - /// Scalar weighted cost under the calibrated default weights. - weighted_cost: f64, -} - -/// All renders produced for one model: the optional hand-authored reference and -/// the three generated layouts (best/median/worst). Task 4 serializes these -/// per-model metric breakdowns into `metrics.json` and the contact-sheet, so the -/// fields are kept as data the report can read back. A render that failed is -/// `None` (the failure was already WARN-logged) -- skip-on-failure feeds Task 6. -struct ModelRenders { - reference: Option, - best: Option, - median: Option, - worst: Option, -} - -/// Render one view to a PNG file under `out`, scoring it with the default -/// layout config (the metric core is config-driven only for node sizing, which -/// is constant across the sweep). On any render or write failure, WARN to -/// stderr and return `None` so the sweep continues (AC3.6). -/// -/// `project` must already carry the view to render as its main view's first -/// view (the renderer reads `model.views.first()`). The caller installs the -/// view (a clone of the project for a generated layout, or the as-loaded -/// project for the reference) before calling. -fn render_view( - project: &datamodel::Project, - metrics: LayoutMetrics, - seed: Option, - file: &str, - out: &str, -) -> Option { - let png = match render_png(project, MAIN_MODEL, &PngRenderOpts::default()) { - Ok(bytes) => bytes, - Err(err) => { - eprintln!("WARN: failed to render {file}: {err}"); - return None; - } - }; - let path = format!("{out}/{file}"); - if let Err(err) = std::fs::write(&path, &png) { - eprintln!("WARN: failed to write {path}: {err}"); - return None; - } - let weighted_cost = metrics.weighted_cost(&MetricWeights::default()); - Some(Render { - file: file.to_string(), - seed, - metrics, - weighted_cost, - }) -} - -/// Regenerate the view for `seed`, install it into a clone of `project`, render -/// it to `{key}_{suffix}.png`, and return the `Render`. A layout-generation -/// failure is non-fatal: WARN and return `None`. -fn render_generated( - key: &str, - suffix: &str, - project: &datamodel::Project, - seed: u64, - out: &str, -) -> Option { - let cfg = LayoutConfig { - annealing_random_seed: seed, - declutter: declutter_enabled(), - ..LayoutConfig::default() - }; - let view = match generate_layout_with_config(project, MAIN_MODEL, cfg.clone(), None) { - Ok(view) => view, - Err(err) => { - eprintln!("WARN: {key} {suffix} (seed {seed}) failed to lay out: {err}"); - return None; - } - }; - let metrics = compute_layout_metrics(&view, &cfg); - // Install the generated view into a clone so the as-loaded project (and its - // reference view) is never mutated. - let mut p = project.clone(); - p.get_model_mut(MAIN_MODEL).unwrap().views = vec![datamodel::View::StockFlow(view)]; - let file = format!("{key}_{suffix}.png"); - render_view(&p, metrics, Some(seed), &file, out) -} - -/// Render the model's best/median/worst generated layouts and -- if the model -/// ships a hand-authored view -- its reference, all to PNGs under `out`. -/// -/// The reference is rendered from the AS-LOADED `project` (before any view is -/// overwritten) so it captures the model's own diagram, not a generated one. -/// Generated layouts are each regenerated from `project` by seed and installed -/// into a fresh clone, leaving `project` untouched. -fn render_model( - key: &str, - project: &datamodel::Project, - stats: &ModelStats, - out: &str, -) -> ModelRenders { - // Reference first, from the as-loaded project, before any clone-and-install. - // Score the hand-authored `StockFlow` directly (the renderer reads the same - // view from `project`, so this is the geometry being rasterized). - let reference = reference_view(project).and_then(|sf| { - let metrics = compute_layout_metrics(sf, &LayoutConfig::default()); - render_view(project, metrics, None, &format!("{key}_reference.png"), out) - }); - - // A model whose sweep produced no samples has all-zero seeds and nothing - // worth rendering; skip the generated renders (the reference, if any, is - // already captured). - if stats.samples.is_empty() { - return ModelRenders { - reference, - best: None, - median: None, - worst: None, - }; - } - - let best = render_generated(key, "best", project, stats.best_seed, out); - let median = render_generated(key, "median", project, stats.median_seed, out); - let worst = render_generated(key, "worst", project, stats.worst_seed, out); - - ModelRenders { - reference, - best, - median, - worst, - } -} - -/// Print the PNG filenames produced for one model (and note a skipped reference -/// or generated render) so a run's stdout records exactly what was written. -fn report_renders(key: &str, renders: &ModelRenders) { - let mut produced: Vec<&str> = Vec::new(); - for render in [ - &renders.reference, - &renders.best, - &renders.median, - &renders.worst, - ] - .into_iter() - .flatten() - { - produced.push(render.file.as_str()); - } - if produced.is_empty() { - println!("{key}: no PNGs rendered"); - } else { - println!("{key}: rendered {}", produced.join(", ")); - } - if renders.reference.is_none() { - println!("{key}: no hand-authored reference view (skipped reference render)"); - } -} - -// ── Per-model pipeline (skip-on-failure) ───────────────────────────────────── - -/// Run one model's full pipeline -- load -> seed sweep -> render -- and return -/// its `(ModelStats, ModelRenders)` on success. -/// -/// This is the model-level skip-on-failure boundary (AC3.6): EVERY way a single -/// model can fail funnels through the returned `Err(String)`, which `main` turns -/// into a `WARN: skipping {key}: {err}` and a continue to the next model, so one -/// bad model never aborts the sweep and is simply omitted from the report. -/// -/// Three failure modes, validated in the order data flows (defense-in-depth): -/// 1. **Load failure** (entry layer): a missing file or a parse error is -/// already surfaced as `Err(String)` by `load_model`; propagated with `?`. -/// 2. **No usable layout** (business layer): `sweep_model` drops each -/// individually-failing seed with a WARN but still returns a (possibly -/// empty) `ModelStats`. A model whose layout failed on EVERY seed has zero -/// samples and cannot be scored, rendered, or aggregated -- it is a -/// model-level failure here, returned as `Err`. Crucially this only fires -/// when ALL seeds failed: a model with even one usable sample proceeds, so -/// a partial per-seed failure never sinks the model. -/// 3. **Render failure** (handled inside `render_model`): a layout that scores -/// but fails to rasterize or write is non-fatal -- it is WARN-logged and -/// its `Render` is `None`. A model can therefore appear in the report with -/// its statistics but a missing PNG cell; this is intentionally NOT a -/// model-level skip (the scores are still meaningful). -fn process_model( - spec: &ModelSpec, - seeds: &[u64], - out: &str, -) -> Result<(ModelStats, ModelRenders), String> { - // 1. Load (entry-layer validation lives in `load_model`). - let project = load_model(spec)?; - - let n = loaded_element_count(&project); - println!("loaded {}: {n} elements", spec.key); - - // 2. Sweep. A model with zero usable samples laid out on no seed -- it is a - // model-level failure, not a degenerate all-zero report entry. - let stats = sweep_model(spec.key, &project, seeds); - if stats.samples.is_empty() { - return Err(format!( - "no usable layout: all {} seed(s) failed to lay out", - seeds.len(), - )); - } - - let (p25, p75) = stats.spread; - println!( - "{}: median={:.4} p25/p75={:.4}/{:.4} best_of_k={:.4} (M={})", - spec.key, - stats.median_cost, - p25, - p75, - stats.best_of_k_cost, - stats.samples.len(), - ); - - // 3. Render best/median/worst (and the reference, if any). Render failures - // are non-fatal: `render_model` WARN-logs and leaves the cell `None`. - let renders = render_model(spec.key, &project, &stats, out); - report_renders(spec.key, &renders); - - Ok((stats, renders)) -} - -// ── Report (metrics.json + index.html) ────────────────────────────────────── -// -// The structs below are the on-disk JSON shape. They are PURE DATA built once -// from the in-memory `ModelStats` + `ModelRenders` the sweep produced, then -// serialized straight to disk -- no recomputation. The contact-sheet HTML is -// rendered from the same `EvalReport`, so the JSON table and the HTML can never -// disagree. Building the report and rendering the HTML are pure (the only I/O -// is the two `std::fs::write` calls in `main`). - -/// One rendered view's row in the JSON: the PNG filename, the seed that -/// produced it (`None` for the as-loaded reference), the full per-term -/// `LayoutMetrics` breakdown, and the scalar `weighted_cost` under the weights -/// in use. -#[derive(Serialize)] -struct RenderReport { - file: String, - seed: Option, - metrics: LayoutMetrics, - weighted_cost: f64, -} - -/// One model's full row in the JSON: its summary statistics (the seed-sweep -/// center/spread, the best-of-k production proxy, the chosen best/median/worst -/// seeds, and `m` -- the number of seeds actually swept) plus each of its -/// renders' per-term breakdowns (`reference` present only when the model ships -/// a hand-authored view). -#[derive(Serialize)] -struct ModelReport { - model: String, - /// Number of seeds swept for this model (the union of `LAYOUT_SEEDS` and - /// `0..M`, deduped). Recorded so a reader can interpret the spread. - m: usize, - median_cost: f64, - /// `(p25, p75)` of the per-seed weighted costs. - spread: (f64, f64), - /// Production proxy: min weighted cost over the `LAYOUT_SEEDS` seed set. - best_of_k_cost: f64, - best_seed: u64, - median_seed: u64, - worst_seed: u64, - /// The hand-authored reference render + score, when the model ships one. - reference: Option, - best: Option, - median: Option, - worst: Option, -} - -/// The top-level `metrics.json` document: every scored model plus the corpus -/// aggregates (the shifted-geomean `aggregate_cost` and the weight set used). -/// -/// `baseline_comparison` carries the baseline-vs-candidate diff (per-model + -/// aggregate deltas with Mann-Whitney p-values) when a committed baseline JSON -/// is present; it is `None` (and serde-skipped) when there is no baseline to -/// diff against. A reader therefore sees the diff embedded directly in the JSON, -/// or no `baseline_comparison` key at all. -#[derive(Serialize)] -struct EvalReport { - /// Models sorted worst-cost-first (highest `median_cost` at the front), the - /// same order the contact-sheet renders so the JSON and HTML agree. - models: Vec, - /// Shifted geometric mean (`geomean1p`) of the per-model medians -- the - /// single headline aggregate; zero-cost trivial models are neutral factors. - aggregate_cost: f64, - /// The `MetricWeights` used to compute every `weighted_cost` in this report. - weights: MetricWeights, - /// The baseline-vs-candidate diff, present only when a committed baseline - /// `CorpusReport` was found and compared against this run. - #[serde(skip_serializing_if = "Option::is_none")] - baseline_comparison: Option, -} - -/// Map an in-memory `Render` to its JSON row. -fn render_report(render: &Render) -> RenderReport { - RenderReport { - file: render.file.clone(), - seed: render.seed, - metrics: render.metrics, - weighted_cost: render.weighted_cost, - } -} - -/// Build the serializable report from the sweep's in-memory results. -/// -/// PURE: a read over `(per_model, renders)` (paired positionally -- they are -/// pushed together per model in `main`) plus the corpus `aggregate_cost` -/// and the weight set. Models are sorted worst-cost-first (highest median at -/// the front), the order the contact-sheet inspects top-down as the visual -/// guardrail; ties break on the model name so the order is deterministic. -fn build_report( - per_model: &[ModelStats], - renders: &[ModelRenders], - aggregate_cost: f64, - weights: &MetricWeights, - baseline_comparison: Option, -) -> EvalReport { - let mut models: Vec = per_model - .iter() - .zip(renders.iter()) - .map(|(stats, render)| ModelReport { - model: stats.model.clone(), - m: stats.samples.len(), - median_cost: stats.median_cost, - spread: stats.spread, - best_of_k_cost: stats.best_of_k_cost, - best_seed: stats.best_seed, - median_seed: stats.median_seed, - worst_seed: stats.worst_seed, - reference: render.reference.as_ref().map(render_report), - best: render.best.as_ref().map(render_report), - median: render.median.as_ref().map(render_report), - worst: render.worst.as_ref().map(render_report), - }) - .collect(); - - // Worst-cost-first: highest median at the front. Sort descending by median, - // tie-break on model name (ascending) for a deterministic ordering. NaN - // medians can't occur (eval_stats guarantees finite costs), but guard the - // partial_cmp anyway so a hypothetical NaN never panics the sort. - models.sort_by(|a, b| { - b.median_cost - .partial_cmp(&a.median_cost) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.model.cmp(&b.model)) - }); - - EvalReport { - models, - aggregate_cost, - weights: *weights, - baseline_comparison, - } -} - -/// HTML-escape the five characters that are special in element text or -/// attribute values. The interpolated strings are static model keys and -/// PNG filenames derived from them, so this is defense-in-depth rather than a -/// live injection vector -- but escaping unconditionally keeps the artifact -/// well-formed if a corpus key ever gains a special character. -fn html_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - match ch { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - '\'' => out.push_str("'"), - _ => out.push(ch), - } - } - out -} - -/// Render the per-term metric breakdown for one render as a compact two-column -/// table (term name -> value), with the scalar `weighted_cost` as the final -/// row. PURE: appends to `html`. -fn write_metrics_table(html: &mut String, render: &RenderReport) { - let m = &render.metrics; - let rows = [ - ("node_overlap", m.node_overlap), - ("node_connector_overlap", m.node_connector_overlap), - ("label_overlap", m.label_overlap), - ("crossings", m.crossings), - ("sprawl", m.sprawl), - ("edge_length_cv", m.edge_length_cv), - ("aspect_penalty", m.aspect_penalty), - ("chain_straightness", m.chain_straightness), - ("loop_compactness", m.loop_compactness), - ("flow_bends", m.flow_bends), - ("loop_straightness", m.loop_straightness), - ]; - html.push_str(""); - for (name, value) in rows { - let _ = write!( - html, - "" - ); - } - let _ = write!( - html, - "", - render.weighted_cost - ); - html.push_str("
{name}{value:.4}
weighted_cost{:.4}
"); -} - -/// Render one render's cell (heading + image + breakdown table). A missing -/// render (the model shipped no reference, or its layout/render failed) renders -/// a muted placeholder so the contact-sheet records the gap rather than hiding -/// it. PURE. -fn write_render_cell(html: &mut String, kind: &str, render: Option<&RenderReport>) { - html.push_str("
"); - let _ = write!(html, "

{}

", html_escape(kind)); - match render { - Some(r) => { - let src = html_escape(&r.file); - let alt = html_escape(&format!("{kind} layout")); - let _ = write!(html, "\"{alt}\""); - if let Some(seed) = r.seed { - let _ = write!(html, "

seed {seed}

"); - } - write_metrics_table(html, r); - } - None => html.push_str("

(not rendered)

"), - } - html.push_str("
"); -} - -/// Format a `delta_ratio` as a signed percentage (e.g. `+3.2%`, `-0.0%`). PURE. -fn fmt_delta_pct(ratio: f64) -> String { - format!("{:+.2}%", ratio * 100.0) -} - -/// Render the baseline-vs-candidate diff into the header: the aggregate delta + -/// significance verdict, then a per-model table of `delta_ratio`, the -/// Mann-Whitney p-value, and the significance verdict. A `None` comparison (no -/// committed baseline) renders a muted note instead, so the contact-sheet always -/// records whether a baseline was diffed. PURE: appends to `html`. -fn write_baseline_diff(html: &mut String, comparison: Option<&Comparison>) { - let Some(cmp) = comparison else { - html.push_str( - "

No baseline diff (run with \ - LAYOUT_EVAL_WRITE_BASELINE=1 to seed one).

\n", - ); - return; - }; - - html.push_str("

Baseline diff

"); - let agg_class = if cmp.aggregate_significant { - "sig" - } else { - "nonsig" - }; - let agg_verdict = if cmp.aggregate_significant { - "significant" - } else { - "not significant" - }; - let _ = write!( - html, - "

aggregate delta {} · \ - p={:.4} · {agg_verdict}

", - fmt_delta_pct(cmp.aggregate_delta_ratio), - cmp.aggregate_p_value, - ); - - if cmp.per_model.is_empty() { - html.push_str("

(no models matched the baseline)

\n"); - return; - } - - html.push_str( - "\ - ", - ); - for m in &cmp.per_model { - let (cls, verdict) = if m.significant { - ("sig", "significant") - } else { - ("nonsig", "—") - }; - let _ = write!( - html, - "\ - \ - ", - html_escape(&m.model), - m.baseline_median, - m.candidate_median, - fmt_delta_pct(m.delta_ratio), - m.p_value, - ); - } - html.push_str("
modelbaselinecandidatedeltapsignificance
{}{:.4}{:.4}{}{:.4}{verdict}
\n"); -} - -/// Render the self-contained `index.html` contact-sheet from the report. -/// -/// PURE: a string built from `report`. The header shows the corpus -/// `aggregate_cost`, the weight set, and (when a committed baseline was -/// diffed) the baseline-vs-candidate delta table; models are laid out one -/// section per model, worst-cost-first (the report is already sorted), each with -/// its reference (if any) and best/median/worst renders side by side and a -/// per-term breakdown under each. `` paths are relative to the out dir so -/// the file references its sibling PNGs. -fn render_index_html(report: &EvalReport) -> String { - let mut html = String::new(); - html.push_str( - "\n\n\n\n\ - \n\ - Layout quality eval\n\n\n\n", - ); - - html.push_str("

Layout quality eval

\n"); - let _ = writeln!( - &mut html, - "

Corpus aggregate_cost = {:.4} over \ - {} model(s), sorted worst-cost-first.

", - report.aggregate_cost, - report.models.len(), - ); - - // The weight set used for every weighted_cost in this report. - let w = &report.weights; - let weight_rows = [ - ("node_overlap", w.node_overlap), - ("node_connector_overlap", w.node_connector_overlap), - ("label_overlap", w.label_overlap), - ("crossings", w.crossings), - ("sprawl", w.sprawl), - ("edge_length_cv", w.edge_length_cv), - ("aspect_penalty", w.aspect_penalty), - ("chain_straightness", w.chain_straightness), - ("loop_compactness", w.loop_compactness), - ("flow_bends", w.flow_bends), - ("loop_straightness", w.loop_straightness), - ]; - html.push_str(""); - for (name, value) in weight_rows { - let _ = write!( - &mut html, - "" - ); - } - html.push_str("
weights
{name}{value:.4}
\n"); - - write_baseline_diff(&mut html, report.baseline_comparison.as_ref()); - - for model in &report.models { - let name = html_escape(&model.model); - html.push_str("
"); - let _ = write!(&mut html, "

{name}

"); - let _ = write!( - &mut html, - "

median={:.4} · p25/p75={:.4}/{:.4} · \ - best_of_k={:.4} · M={} · \ - seeds best/median/worst={}/{}/{}

", - model.median_cost, - model.spread.0, - model.spread.1, - model.best_of_k_cost, - model.m, - model.best_seed, - model.median_seed, - model.worst_seed, - ); - html.push_str("
"); - write_render_cell(&mut html, "reference", model.reference.as_ref()); - write_render_cell(&mut html, "best", model.best.as_ref()); - write_render_cell(&mut html, "median", model.median.as_ref()); - write_render_cell(&mut html, "worst", model.worst.as_ref()); - html.push_str("
\n"); - } - - html.push_str("\n\n"); - html -} - -// ── Baseline diff (imperative shell) ───────────────────────────────────────── - -/// Write `candidate` to the committed baseline JSON, replacing any existing -/// file. The full `CorpusReport` -- including each model's per-seed `samples` -- -/// is serialized so a later run can re-run Mann-Whitney U over the seed-sample -/// cost sets. On a serialize or write failure WARN to stderr (the run still -/// emits its `target/` artifacts; only the baseline re-seed failed). -fn write_baseline(candidate: &CorpusReport) { - let path = baseline_path(); - match serde_json::to_string_pretty(candidate) { - Ok(json) => match std::fs::write(&path, json) { - Ok(()) => println!( - "wrote baseline {path}\n\ - note: re-seed this baseline after the metric weights change." - ), - Err(err) => eprintln!("WARN: failed to write baseline {path}: {err}"), - }, - Err(err) => eprintln!("WARN: failed to serialize baseline: {err}"), - } -} - -/// Read and deserialize the committed baseline `CorpusReport`, if present. -/// -/// Returns `None` (with a one-line note) when the file does not exist -- the -/// expected state before a baseline has been seeded. A file that exists but -/// fails to read or parse is a real error: WARN with the cause and return `None` -/// so the run still emits its artifacts without a diff. -fn read_baseline() -> Option { - let path = baseline_path(); - let json = match std::fs::read_to_string(&path) { - Ok(json) => json, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - println!("no baseline; run with LAYOUT_EVAL_WRITE_BASELINE=1 to seed one."); - return None; - } - Err(err) => { - eprintln!("WARN: failed to read baseline {path}: {err}"); - return None; - } - }; - match serde_json::from_str::(&json) { - Ok(report) => Some(report), - Err(err) => { - eprintln!("WARN: failed to parse baseline {path}: {err}"); - None - } - } -} - -/// Print the baseline-vs-candidate diff to stdout: one line per matched model -/// (delta + p-value + significance) and an aggregate line. PURE-ish: reads -/// `cmp` and prints; kept in the shell because it does I/O (stdout). -fn print_comparison(cmp: &Comparison) { - println!("baseline diff (candidate vs baseline):"); - for m in &cmp.per_model { - let verdict = if m.significant { - "significant" - } else { - "not significant" - }; - println!( - " {}: delta={} p={:.4} ({verdict})", - m.model, - fmt_delta_pct(m.delta_ratio), - m.p_value, - ); - } - if cmp.per_model.is_empty() { - println!(" (no models matched the baseline)"); - } - let agg_verdict = if cmp.aggregate_significant { - "significant" - } else { - "not significant" - }; - println!( - " aggregate: delta={} p={:.4} ({agg_verdict})", - fmt_delta_pct(cmp.aggregate_delta_ratio), - cmp.aggregate_p_value, - ); -} - -/// Resolve the baseline diff for this run. -/// -/// When `LAYOUT_EVAL_WRITE_BASELINE` is set, (re)seed the committed baseline -/// from `candidate` and return `None` (a seeding run reports no diff -- there is -/// nothing yet to diff against). Otherwise read the committed baseline (if any), -/// run `compare(baseline, candidate)`, print the diff, and return it for -/// embedding in the artifacts. Absent baseline -> `None`. -fn resolve_baseline_diff(candidate: &CorpusReport) -> Option { - if write_baseline_requested() { - write_baseline(candidate); - return None; - } - let baseline = read_baseline()?; - let cmp = compare(&baseline, candidate); - print_comparison(&cmp); - Some(cmp) -} - -fn main() { - let keys = selected_keys(); - let m = seed_count(); - let seeds = seed_set(m); - let out = out_dir(); - - std::fs::create_dir_all(&out) - .unwrap_or_else(|e| panic!("failed to create output dir {out}: {e}")); - - let n_sampled = seeds.len(); - println!( - "layout_eval: {} model(s), M={m} seeds (sampling {n_sampled} unique), out={out}", - keys.len(), - ); - - // Per-model skip-on-failure (AC3.6): each model's full pipeline (load -> - // sweep -> render) is wrapped in `process_model`. ANY failure -- a load - // error, a layout that fails on every seed, etc. -- is WARN-logged and the - // sweep CONTINUES to the next model; the failed model is omitted from - // `per_model`/`renders` (and therefore from every artifact). The harness - // always reaches the end and exits 0, even if every model was skipped. - // - // `per_model` and `renders` stay positionally paired: both are pushed - // exactly once per surviving model, so the Task-4 report builder can zip - // them. - let mut per_model: Vec = Vec::new(); - let mut renders: Vec = Vec::new(); - let mut skipped = 0usize; - for spec in CORPUS.iter().filter(|s| keys.contains(&s.key)) { - match process_model(spec, &seeds, &out) { - Ok((stats, model_renders)) => { - per_model.push(stats); - renders.push(model_renders); - } - Err(err) => { - eprintln!("WARN: skipping {}: {err}", spec.key); - skipped += 1; - } - } - } - if skipped > 0 { - println!("skipped {skipped} model(s) (see WARN lines above)"); - } - - let corpus = CorpusReport::from_model_stats(per_model); - println!( - "corpus: aggregate_cost={:.4} ({} model(s) scored)", - corpus.aggregate_cost, - corpus.per_model.len(), - ); - - let with_reference = renders.iter().filter(|r| r.reference.is_some()).count(); - println!( - "corpus: {with_reference}/{} model(s) shipped a hand-authored reference view", - renders.len(), - ); - - // Either (re)seed the committed baseline from this run, or diff this run's - // report against the committed baseline (printing the per-model + aggregate - // deltas with Mann-Whitney p-values). The returned `Comparison` (if any) is - // embedded into both artifacts below. - let baseline_comparison = resolve_baseline_diff(&corpus); - - // Build the serializable report from the in-memory stats + renders, then - // emit both artifacts under the out dir (which defaults under the gitignored - // repo-root `target/`). `corpus.per_model` and `renders` are positionally - // paired -- both are pushed once per surviving model in the loop above. - let report = build_report( - &corpus.per_model, - &renders, - corpus.aggregate_cost, - &MetricWeights::default(), - baseline_comparison, - ); - - let metrics_path = format!("{out}/metrics.json"); - match serde_json::to_string_pretty(&report) { - Ok(json) => match std::fs::write(&metrics_path, json) { - Ok(()) => println!("wrote {metrics_path}"), - Err(err) => eprintln!("WARN: failed to write {metrics_path}: {err}"), - }, - Err(err) => eprintln!("WARN: failed to serialize metrics.json: {err}"), - } - - let index_path = format!("{out}/index.html"); - let html = render_index_html(&report); - match std::fs::write(&index_path, html) { - Ok(()) => println!("wrote {index_path}"), - Err(err) => eprintln!("WARN: failed to write {index_path}: {err}"), - } -} diff --git a/src/simlin-engine/examples/layout_eval/corpus.rs b/src/simlin-engine/examples/layout_eval/corpus.rs new file mode 100644 index 000000000..d5f70f409 --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/corpus.rs @@ -0,0 +1,393 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! The curated corpus: which models the sweep lays out, how far each model's +//! shipped diagram can be trusted as a taste anchor, and how models load. + +use std::io::BufReader; + +use simlin_engine::{datamodel, open_vensim, open_xmile}; + +/// The model name the layout pipeline and renderer operate on. `Project::get_model` +/// maps "main" to the single/main model (matching `tests/integration/layout.rs`). +pub const MAIN_MODEL: &str = "main"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Xmile, + Vensim, +} + +/// How far a model's shipped view can be trusted as a quality exemplar. +/// +/// A hand-drawn diagram is ground truth for arrangement, but the metric scores +/// the geometry OUR renderer draws. That matches the author's picture only when +/// the authoring tool draws the way we do, so the anchors are graded rather than +/// treated alike. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Reference { + /// One view authored in Stella or Simlin, whose conventions our renderer + /// reproduces (a named circle/box with a side label): its score is directly + /// comparable to a generated layout's. + Curated, + /// One view authored in Vensim. Its arrangement is a trustworthy exemplar, + /// but Vensim draws a variable AS its wrapped name (no circle, no side + /// label), so the label geometry our renderer imposes on it is not what the + /// author saw; label-dependent terms over it are not comparable. + Imported, + /// Several Vensim views the importer stacks into one diagram with group + /// boxes: an exemplar of decomposing a large model, not one comparable + /// diagram. + MultiView, + /// No shipped diagram: the model is only ever laid out by the generator. + None, +} + +/// Size class, for reading results and for running a cheap subset. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Tier { + /// Up to ~20 variables: a textbook model. + Small, + /// ~20-100 variables: what an agent or a student builds. + Medium, + /// Over ~100 variables: a published research model. + Large, +} + +impl Tier { + pub fn parse(s: &str) -> Option { + match s { + "small" => Some(Tier::Small), + "medium" => Some(Tier::Medium), + "large" => Some(Tier::Large), + _ => None, + } + } +} + +/// One corpus entry. Paths are relative to `CARGO_MANIFEST_DIR` +/// (`src/simlin-engine`) unless absolute. +#[derive(Clone, Debug)] +pub struct ModelSpec { + pub key: String, + pub path: String, + pub format: Format, + pub tier: Tier, + pub reference: Reference, +} + +struct Entry { + key: &'static str, + rel_path: &'static str, + format: Format, + tier: Tier, + reference: Reference, +} + +use Format::{Vensim, Xmile}; +use Reference::{Curated, Imported, MultiView}; +use Tier::{Large, Medium, Small}; + +/// The curated corpus. Every entry is verified to exist on disk and load; each +/// earns its place by exercising something the others do not. +const CORPUS: &[Entry] = &[ + // Textbook small models. + Entry { + key: "teacup", + rel_path: "../../test/test-models/samples/teacup/teacup.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "sir", + rel_path: "../../test/test-models/samples/SIR/SIR.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // default_projects: the app's curated, hand-laid-out built-in projects, the + // primary taste anchors (and the reference-pair tests' fixtures). + Entry { + key: "logistic_growth", + rel_path: "../../default_projects/logistic-growth/model.xmile", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "population", + rel_path: "../../default_projects/population/model.xmile", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "fishbanks", + rel_path: "../../default_projects/fishbanks/model.xmile", + format: Xmile, + tier: Medium, + reference: Curated, + }, + Entry { + key: "reliability", + rel_path: "../../default_projects/reliability/model.xmile", + format: Xmile, + tier: Medium, + reference: Curated, + }, + // Small and medium single-view Vensim models: the hand arrangement is the + // exemplar (chains in rows, parameters beside their consumers). + Entry { + key: "lotka_volterra", + rel_path: "../../test/test-models/samples/Lotka_Volterra/Lotka_Volterra.mdl", + format: Vensim, + tier: Small, + reference: Imported, + }, + Entry { + key: "workforce", + rel_path: "../../test/test-models/samples/Workforce/workforce.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + Entry { + key: "bathtub", + rel_path: "../../test/metasd/bathtub-statistics/integration3.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + Entry { + key: "catastrophe", + rel_path: "../../test/metasd/early-warnings-catastrophe/catastropeWarning2.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + Entry { + key: "groupon", + rel_path: "../../test/metasd/social-network-valuation/groupon 1.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + // Delay and smooth structures (implicit modules behind builtins). + Entry { + key: "delays", + rel_path: "../../test/delays/model.xmile", + format: Xmile, + tier: Small, + reference: Curated, + }, + // No shipped diagram: laid out only by the generator, like a model an agent + // builds from equations. + Entry { + key: "arms_race", + rel_path: "../../test/arms_race_3party/arms_race.stmx", + format: Xmile, + tier: Small, + reference: Reference::None, + }, + // Modules. + Entry { + key: "hares_and_foxes", + rel_path: "../../test/modules_hares_and_foxes/modules_hares_and_foxes.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "ai_modules_arrays", + rel_path: "../../test/ai-information/WithModulesAndArrays.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // Aliases. + Entry { + key: "alias1", + rel_path: "../../test/alias1/alias1.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // Arrays and several flows into one stock. + Entry { + key: "cross_element", + rel_path: "../../test/cross_element_ltm/cross_element.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "arrayed_pop", + rel_path: "../../test/arrayed_population_ltm/arrayed_population.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // An AI-generated model a human then edited: the kind of model the MCP + // server lays out. + Entry { + key: "ai_edited", + rel_path: "../../test/ai-information/GeneratedByAIThenEdited.stmx", + format: Xmile, + tier: Large, + reference: Curated, + }, + // Large published Vensim models. + Entry { + key: "wrld3_03", + rel_path: "../../test/metasd/WRLD3-03/wrld3-03.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "beer_game", + rel_path: "../../test/metasd/beer-game/RealBeer4-Sterman13.mdl", + format: Vensim, + tier: Medium, + reference: MultiView, + }, + Entry { + key: "wonderland", + rel_path: "../../test/metasd/wonderland/Wonderland3.mdl", + format: Vensim, + tier: Medium, + reference: MultiView, + }, + Entry { + key: "mortgage_econ", + rel_path: "../../test/bobby/vdf/econ/mark2.mdl", + format: Vensim, + tier: Medium, + reference: MultiView, + }, + Entry { + key: "land_use", + rel_path: "../../test/land_model/land_model.stmx", + format: Xmile, + tier: Large, + reference: MultiView, + }, + // Multi-view Vensim: hand-authored references that decompose a big model + // into ~25-50-variable views connected by ghost variables -- the exemplars + // of what a readable layout of a large model looks like. + Entry { + key: "scirev", + rel_path: "../../test/metasd/scientific-revolution/scirev8.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "thyroid", + rel_path: "../../test/metasd/thyroid-dynamics/thyroid-2008-d.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "covid19", + rel_path: "../../test/metasd/covid19-us-homer/homer v8/Covid19US v8.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "industrial_dynamics", + rel_path: "../../test/metasd/industrial-dynamics/IDch15/IDch15d.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, +]; + +/// The corpus as owned specs, followed by any ad-hoc `extra` entries +/// (`(key, path)` pairs from `LAYOUT_EVAL_EXTRA`). An extra's format comes from +/// its extension; it is filed as a medium model whose reference (if it ships +/// one) is graded by format, since nothing more is known about it. +pub fn all_specs(extra: &[(String, String)]) -> Vec { + let mut specs: Vec = CORPUS + .iter() + .map(|e| ModelSpec { + key: e.key.to_string(), + path: e.rel_path.to_string(), + format: e.format, + tier: e.tier, + reference: e.reference, + }) + .collect(); + for (key, path) in extra { + let format = if path.to_ascii_lowercase().ends_with(".mdl") { + Format::Vensim + } else { + Format::Xmile + }; + specs.push(ModelSpec { + key: key.clone(), + path: path.clone(), + format, + tier: Tier::Medium, + reference: match format { + Format::Xmile => Reference::Curated, + Format::Vensim => Reference::Imported, + }, + }); + } + specs +} + +/// Resolve a spec path: absolute paths as given, relative ones against the +/// crate manifest dir. +fn abs_path(path: &str) -> String { + if std::path::Path::new(path).is_absolute() { + path.to_string() + } else { + format!("{}/{}", env!("CARGO_MANIFEST_DIR"), path) + } +} + +/// Load one corpus model, dispatching on its declared format. Returns a +/// human-readable error on any I/O or parse failure so the caller can +/// WARN-and-skip. +pub fn load_model(spec: &ModelSpec) -> Result { + let path = abs_path(&spec.path); + match spec.format { + Format::Xmile => { + let file = + std::fs::File::open(&path).map_err(|e| format!("failed to open {path}: {e}"))?; + let mut reader = BufReader::new(file); + open_xmile(&mut reader).map_err(|e| format!("failed to parse {path}: {e:?}")) + } + Format::Vensim => { + let contents = std::fs::read_to_string(&path) + .map_err(|e| format!("failed to read {path}: {e}"))?; + open_vensim(&contents).map_err(|e| format!("failed to parse {path}: {e:?}")) + } + } +} + +/// Borrow the model's as-loaded main `StockFlow` view if it is a non-empty +/// hand-authored diagram; `None` when the model ships no diagram. +pub fn reference_view(project: &datamodel::Project) -> Option<&datamodel::StockFlow> { + let model = project.get_model(MAIN_MODEL)?; + match model.views.first() { + Some(datamodel::View::StockFlow(sf)) if !sf.elements.is_empty() => Some(sf), + _ => None, + } +} + +/// Number of model variables, the size the tiers describe. +pub fn variable_count(project: &datamodel::Project) -> usize { + project + .get_model(MAIN_MODEL) + .map(|m| m.variables.len()) + .unwrap_or(0) +} diff --git a/src/simlin-engine/examples/layout_eval/knobs.rs b/src/simlin-engine/examples/layout_eval/knobs.rs new file mode 100644 index 000000000..d8312ff40 --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/knobs.rs @@ -0,0 +1,115 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Environment knobs, parsed once at startup. + +use std::env; + +use crate::corpus::Tier; + +/// Default number of seeds to sample per model when `LAYOUT_EVAL_SEEDS` is unset. +const DEFAULT_SEEDS: u64 = 25; + +/// Default number of edits in the incremental-build replay: a handful, like an +/// agent's session of `edit_model` calls. +const DEFAULT_REPLAY_STEPS: usize = 4; + +pub struct Knobs { + /// `LAYOUT_EVAL_MODELS`: corpus keys to run (`None` = all). + pub models: Option>, + /// `LAYOUT_EVAL_TIERS`: size classes to run (`None` = all). + pub tiers: Option>, + /// `LAYOUT_EVAL_EXTRA`: ad-hoc `key=path` models appended to the corpus. + pub extra: Vec<(String, String)>, + /// `LAYOUT_EVAL_SEEDS`: seeds sampled per model. + pub seeds: u64, + /// `LAYOUT_EVAL_OUT`: output directory. + pub out: String, + /// `LAYOUT_EVAL_WRITE_BASELINE`: re-seed the committed baseline instead of + /// diffing against it. + pub write_baseline: bool, + /// `LAYOUT_EVAL_COMPARE`: a previous run's output dir to diff against. + pub compare_dir: Option, + /// `LAYOUT_EVAL_DECLUTTER=0` disables the declutter pass in the seed sweep. + pub declutter: bool, + /// `LAYOUT_EVAL_REPLAY_STEPS`: edits in the incremental-build replay; 0 + /// skips it. + pub replay_steps: usize, +} + +fn list(name: &str) -> Option> { + let raw = env::var(name).ok()?; + let items: Vec = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if items.is_empty() { None } else { Some(items) } +} + +fn flag(name: &str) -> bool { + matches!( + env::var(name) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), + "1" | "true" + ) +} + +impl Knobs { + pub fn from_env() -> Knobs { + let tiers = list("LAYOUT_EVAL_TIERS").map(|names| { + names + .iter() + .filter_map(|n| { + let tier = Tier::parse(n); + if tier.is_none() { + eprintln!("WARN: unknown tier {n:?} (small|medium|large); ignoring"); + } + tier + }) + .collect() + }); + let extra = list("LAYOUT_EVAL_EXTRA") + .unwrap_or_default() + .into_iter() + .filter_map(|item| match item.split_once('=') { + Some((key, path)) if !key.is_empty() && !path.is_empty() => { + Some((key.to_string(), path.to_string())) + } + _ => { + eprintln!("WARN: LAYOUT_EVAL_EXTRA entry {item:?} is not key=path; ignoring"); + None + } + }) + .collect(); + Knobs { + models: list("LAYOUT_EVAL_MODELS"), + tiers, + extra, + seeds: env::var("LAYOUT_EVAL_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_SEEDS), + out: env::var("LAYOUT_EVAL_OUT").unwrap_or_else(|_| { + format!("{}/../../target/layout-eval", env!("CARGO_MANIFEST_DIR")) + }), + write_baseline: flag("LAYOUT_EVAL_WRITE_BASELINE"), + compare_dir: env::var("LAYOUT_EVAL_COMPARE") + .ok() + .filter(|s| !s.trim().is_empty()), + declutter: !matches!( + env::var("LAYOUT_EVAL_DECLUTTER").unwrap_or_default().trim(), + "0" | "false" + ), + replay_steps: env::var("LAYOUT_EVAL_REPLAY_STEPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_REPLAY_STEPS), + } + } +} diff --git a/src/simlin-engine/examples/layout_eval/main.rs b/src/simlin-engine/examples/layout_eval/main.rs new file mode 100644 index 000000000..7d196185e --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/main.rs @@ -0,0 +1,332 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Layout-quality evaluation sweep (on-demand; NOT part of `cargo test`). +//! +//! For each corpus model: lay it out across many seeds and score each layout +//! with the layout-quality metric (the algorithm's quality DISTRIBUTION), run +//! the production `generate_best_layout` call once, timed (what a user GETS), +//! replay building the model over a few edits with the diagram synced +//! incrementally after each (what an agent or notebook user GETS), and render +//! the hand-authored reference, the production and incremental layouts, and +//! the median and worst seeds to PNG. Writes `metrics.json`, `corpus.json`, and an +//! `index.html` contact sheet under a gitignored `target/` directory. +//! +//! This is a thin imperative shell over the metric core +//! (`layout::metrics::compute_layout_metrics`) and the statistics core +//! (`layout::eval_stats`). +//! +//! Usage: +//! cargo run --release -p simlin-engine --features png_render,file_io --example layout_eval +//! +//! Env knobs: +//! LAYOUT_EVAL_MODELS comma list of corpus keys to run (default: all) +//! LAYOUT_EVAL_TIERS comma list of size classes: small,medium,large +//! LAYOUT_EVAL_EXTRA comma list of ad-hoc key=path models to add +//! (paths relative to src/simlin-engine or absolute) +//! LAYOUT_EVAL_SEEDS number of seeds M to sample (default: 25) +//! LAYOUT_EVAL_OUT output directory (default: repo-root target/layout-eval) +//! LAYOUT_EVAL_COMPARE a previous run's output dir: diff this run against +//! its corpus.json (re-scored under this run's weights) +//! and show its per-term values beside this run's +//! LAYOUT_EVAL_WRITE_BASELINE 1 -> write this run's report to the committed +//! baseline JSON instead of diffing against it +//! LAYOUT_EVAL_DECLUTTER 0 -> disable the declutter pass in the seed sweep +//! LAYOUT_EVAL_REPLAY_STEPS edits in the incremental-build replay (default 4; +//! 0 skips the replay) +//! +//! Baseline diff: the committed `examples/layout_eval_baseline.json` (a +//! serialized `CorpusReport`) records a reference run. A normal run re-scores +//! it under the current weights, compares, and embeds the per-model + paired +//! aggregate verdicts into `metrics.json` and `index.html`. +//! +//! Requires `--features png_render,file_io`: `png_render` for the rasterizer, +//! and `file_io` so Vensim corpus models that reference external data load. + +mod corpus; +mod knobs; +mod render; +mod replay; +mod report; +mod sweep; +mod taste; + +use simlin_engine::layout::LAYOUT_SEEDS; +use simlin_engine::layout::eval_stats::{Comparison, CorpusReport, ModelStats, compare}; +use simlin_engine::layout::metrics::MetricWeights; + +use corpus::{ModelSpec, Reference}; +use knobs::Knobs; +use report::{ModelFacts, ModelRenders}; + +/// Path (relative to `CARGO_MANIFEST_DIR`) of the committed baseline report. It +/// lives in the SOURCE TREE by design (checked in and diffed on every normal +/// run), unlike every other artifact, which is written under `target/`. +const BASELINE_REL_PATH: &str = "examples/layout_eval_baseline.json"; + +fn baseline_path() -> String { + format!("{}/{}", env!("CARGO_MANIFEST_DIR"), BASELINE_REL_PATH) +} + +/// The specs this run lays out: the corpus plus extras, filtered by the +/// `LAYOUT_EVAL_MODELS` keys and `LAYOUT_EVAL_TIERS` classes. Unknown keys are +/// reported so a typo does not silently run nothing. +fn selected_specs(knobs: &Knobs) -> Vec { + let all = corpus::all_specs(&knobs.extra); + if let Some(keys) = &knobs.models { + for key in keys { + if !all.iter().any(|s| &s.key == key) { + eprintln!("WARN: unknown model key {key:?}; skipping"); + } + } + } + all.into_iter() + .filter(|s| { + knobs + .models + .as_ref() + .is_none_or(|keys| keys.contains(&s.key)) + }) + .filter(|s| { + knobs + .tiers + .as_ref() + .is_none_or(|tiers| tiers.contains(&s.tier)) + }) + .collect() +} + +/// One model's pipeline -- load, sweep, production, render -- as the +/// model-level skip-on-failure boundary: ANY failure funnels through the +/// returned `Err`, which `main` WARN-logs before moving on, so one bad model +/// never aborts the sweep. A model that lays out on no seed is a failure; a +/// render that fails is not (its cell is simply empty). +fn process_model( + spec: &ModelSpec, + seeds: &[u64], + knobs: &Knobs, +) -> Result<(ModelStats, ModelRenders, ModelFacts), String> { + let project = corpus::load_model(spec)?; + let variables = corpus::variable_count(&project); + println!("loaded {}: {variables} variables", spec.key); + + let stats = sweep::sweep_model(&spec.key, &project, seeds, knobs.declutter); + if stats.samples.is_empty() { + return Err(format!( + "no usable layout: all {} seed(s) failed to lay out", + seeds.len() + )); + } + let production = sweep::production(&spec.key, &project); + + let out = &knobs.out; + let key = &spec.key; + let reference_view = corpus::reference_view(&project); + let reference = reference_view.and_then(|sf| { + render::render_view( + &project, + sf, + None, + &format!("{key}_reference.png"), + out, + true, + ) + }); + let production_render = production.as_ref().and_then(|p| { + render::render_view( + &project, + &p.view, + None, + &format!("{key}_production.png"), + out, + true, + ) + }); + let seed_render = |suffix: &str, seed: u64| { + sweep::seed_view(key, &project, seed, knobs.declutter).and_then(|view| { + render::render_view( + &project, + &view, + Some(seed), + &format!("{key}_{suffix}.png"), + out, + false, + ) + }) + }; + let replayed = (knobs.replay_steps > 0) + .then(|| replay::replay(key, &project, knobs.replay_steps)) + .flatten(); + let incremental = replayed.as_ref().and_then(|r| { + render::render_view( + &project, + &r.view, + None, + &format!("{key}_incremental.png"), + out, + true, + ) + }); + let renders = ModelRenders { + reference, + production: production_render, + incremental, + median: seed_render("median", stats.median_seed), + worst: seed_render("worst", stats.worst_seed), + }; + + let (p25, p75) = stats.spread; + println!( + "{key}: median={:.4} p25/p75={p25:.4}/{p75:.4} best_of_k={:.4} production={} (M={})", + stats.median_cost, + stats.best_of_k_cost, + match (&production, &renders.production) { + (Some(p), Some(r)) => format!("{:.4} in {:.0}ms", r.weighted_cost, p.elapsed_ms), + _ => "n/a".to_string(), + }, + stats.samples.len(), + ); + if let (Some(r), Some(render)) = (&replayed, &renders.incremental) { + println!( + "{key}: incremental={:.4} over {} edits in {:.0}ms", + render.weighted_cost, r.steps, r.elapsed_ms + ); + } + + // Taste checks run on the diagrams worth degrading: a single-view reference + // (a stacked multi-view reference is not one diagram) and production. + let taste_reference = match (reference_view, spec.reference) { + (Some(sf), Reference::Curated | Reference::Imported) => taste::run_battery(sf), + _ => Vec::new(), + }; + let taste_production = production + .as_ref() + .map(|p| taste::run_battery(&p.view)) + .unwrap_or_default(); + + let facts = ModelFacts { + tier: spec.tier, + reference: if reference_view.is_some() { + spec.reference + } else { + Reference::None + }, + variables, + production_ms: production.as_ref().map(|p| p.elapsed_ms), + incremental_ms: replayed.as_ref().map(|r| r.elapsed_ms), + taste_reference, + taste_production, + }; + Ok((stats, renders, facts)) +} + +/// Diff `candidate` against the committed baseline (or re-seed it), printing +/// and returning the verdicts. Both sides are scored under `weights`. +fn baseline_comparison( + candidate: &CorpusReport, + weights: &MetricWeights, + knobs: &Knobs, +) -> Option { + let path = baseline_path(); + if knobs.write_baseline { + report::write_json(&path, candidate); + println!("note: re-seed this baseline after the metric terms change."); + return None; + } + let baseline = report::read_corpus(&path)?.rescored(weights, &LAYOUT_SEEDS); + let cmp = compare(&baseline, candidate); + report::print_comparison("committed baseline", &cmp); + Some(cmp) +} + +/// Print, per degradation, on how many diagrams the metric penalized it. +fn print_taste_summary(facts: &[ModelFacts]) { + let Some(first) = facts.iter().find(|f| !f.taste_production.is_empty()) else { + return; + }; + println!("taste checks (noticed/applicable): references | production"); + for (i, check) in first.taste_production.iter().enumerate() { + let (rn, ra) = taste::tally(facts.iter().filter_map(|f| f.taste_reference.get(i))); + let (pn, pa) = taste::tally(facts.iter().filter_map(|f| f.taste_production.get(i))); + println!( + " {:<12} {rn:>3}/{ra:<3} | {pn:>3}/{pa:<3}", + check.degradation + ); + } +} + +fn main() { + let knobs = Knobs::from_env(); + let specs = selected_specs(&knobs); + let seeds = sweep::seed_set(knobs.seeds); + std::fs::create_dir_all(&knobs.out) + .unwrap_or_else(|e| panic!("failed to create output dir {}: {e}", knobs.out)); + println!( + "layout_eval: {} model(s), M={} seeds (sampling {} unique), out={}", + specs.len(), + knobs.seeds, + seeds.len(), + knobs.out, + ); + + // `per_model`, `renders`, and `facts` stay positionally paired: all three + // are pushed exactly once per surviving model. + let mut per_model = Vec::new(); + let mut renders = Vec::new(); + let mut facts = Vec::new(); + for spec in &specs { + match process_model(spec, &seeds, &knobs) { + Ok((stats, model_renders, model_facts)) => { + per_model.push(stats); + renders.push(model_renders); + facts.push(model_facts); + } + Err(err) => eprintln!("WARN: skipping {}: {err}", spec.key), + } + } + + let weights = MetricWeights::default(); + let corpus = CorpusReport::from_model_stats(per_model); + println!( + "corpus: aggregate_cost={:.4} ({} model(s) scored)", + corpus.aggregate_cost, + corpus.per_model.len(), + ); + + print_taste_summary(&facts); + + let baseline_cmp = baseline_comparison(&corpus, &weights, &knobs); + let before_report = knobs + .compare_dir + .as_ref() + .and_then(|dir| report::read_eval_report(&format!("{dir}/metrics.json"))); + let run_cmp = knobs.compare_dir.as_ref().and_then(|dir| { + let before = + report::read_corpus(&format!("{dir}/corpus.json"))?.rescored(&weights, &LAYOUT_SEEDS); + let cmp = compare(&before, &corpus); + report::print_comparison(&format!("compared run {dir}"), &cmp); + Some(cmp) + }); + + let eval = report::build_report( + &corpus.per_model, + &renders, + &facts, + corpus.aggregate_cost, + &weights, + baseline_cmp, + run_cmp, + ); + let out = &knobs.out; + report::write_json(&format!("{out}/metrics.json"), &eval); + report::write_json(&format!("{out}/corpus.json"), &corpus); + let index_path = format!("{out}/index.html"); + match std::fs::write( + &index_path, + report::render_index_html(&eval, before_report.as_ref()), + ) { + Ok(()) => println!("wrote {index_path}"), + Err(err) => eprintln!("WARN: failed to write {index_path}: {err}"), + } +} diff --git a/src/simlin-engine/examples/layout_eval/render.rs b/src/simlin-engine/examples/layout_eval/render.rs new file mode 100644 index 000000000..4c167eef2 --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/render.rs @@ -0,0 +1,157 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Rasterize views to PNG at a size a person can actually read. + +use simlin_engine::datamodel; +use simlin_engine::diagram::{PngRenderOpts, render_svg, svg_to_png}; +use simlin_engine::layout::config::LayoutConfig; +use simlin_engine::layout::metrics::{ + Defect, DefectKind, LayoutMetrics, MetricWeights, analyze_layout, compute_layout_metrics, +}; + +use crate::corpus::MAIN_MODEL; + +/// Target width for small diagrams: a textbook model rendered 1:1 is a few +/// hundred pixels wide and its 12px labels are unreadable once a contact sheet +/// shrinks it, so small diagrams are upscaled toward this width. +const TARGET_WIDTH_PX: f64 = 1200.0; + +/// The largest upscale applied: past this a tiny diagram is just blurry. +const MAX_UPSCALE: f64 = 2.5; + +/// One rendered diagram: its PNG filename (relative to the out dir), the seed +/// that produced it (`None` for the reference and for production, which picks +/// among several seeds), and the metrics of exactly the geometry rendered. +pub struct Render { + pub file: String, + pub seed: Option, + pub metrics: LayoutMetrics, + pub weighted_cost: f64, +} + +/// The intrinsic width of a rendered SVG, from its `viewBox`. +fn svg_width(svg: &str) -> Option { + let start = svg.find("viewBox=\"")? + "viewBox=\"".len(); + let end = start + svg[start..].find('"')?; + svg[start..end].split_whitespace().nth(2)?.parse().ok() +} + +/// Upscale factor for a diagram of intrinsic width `w`: grow small diagrams +/// toward `TARGET_WIDTH_PX` (capped), never shrink a large one -- its full +/// resolution is what a zoomed-in look needs. +fn upscale_for(w: f64) -> f64 { + if w <= 0.0 { + 1.0 + } else { + (TARGET_WIDTH_PX / w).clamp(1.0, MAX_UPSCALE) + } +} + +/// SVG marks for what the metric charged: one shape per defect, colored by +/// kind, drawn over the diagram so a look at the picture shows what the score +/// sees -- a defect the eye finds but no mark covers is a blind spot, and a +/// mark over something that reads fine is a false positive. +fn defect_overlay(defects: &[Defect]) -> String { + let mut svg = String::from(""); + for d in defects { + let [l, t, r, b] = d.region; + let (w, h) = ((r - l).max(0.0), (b - t).max(0.0)); + let rect = |stroke: &str, fill: &str, dash: &str| { + format!( + "" + ) + }; + let dot = |color: &str, radius: f64| { + format!( + "", + (l + r) / 2.0, + (t + b) / 2.0 + ) + }; + svg.push_str(&match d.kind { + DefectKind::NodeOverlap => rect("#d32f2f", "rgba(211,47,47,0.35)", "none"), + DefectKind::ConnectorThroughNode => rect("#ef6c00", "none", "3,2"), + DefectKind::LabelObscured => rect("#c2185b", "rgba(194,24,91,0.15)", "none"), + DefectKind::LabelCrossed => rect("#7b1fa2", "none", "2,2"), + DefectKind::Crowded => rect("#f9a825", "none", "1,2"), + DefectKind::Crossing => dot("#1565c0", 3.5), + DefectKind::LongConnector => dot("#2e7d32", 6.0), + }); + } + svg.push_str(""); + svg +} + +fn rasterize(svg: &str, file: &str, out: &str) -> bool { + let width = svg_width(svg).map(|w| (w * upscale_for(w)).round() as u32); + let png = match svg_to_png( + svg, + &PngRenderOpts { + width, + height: None, + }, + ) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("WARN: failed to rasterize {file}: {err}"); + return false; + } + }; + let path = format!("{out}/{file}"); + if let Err(err) = std::fs::write(&path, &png) { + eprintln!("WARN: failed to write {path}: {err}"); + return false; + } + true +} + +/// Render `view` (installed into a clone of `project`) to `{out}/{file}` and +/// score it; with `overlay`, also write `{stem}_defects.png` with the metric's +/// defects drawn over it. On any failure WARN and return `None` so the sweep +/// continues. +pub fn render_view( + project: &datamodel::Project, + view: &datamodel::StockFlow, + seed: Option, + file: &str, + out: &str, + overlay: bool, +) -> Option { + let mut p = project.clone(); + p.get_model_mut(MAIN_MODEL)?.views = vec![datamodel::View::StockFlow(view.clone())]; + let svg = match render_svg(&p, MAIN_MODEL) { + Ok(svg) => svg, + Err(err) => { + eprintln!("WARN: failed to render {file}: {err}"); + return None; + } + }; + if !rasterize(&svg, file, out) { + return None; + } + // The rendered view itself, for inspection and for replaying a judgment + // against a later metric: `{stem}.view.json` in the JSON project schema. + if let Some(stem) = file.strip_suffix(".png") { + let json_view = simlin_engine::json::View::from(datamodel::View::StockFlow(view.clone())); + if let Ok(text) = serde_json::to_string(&json_view) { + let _ = std::fs::write(format!("{out}/{stem}.view.json"), text); + } + } + if overlay && let Some(stem) = file.strip_suffix(".png") { + let analysis = analyze_layout(view); + if let Some(end) = svg.rfind("") { + let marked = format!("{}{}", &svg[..end], defect_overlay(&analysis.defects)); + rasterize(&marked, &format!("{stem}_defects.png"), out); + } + } + let metrics = compute_layout_metrics(view, &LayoutConfig::default()); + Some(Render { + file: file.to_string(), + seed, + metrics, + weighted_cost: metrics.weighted_cost(&MetricWeights::default()), + }) +} diff --git a/src/simlin-engine/examples/layout_eval/replay.rs b/src/simlin-engine/examples/layout_eval/replay.rs new file mode 100644 index 000000000..8809ec232 --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/replay.rs @@ -0,0 +1,186 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Replay a model's construction the way an agent or a notebook user builds +//! one: a few edits, each adding a group of variables, with the diagram synced +//! after every edit by the production path -- `generate_best_layout` while the +//! view is empty, `incremental_layout` after that (the rule MCP `edit_model` +//! and pysimlin's patch sync follow). The final diagram is what such a user +//! ends up looking at, and it can be very different from a fresh layout of the +//! finished model: incremental layout preserves everything already placed. + +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::time::Instant; + +use simlin_engine::datamodel::{self, StockFlow, Variable}; +use simlin_engine::layout::{compute_layout_metadata, generate_best_layout, incremental_layout}; +use simlin_engine::{ModelOperation, ModelPatch, ProjectPatch, apply_patch, canonicalize}; + +use crate::corpus::MAIN_MODEL; + +/// The replayed build: its final view, how many edits it took, and the total +/// wall-clock milliseconds of every diagram sync along the way. +pub struct Replay { + pub view: StockFlow, + pub steps: usize, + pub elapsed_ms: f64, +} + +/// The model's variables grouped into build units in the order a person builds +/// them: each stock-flow chain as one unit (a stock arrives together with its +/// flows, so no flow references an absent stock), largest first; then every +/// other variable singly, nearest the backbone first (breadth-first over the +/// dependency graph from the chains), with unconnected variables last by name. +fn build_units<'a>( + project: &datamodel::Project, + model: &'a datamodel::Model, +) -> Result>, String> { + let metadata = compute_layout_metadata(project, MAIN_MODEL, None) + .ok_or_else(|| "no layout metadata".to_string())?; + let by_ident: HashMap = model + .variables + .iter() + .map(|v| (canonicalize(v.get_ident()).into_owned(), v)) + .collect(); + + let mut placed: BTreeSet = BTreeSet::new(); + let mut units: Vec> = Vec::new(); + let mut chains: Vec<&simlin_engine::layout::metadata::StockFlowChain> = + metadata.chains.iter().collect(); + chains.sort_by(|a, b| { + b.all_vars + .len() + .cmp(&a.all_vars.len()) + .then_with(|| a.all_vars.cmp(&b.all_vars)) + }); + for chain in chains { + let unit: Vec<&Variable> = chain + .all_vars + .iter() + .filter(|ident| placed.insert((*ident).clone())) + .filter_map(|ident| by_ident.get(ident).copied()) + .collect(); + if !unit.is_empty() { + units.push(unit); + } + } + + // Breadth-first from the backbone over dependencies in both directions. + let neighbors = |ident: &str| -> Vec { + let mut out: Vec = Vec::new(); + for graph in [&metadata.dep_graph, &metadata.reverse_dep_graph] { + if let Some(set) = graph.get(ident) { + out.extend(set.iter().cloned()); + } + } + out + }; + let mut queue: VecDeque = placed.iter().cloned().collect(); + while let Some(ident) = queue.pop_front() { + for next in neighbors(&ident) { + if by_ident.contains_key(&next) && placed.insert(next.clone()) { + units.push(vec![by_ident[&next]]); + queue.push_back(next); + } + } + } + let mut rest: Vec<&String> = by_ident.keys().filter(|k| !placed.contains(*k)).collect(); + rest.sort(); + for ident in rest { + units.push(vec![by_ident[ident]]); + } + Ok(units) +} + +/// Split ordered units into at most `steps` contiguous edits of roughly equal +/// variable counts, never splitting a unit. +fn batch_units(units: Vec>, steps: usize) -> Vec> { + let total: usize = units.iter().map(Vec::len).sum(); + let target = total.div_ceil(steps.max(1)).max(1); + let mut batches: Vec> = Vec::new(); + let mut current: Vec<&Variable> = Vec::new(); + for unit in units { + if !current.is_empty() && current.len() + unit.len() > target && batches.len() + 1 < steps { + batches.push(std::mem::take(&mut current)); + } + current.extend(unit); + } + if !current.is_empty() { + batches.push(current); + } + batches +} + +fn upsert(var: &Variable) -> ModelOperation { + match var { + Variable::Stock(s) => ModelOperation::UpsertStock(s.clone()), + Variable::Flow(f) => ModelOperation::UpsertFlow(f.clone()), + Variable::Aux(a) => ModelOperation::UpsertAux(a.clone()), + Variable::Module(m) => ModelOperation::UpsertModule(m.clone()), + } +} + +/// Build `project`'s main model from empty in `steps` edits, syncing the +/// diagram after each, and return the final diagram. +pub fn replay(key: &str, project: &datamodel::Project, steps: usize) -> Option { + let run = || -> Result { + let model = project + .get_model(MAIN_MODEL) + .ok_or_else(|| "no main model".to_string())?; + let model_name = model.name.clone(); + let batches = batch_units(build_units(project, model)?, steps); + + let mut building = project.clone(); + { + let empty = building + .get_model_mut(MAIN_MODEL) + .ok_or_else(|| "no main model".to_string())?; + empty.variables.clear(); + empty.views.clear(); + empty.loop_metadata.clear(); + } + + let mut view: Option = None; + let mut elapsed_ms = 0.0; + for batch in &batches { + let model_patch = ModelPatch { + name: model_name.clone(), + ops: batch.iter().map(|v| upsert(v)).collect(), + }; + apply_patch( + &mut building, + ProjectPatch { + project_ops: vec![], + models: vec![model_patch.clone()], + }, + ) + .map_err(|e| format!("patch failed: {e:?}"))?; + let start = Instant::now(); + let synced = match &view { + Some(old) if !old.elements.is_empty() => { + incremental_layout(old, &building, MAIN_MODEL, &model_patch, None) + } + _ => generate_best_layout(&building, MAIN_MODEL, None), + }?; + elapsed_ms += start.elapsed().as_secs_f64() * 1000.0; + if let Some(m) = building.get_model_mut(MAIN_MODEL) { + m.views = vec![datamodel::View::StockFlow(synced.clone())]; + } + view = Some(synced); + } + let view = view.ok_or_else(|| "model has no variables".to_string())?; + Ok(Replay { + view, + steps: batches.len(), + elapsed_ms, + }) + }; + match run() { + Ok(replay) => Some(replay), + Err(err) => { + eprintln!("WARN: {key} incremental replay failed: {err}"); + None + } + } +} diff --git a/src/simlin-engine/examples/layout_eval/report.rs b/src/simlin-engine/examples/layout_eval/report.rs new file mode 100644 index 000000000..e3122db6a --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/report.rs @@ -0,0 +1,573 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! The on-disk artifacts: `metrics.json` (per-model breakdowns), `corpus.json` +//! (the per-seed samples a later run diffs against), and the `index.html` +//! contact sheet. Building the report and rendering HTML are pure reads over +//! the sweep's results; the only I/O is in the `write_*`/`read_*` shells. + +use std::fmt::Write as _; + +use serde::{Deserialize, Serialize}; +use simlin_engine::layout::eval_stats::{Comparison, CorpusReport, ModelStats}; +use simlin_engine::layout::metrics::{LayoutMetrics, MetricWeights}; + +use crate::corpus::{Reference, Tier}; +use crate::render::Render; +use crate::taste::{TasteCheck, tally}; + +/// Everything rendered for one model. A render that failed is `None` (already +/// WARN-logged); the contact sheet records the gap rather than hiding it. +pub struct ModelRenders { + pub reference: Option, + pub production: Option, + pub incremental: Option, + pub median: Option, + pub worst: Option, +} + +/// Facts about a model the report carries beside its statistics. +pub struct ModelFacts { + pub tier: Tier, + pub reference: Reference, + pub variables: usize, + /// Wall-clock milliseconds of the production `generate_best_layout` call. + pub production_ms: Option, + /// Total wall-clock milliseconds of the incremental replay's diagram syncs. + pub incremental_ms: Option, + /// Taste checks over the reference (empty when it is not one diagram). + pub taste_reference: Vec, + /// Taste checks over the production layout. + pub taste_production: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct RenderReport { + pub file: String, + pub seed: Option, + pub metrics: LayoutMetrics, + pub weighted_cost: f64, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct ModelReport { + pub model: String, + pub tier: String, + pub reference_kind: String, + pub variables: usize, + /// Number of seeds swept. + pub m: usize, + pub median_cost: f64, + /// `(p25, p75)` of the per-seed weighted costs. + pub spread: (f64, f64), + /// Production proxy: min weighted cost over the `LAYOUT_SEEDS` seed set. + pub best_of_k_cost: f64, + pub best_seed: u64, + pub median_seed: u64, + pub worst_seed: u64, + pub production_ms: Option, + #[serde(default)] + pub incremental_ms: Option, + #[serde(default)] + pub taste_reference: Vec, + #[serde(default)] + pub taste_production: Vec, + pub reference: Option, + pub production: Option, + #[serde(default)] + pub incremental: Option, + pub median: Option, + pub worst: Option, +} + +/// The `metrics.json` document. `baseline_comparison` diffs against the +/// committed baseline, `run_comparison` against `LAYOUT_EVAL_COMPARE`'s run; +/// both are re-scored under this run's weights before comparing. +#[derive(Serialize, Deserialize)] +pub struct EvalReport { + /// Models sorted worst-cost-first (highest `median_cost` at the front), the + /// order the contact sheet inspects top-down. + pub models: Vec, + /// Shifted geometric mean of the per-model medians. + pub aggregate_cost: f64, + pub weights: MetricWeights, + #[serde(skip_serializing_if = "Option::is_none", default, skip_deserializing)] + pub baseline_comparison: Option, + #[serde(skip_serializing_if = "Option::is_none", default, skip_deserializing)] + pub run_comparison: Option, +} + +fn render_report(render: &Render) -> RenderReport { + RenderReport { + file: render.file.clone(), + seed: render.seed, + metrics: render.metrics, + weighted_cost: render.weighted_cost, + } +} + +fn snake(value: &T) -> String { + serde_json::to_value(value) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default() +} + +/// Build the serializable report. PURE: a read over the positionally paired +/// `(stats, renders, facts)` plus the aggregate and weights. Models are sorted +/// worst-cost-first; ties break on the name so the order is deterministic. +pub fn build_report( + per_model: &[ModelStats], + renders: &[ModelRenders], + facts: &[ModelFacts], + aggregate_cost: f64, + weights: &MetricWeights, + baseline_comparison: Option, + run_comparison: Option, +) -> EvalReport { + let mut models: Vec = per_model + .iter() + .zip(renders) + .zip(facts) + .map(|((stats, render), fact)| ModelReport { + model: stats.model.clone(), + tier: snake(&fact.tier), + reference_kind: snake(&fact.reference), + variables: fact.variables, + m: stats.samples.len(), + median_cost: stats.median_cost, + spread: stats.spread, + best_of_k_cost: stats.best_of_k_cost, + best_seed: stats.best_seed, + median_seed: stats.median_seed, + worst_seed: stats.worst_seed, + production_ms: fact.production_ms, + incremental_ms: fact.incremental_ms, + taste_reference: fact.taste_reference.clone(), + taste_production: fact.taste_production.clone(), + reference: render.reference.as_ref().map(render_report), + production: render.production.as_ref().map(render_report), + incremental: render.incremental.as_ref().map(render_report), + median: render.median.as_ref().map(render_report), + worst: render.worst.as_ref().map(render_report), + }) + .collect(); + models.sort_by(|a, b| { + b.median_cost + .partial_cmp(&a.median_cost) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.model.cmp(&b.model)) + }); + EvalReport { + models, + aggregate_cost, + weights: *weights, + baseline_comparison, + run_comparison, + } +} + +/// HTML-escape the five characters special in element text or attribute +/// values. Model keys and filenames are static, so this is defense in depth. +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn fmt_delta_pct(ratio: f64) -> String { + format!("{:+.2}%", ratio * 100.0) +} + +/// One render's cell: heading, a thumbnail linking to the full-size PNG, and +/// the per-term breakdown. `before` is the same render kind from the compared +/// run, when present, whose terms are shown alongside. PURE. +fn write_render_cell( + html: &mut String, + kind: &str, + render: Option<&RenderReport>, + before: Option<&RenderReport>, +) { + html.push_str("
"); + let _ = write!(html, "

{}

", html_escape(kind)); + let Some(r) = render else { + html.push_str("

(not rendered)

"); + return; + }; + let src = html_escape(&r.file); + let _ = write!( + html, + "\"{}", + html_escape(kind) + ); + if let Some(seed) = r.seed { + let _ = write!(html, "

seed {seed}

"); + } + html.push_str(""); + let before_rows = before.map(|b| b.metrics.terms()); + for (i, (name, value)) in r.metrics.terms().into_iter().enumerate() { + let _ = write!(html, ""); + if let Some(rows) = &before_rows { + let old = rows[i].1; + let class = if (value - old).abs() < 1e-9 { + "same" + } else if value < old { + "better" + } else { + "worse" + }; + let _ = write!(html, ""); + } + html.push_str(""); + } + let _ = write!( + html, + "", + r.weighted_cost + ); + if let Some(b) = before { + let _ = write!(html, "", b.weighted_cost); + } + html.push_str("
{name}{value:.4}{old:.4}
weighted_cost{:.4}{:.4}
"); +} + +/// One model's taste checks as a compact row of `degradation +delta%` chips, +/// red where the metric failed to penalize the degradation. PURE. +fn write_taste_row(html: &mut String, kind: &str, checks: &[TasteCheck]) { + if checks.is_empty() { + return; + } + let (noticed, applicable) = tally(checks); + let _ = write!( + html, + "

taste checks on {kind}: {noticed}/{applicable} noticed · " + ); + for c in checks { + match (c.delta_ratio, c.noticed) { + (Some(r), Some(true)) => { + let _ = write!( + html, + "{} {} ", + c.degradation, + fmt_delta_pct(r) + ); + } + (Some(r), _) => { + let _ = write!( + html, + "{} {} ", + c.degradation, + fmt_delta_pct(r) + ); + } + _ => { + let _ = write!(html, "{} n/a ", c.degradation); + } + } + } + html.push_str("

"); +} + +/// The corpus-wide taste matrix: for each degradation, how many diagrams the +/// metric penalized it on, over references and over production layouts. PURE. +fn write_taste_summary(html: &mut String, report: &EvalReport) { + let Some(first) = report + .models + .iter() + .find(|m| !m.taste_production.is_empty()) + else { + return; + }; + html.push_str( + "

Taste checks (does the metric penalize a visibly worse edit?)

\ + ", + ); + for (i, check) in first.taste_production.iter().enumerate() { + let column = |select: fn(&ModelReport) -> &Vec| { + tally(report.models.iter().filter_map(|m| select(m).get(i))) + }; + let (rn, ra) = column(|m| &m.taste_reference); + let (pn, pa) = column(|m| &m.taste_production); + let class = |n: usize, a: usize| if n == a { "better" } else { "worse" }; + let _ = write!( + html, + "", + check.degradation, + class(rn, ra), + class(pn, pa), + ); + } + html.push_str("
degradationreferencesproduction
{}{rn}/{ra}{pn}/{pa}
\n"); +} + +/// A comparison table: the aggregate verdict and per-model deltas. PURE. +fn write_comparison(html: &mut String, title: &str, cmp: &Comparison) { + let _ = write!( + html, + "

{}

", + html_escape(title) + ); + let (class, verdict) = if cmp.aggregate_significant { + ("sig", "significant") + } else { + ("nonsig", "not significant") + }; + let _ = write!( + html, + "

aggregate delta {} · paired p={:.4} · \ + {verdict}

", + fmt_delta_pct(cmp.aggregate_delta_ratio), + cmp.aggregate_p_value, + ); + html.push_str( + "\ + ", + ); + for m in &cmp.per_model { + let (cls, verdict) = if !m.significant { + ("nonsig", "—") + } else if m.delta_ratio < 0.0 { + ("better", "better") + } else { + ("worse", "worse") + }; + let _ = write!( + html, + "\ + ", + html_escape(&m.model), + m.baseline_median, + m.candidate_median, + fmt_delta_pct(m.delta_ratio), + m.p_value, + ); + } + html.push_str("
modelbeforeafterdeltapverdict
{}{:.4}{:.4}{}{:.4}{verdict}
\n"); +} + +/// Render the self-contained `index.html` contact sheet. `before` is the +/// compared run's report (for per-term deltas), when one was given. PURE. +pub fn render_index_html(report: &EvalReport, before: Option<&EvalReport>) -> String { + let mut html = String::new(); + html.push_str( + "\n\n\n\n\ + \n\ + Layout quality eval\n\n\n\n", + ); + html.push_str("

Layout quality eval

\n"); + let total_ms: f64 = report.models.iter().filter_map(|m| m.production_ms).sum(); + let _ = writeln!( + &mut html, + "

Corpus aggregate_cost = {:.4} over {} model(s), \ + sorted worst-cost-first · production layout time {:.1}s total{}.

", + report.aggregate_cost, + report.models.len(), + total_ms / 1000.0, + if before.is_some() { + " · grey columns are the compared run" + } else { + "" + }, + ); + + html.push_str(""); + for (name, value) in report.weights.terms() { + let _ = write!( + &mut html, + "" + ); + } + html.push_str("
weights
{name}{value:.4}
\n"); + + write_taste_summary(&mut html, report); + + if let Some(cmp) = &report.run_comparison { + write_comparison(&mut html, "Compared run", cmp); + } + match &report.baseline_comparison { + Some(cmp) => write_comparison(&mut html, "Committed baseline", cmp), + None => html.push_str( + "

No baseline diff (run with \ + LAYOUT_EVAL_WRITE_BASELINE=1 to seed one).

\n", + ), + } + + for model in &report.models { + let prior = before.and_then(|b| b.models.iter().find(|m| m.model == model.model)); + html.push_str("
"); + let _ = write!(&mut html, "

{}

", html_escape(&model.model)); + let timing = match (model.production_ms, prior.and_then(|p| p.production_ms)) { + (Some(ms), Some(old)) => format!(" · production {ms:.0}ms (was {old:.0}ms)"), + (Some(ms), None) => format!(" · production {ms:.0}ms"), + _ => String::new(), + }; + let _ = write!( + &mut html, + "

{} · {} variables · reference: {} · \ + median={:.4} · p25/p75={:.4}/{:.4} · best_of_k={:.4} · M={}{timing}

", + html_escape(&model.tier), + model.variables, + html_escape(&model.reference_kind), + model.median_cost, + model.spread.0, + model.spread.1, + model.best_of_k_cost, + model.m, + ); + html.push_str("
"); + write_render_cell(&mut html, "reference", model.reference.as_ref(), None); + write_render_cell( + &mut html, + "production", + model.production.as_ref(), + prior.and_then(|p| p.production.as_ref()), + ); + write_render_cell( + &mut html, + "incremental", + model.incremental.as_ref(), + prior.and_then(|p| p.incremental.as_ref()), + ); + write_render_cell( + &mut html, + "median", + model.median.as_ref(), + prior.and_then(|p| p.median.as_ref()), + ); + write_render_cell( + &mut html, + "worst", + model.worst.as_ref(), + prior.and_then(|p| p.worst.as_ref()), + ); + html.push_str("
"); + write_taste_row(&mut html, "reference", &model.taste_reference); + write_taste_row(&mut html, "production", &model.taste_production); + html.push_str("
\n"); + } + html.push_str("\n\n"); + html +} + +/// Print a comparison to stdout, one line per model plus the aggregate. +pub fn print_comparison(title: &str, cmp: &Comparison) { + println!("{title} (after vs before):"); + for m in &cmp.per_model { + let verdict = if m.significant { + "significant" + } else { + "not significant" + }; + println!( + " {}: {:.4} -> {:.4} delta={} p={:.4} ({verdict})", + m.model, + m.baseline_median, + m.candidate_median, + fmt_delta_pct(m.delta_ratio), + m.p_value, + ); + } + let verdict = if cmp.aggregate_significant { + "significant" + } else { + "not significant" + }; + println!( + " aggregate: delta={} paired p={:.4} ({verdict})", + fmt_delta_pct(cmp.aggregate_delta_ratio), + cmp.aggregate_p_value, + ); +} + +/// Serialize `value` as pretty JSON to `path`, WARN-logging any failure. +pub fn write_json(path: &str, value: &T) { + match serde_json::to_string_pretty(value) { + Ok(json) => match std::fs::write(path, json) { + Ok(()) => println!("wrote {path}"), + Err(err) => eprintln!("WARN: failed to write {path}: {err}"), + }, + Err(err) => eprintln!("WARN: failed to serialize {path}: {err}"), + } +} + +/// Read a `CorpusReport` (the committed baseline, or a run's `corpus.json`). +/// A missing file is a quiet `None`; an unreadable or unparseable one WARNs. +pub fn read_corpus(path: &str) -> Option { + let json = match std::fs::read_to_string(path) { + Ok(json) => json, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + println!("no corpus report at {path}"); + return None; + } + Err(err) => { + eprintln!("WARN: failed to read {path}: {err}"); + return None; + } + }; + match serde_json::from_str(&json) { + Ok(report) => Some(report), + Err(err) => { + eprintln!("WARN: failed to parse {path}: {err}"); + None + } + } +} + +/// Read a previous run's `metrics.json`, for per-term deltas in the sheet. +pub fn read_eval_report(path: &str) -> Option { + let json = std::fs::read_to_string(path).ok()?; + match serde_json::from_str(&json) { + Ok(report) => Some(report), + Err(err) => { + eprintln!("WARN: failed to parse {path}: {err}"); + None + } + } +} diff --git a/src/simlin-engine/examples/layout_eval/sweep.rs b/src/simlin-engine/examples/layout_eval/sweep.rs new file mode 100644 index 000000000..b2ff19431 --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/sweep.rs @@ -0,0 +1,120 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Lay models out: the per-seed sweep that characterizes the algorithm's +//! quality distribution, and the production call users actually get. + +use std::time::Instant; + +use rayon::prelude::*; +use simlin_engine::datamodel; +use simlin_engine::layout::config::LayoutConfig; +use simlin_engine::layout::eval_stats::{MetricSample, ModelStats}; +use simlin_engine::layout::metrics::{MetricWeights, compute_layout_metrics}; +use simlin_engine::layout::{LAYOUT_SEEDS, generate_best_layout, generate_layout_with_config}; + +use crate::corpus::MAIN_MODEL; + +/// The seeds to sample: the union of the production seed set (`LAYOUT_SEEDS`) +/// and `0..m`, deduped and sorted, so the best-of-k production proxy is always +/// computable regardless of `m`. +pub fn seed_set(m: u64) -> Vec { + let mut seeds: std::collections::BTreeSet = (0..m).collect(); + seeds.extend(LAYOUT_SEEDS); + seeds.into_iter().collect() +} + +/// The layout config the sweep uses for one seed. +pub fn seed_config(seed: u64, declutter: bool) -> LayoutConfig { + LayoutConfig { + annealing_random_seed: seed, + declutter, + ..LayoutConfig::default() + } +} + +/// Lay out `project`'s main model once per seed, score each layout, and +/// summarize the samples. +/// +/// The per-seed layouts run in parallel; the results are collapsed back into +/// seed order before summarizing, so every statistic is invariant to rayon's +/// scheduling. `generate_layout_with_config` is deterministic per seed (#633). +/// A seed whose layout fails is dropped with a WARN; a model that fails on +/// every seed yields empty `samples`, which the caller treats as a model-level +/// failure. +pub fn sweep_model( + key: &str, + project: &datamodel::Project, + seeds: &[u64], + declutter: bool, +) -> ModelStats { + let mut indexed: Vec<(u64, MetricSample)> = seeds + .par_iter() + .filter_map(|&seed| { + let cfg = seed_config(seed, declutter); + match generate_layout_with_config(project, MAIN_MODEL, cfg.clone(), None) { + Ok(view) => { + let metrics = compute_layout_metrics(&view, &cfg); + let weighted_cost = metrics.weighted_cost(&MetricWeights::default()); + Some(( + seed, + MetricSample { + seed, + metrics, + weighted_cost, + }, + )) + } + Err(err) => { + eprintln!("WARN: {key} seed {seed} failed to lay out: {err}"); + None + } + } + }) + .collect(); + indexed.sort_by_key(|(seed, _)| *seed); + let samples = indexed.into_iter().map(|(_, sample)| sample).collect(); + ModelStats::from_samples(key.to_string(), samples, &LAYOUT_SEEDS) +} + +/// A regenerated view for one seed (the sweep keeps only scores, so renders +/// regenerate the layouts they draw). `None` (WARN-logged) on failure. +pub fn seed_view( + key: &str, + project: &datamodel::Project, + seed: u64, + declutter: bool, +) -> Option { + match generate_layout_with_config(project, MAIN_MODEL, seed_config(seed, declutter), None) { + Ok(view) => Some(view), + Err(err) => { + eprintln!("WARN: {key} seed {seed} failed to lay out: {err}"); + None + } + } +} + +/// What production hands a user: `generate_best_layout`'s view and how long +/// the call took end to end (metadata extraction, LTM loop detection, and the +/// parallel best-of-k search), in milliseconds. +pub struct Production { + pub view: datamodel::StockFlow, + pub elapsed_ms: f64, +} + +/// Run the production layout call once, timed. Models run one at a time, so +/// the timing is the call's own wall clock, not contended by other models. +pub fn production(key: &str, project: &datamodel::Project) -> Option { + let start = Instant::now(); + match generate_best_layout(project, MAIN_MODEL, None) { + Ok(view) => Some(Production { + view, + elapsed_ms: start.elapsed().as_secs_f64() * 1000.0, + }), + Err(err) => { + eprintln!("WARN: {key} production layout failed: {err}"); + None + } + } +} diff --git a/src/simlin-engine/examples/layout_eval/taste.rs b/src/simlin-engine/examples/layout_eval/taste.rs new file mode 100644 index 000000000..81b2f0fab --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/taste.rs @@ -0,0 +1,63 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Metamorphic taste checks: degrade a good diagram in a way every modeler +//! would call worse, and record whether the metric agrees. See +//! `layout::taste` for the degradations. + +use serde::{Deserialize, Serialize}; +use simlin_engine::datamodel::StockFlow; +use simlin_engine::layout::config::LayoutConfig; +use simlin_engine::layout::metrics::{LayoutMetrics, MetricWeights, compute_layout_metrics}; +use simlin_engine::layout::taste::{Degradation, degrade}; + +/// The smallest relative cost increase that counts as the metric noticing a +/// degradation. Anything below reads as indifference. +const NOTICE_RATIO: f64 = 0.01; + +/// One degradation's outcome on one diagram. +#[derive(Serialize, Deserialize, Clone)] +pub struct TasteCheck { + pub degradation: String, + /// `degraded / original - 1`, or `None` when the degradation did not apply. + pub delta_ratio: Option, + /// Whether the metric penalized the degradation by at least `NOTICE_RATIO`. + pub noticed: Option, + /// The degraded diagram's per-term metrics: the data a weight calibration + /// fits against (every check is a judged pair "original beats degraded"). + #[serde(default)] + pub degraded_metrics: Option, +} + +/// Every battery degradation applied to `view`. +pub fn run_battery(view: &StockFlow) -> Vec { + let cfg = LayoutConfig::default(); + let weights = MetricWeights::default(); + let base = compute_layout_metrics(view, &cfg).weighted_cost(&weights); + Degradation::battery() + .iter() + .map(|&d| { + let degraded_metrics = + degrade(view, d).map(|degraded| compute_layout_metrics(°raded, &cfg)); + // A zero-cost original has no ratio to speak of: any increase is + // "noticed", measured against a small floor. + let delta_ratio = + degraded_metrics.map(|m| (m.weighted_cost(&weights) - base) / base.max(1e-3)); + TasteCheck { + degradation: d.name().to_string(), + delta_ratio, + noticed: delta_ratio.map(|r| r >= NOTICE_RATIO), + degraded_metrics, + } + }) + .collect() +} + +/// `(noticed, applicable)` over a set of checks. +pub fn tally<'a>(checks: impl IntoIterator) -> (usize, usize) { + checks + .into_iter() + .filter_map(|c| c.noticed) + .fold((0, 0), |(n, a), noticed| (n + noticed as usize, a + 1)) +} diff --git a/src/simlin-engine/examples/layout_eval_baseline.README.md b/src/simlin-engine/examples/layout_eval_baseline.README.md index 13040a892..45d1a59f6 100644 --- a/src/simlin-engine/examples/layout_eval_baseline.README.md +++ b/src/simlin-engine/examples/layout_eval_baseline.README.md @@ -1,40 +1,33 @@ # layout_eval_baseline.json -The committed baseline `CorpusReport` that `examples/layout_eval.rs` diffs every -normal run against (per-model + aggregate deltas with Mann-Whitney U p-values). +The committed baseline `CorpusReport` that `examples/layout_eval/` diffs every +normal run against (per-model Mann-Whitney verdicts plus a paired signed-rank +aggregate). A run re-scores the stored per-term metrics under its own weights +before comparing, so a weight change is a pure re-weighting of the same +layouts; a metric TERM added after the baseline was written reads as `0` on +the baseline side until it is re-seeded. ## How this snapshot was seeded -This baseline covers the **whole corpus** (including the large metasd Vensim -models) at a reduced seed count, so any model's regression trips the diff: +The whole corpus at a reduced seed count, so any model's regression trips the +diff: ``` LAYOUT_EVAL_SEEDS=8 LAYOUT_EVAL_WRITE_BASELINE=1 \ cargo run --release -p simlin-engine --features png_render,file_io --example layout_eval ``` -It records the layout behavior **after the quiescence work** (Hu's adaptive -SFDP schedule, isolated-variable parking) and **with the sprawl compactness -counterweight** (`MetricWeights::default().sprawl = 0.1`) -- re-seeded on -2026-05-31 when that weight landed, since weighted costs under the old -weights are not comparable. - -The sweep is minutes-scale (the wrld3/covid19 models dominate); the committed -JSON is ~100-200KB. Both are acceptable for a tripwire that is regenerated -rarely and diffed on every eval run. +The sweep takes a few minutes (WRLD3 and covid19 dominate); the JSON is a few +hundred KB. ## When to regenerate -REGENERATE this baseline: - -- **Whenever the calibrated `MetricWeights::default()` change**: the weighted - costs change, so the recorded sample costs go stale. -- **Whenever the corpus aggregate definition changes** (e.g. the - `geomean_of_medians` -> `aggregate_cost` switch): the old JSON no longer - deserializes, and a normal run will WARN and skip the diff until re-seeded. -- **After landing an intentional layout-quality improvement**: re-seed so the - baseline reflects the new behavior and the next change is measured against - it (each rung of the hill-climb re-seeds after it lands). +- **When a metric term is added or its definition changes**: the stored term + values no longer mean what the current metric computes. +- **When the corpus changes**: models missing from either side are skipped by + the comparison, so a renamed or replaced model silently drops out. +- **After landing an intentional layout-quality improvement**, so the next + change is measured against it. -Re-run the seeding command above and commit the regenerated -`layout_eval_baseline.json`. +Weight-only changes do NOT need a re-seed (the comparison re-scores both +sides). diff --git a/src/simlin-engine/examples/layout_eval_baseline.json b/src/simlin-engine/examples/layout_eval_baseline.json index 4e5a06ada..f82566336 100644 --- a/src/simlin-engine/examples/layout_eval_baseline.json +++ b/src/simlin-engine/examples/layout_eval_baseline.json @@ -6,214 +6,250 @@ { "seed": 0, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 1, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 2, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 3, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 4, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 5, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 6, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 7, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 42, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 123, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 456, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 }, { "seed": 789, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.7932025869461911, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7662647586228936, + "long_connectors": 0.0, + "edge_length_cv": 0.2767344706751235, + "aspect_penalty": 0.927974434611603, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.1915661896557234 } ], - "median_cost": 0.19765785842970066, + "median_cost": 0.1915661896557234, "spread": [ - 0.19765785842970066, - 0.19765785842970066 + 0.1915661896557234, + 0.1915661896557234 ], - "best_of_k_cost": 0.19765785842970066, + "best_of_k_cost": 0.1915661896557234, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -227,33 +263,39 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.05986055020151664, "crossings": 0.0, - "sprawl": 0.8836152259425175, - "edge_length_cv": 0.37369814882506985, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7740016226085579, + "long_connectors": 0.0, + "edge_length_cv": 0.3757057137640149, + "aspect_penalty": 0.13361488481781802, + "misalignment": 0.4444444444444444, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.1767230451885035 + "weighted_cost": 0.3277356753988589 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.033944624958484985, + "node_connector_overlap": 0.03288886650355453, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.2222222222222222, - "sprawl": 0.6209866914411803, - "edge_length_cv": 0.5730547821116815, - "aspect_penalty": 2.5436507936507935, - "chain_straightness": 0.0, + "crowding": 0.04872327973855522, + "sprawl": 0.6455218876577643, + "long_connectors": 0.0, + "edge_length_cv": 0.5256151935161084, + "aspect_penalty": 3.1303854875283443, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3803641854689433 + "weighted_cost": 0.5106037068823275 }, { "seed": 2, @@ -261,16 +303,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.760183886538782, - "edge_length_cv": 0.2921899080548419, - "aspect_penalty": 1.3826699834162524, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7522349327869304, + "long_connectors": 0.0, + "edge_length_cv": 0.2926147525997858, + "aspect_penalty": 2.041638176812066, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.15203677730775642 + "weighted_cost": 0.20055873319673262 }, { "seed": 3, @@ -278,16 +323,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.125, "crossings": 0.0, - "sprawl": 0.8620670400074891, - "edge_length_cv": 0.29840579737450973, - "aspect_penalty": 1.1242090651822654, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.7678703008266486, + "long_connectors": 0.0, + "edge_length_cv": 0.2978086522468397, + "aspect_penalty": 1.303855290488591, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.17241340800149785 + "weighted_cost": 0.3919675752066622 }, { "seed": 4, @@ -295,33 +343,39 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.2222222222222222, - "sprawl": 0.8419573080245638, - "edge_length_cv": 0.2070469549586528, - "aspect_penalty": 1.2046165884194053, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.8342509597581058, + "long_connectors": 0.0, + "edge_length_cv": 0.20639921762572944, + "aspect_penalty": 1.8390933913413927, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.390613683827135 + "weighted_cost": 0.4432849621617487 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009298152814814695, + "node_connector_overlap": 0.006744239909242694, "label_overlap": 0.0, + "label_connector_overlap": 0.010818700666470534, "crossings": 0.0, - "sprawl": 0.9463824608199494, - "edge_length_cv": 0.356464037268458, - "aspect_penalty": 0.6148775894538607, - "chain_straightness": 0.0, + "crowding": 0.11821097099555047, + "sprawl": 0.9581861565599862, + "long_connectors": 0.0, + "edge_length_cv": 0.3501661619947328, + "aspect_penalty": 0.8363526570048312, + "misalignment": 0.25, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.1985746449788046 + "weighted_cost": 0.41247404095373824 }, { "seed": 6, @@ -329,16 +383,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.06400646284962555, "crossings": 0.0, - "sprawl": 0.8821366289829145, + "crowding": 0.06104179019223477, + "sprawl": 0.8752705499420612, + "long_connectors": 0.0, "edge_length_cv": 0.3078393138268285, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "aspect_penalty": 0.2198298777246146, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.1764273257965829 + "weighted_cost": 0.41336912195218845 }, { "seed": 7, @@ -346,50 +403,59 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0798884908166642, "crossings": 0.0, - "sprawl": 0.9629366509286451, - "edge_length_cv": 0.16736351313364584, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.845637473008386, + "long_connectors": 0.0, + "edge_length_cv": 0.16413185340737232, + "aspect_penalty": 0.04149957577706642, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19258733018572904 + "weighted_cost": 0.36874210447709277 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0034061861475130606, + "node_connector_overlap": 0.03124960334962321, "label_overlap": 0.0, - "crossings": 0.1111111111111111, - "sprawl": 0.6712318321999149, - "edge_length_cv": 0.46807026723224493, - "aspect_penalty": 1.81120527306968, - "chain_straightness": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.2222222222222222, + "crowding": 0.04872327973855522, + "sprawl": 0.6794323069602288, + "long_connectors": 0.0, + "edge_length_cv": 0.47572436023607845, + "aspect_penalty": 2.5949494949494953, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.24876366369860714 + "weighted_cost": 0.5408027854000811 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01952424960676582, + "node_connector_overlap": 0.01955158740825022, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.0385011725559112, - "edge_length_cv": 0.42562431304846177, - "aspect_penalty": 0.252476506004256, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.033455214901108, + "long_connectors": 0.0, + "edge_length_cv": 0.4270147268193783, + "aspect_penalty": 0.2777777777777777, + "misalignment": 0.4444444444444444, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22722448411794807 + "weighted_cost": 0.3419114229862219 }, { "seed": 456, @@ -397,16 +463,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.06260824424892301, "crossings": 0.0, - "sprawl": 0.9963435841418137, - "edge_length_cv": 0.4106297937365929, + "crowding": 0.0, + "sprawl": 0.8858498095274605, + "long_connectors": 0.0, + "edge_length_cv": 0.4168459410899187, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.25, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19926871682836275 + "weighted_cost": 0.34037481875524966 }, { "seed": 789, @@ -414,27 +483,30 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.397541275395428, - "edge_length_cv": 0.6092927845061417, - "aspect_penalty": 0.16173620863749694, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3467630826615695, + "long_connectors": 0.019524591418999173, + "edge_length_cv": 0.6234172050639732, + "aspect_penalty": 0.3057777500136649, + "misalignment": 0.33333333333333337, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2795082550790856 + "weighted_cost": 0.3797863997082253 } ], - "median_cost": 0.19892168090358367, + "median_cost": 0.38587698745744375, "spread": [ - 0.17664911534052335, - 0.25644981154372676 + 0.34152727192847887, + 0.4208480820045785 ], - "best_of_k_cost": 0.19926871682836275, + "best_of_k_cost": 0.34037481875524966, "best_seed": 2, - "median_seed": 5, - "worst_seed": 4 + "median_seed": 3, + "worst_seed": 42 }, { "model": "logistic_growth", @@ -442,1361 +514,2602 @@ { "seed": 0, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.07228406293912552, "crossings": 0.0, - "sprawl": 1.0695290083991442, + "crowding": 0.0, + "sprawl": 1.0147599018870563, + "long_connectors": 0.0, "edge_length_cv": 0.17217974049680632, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.31744430207475827, "flow_bends": 0.0, "loop_straightness": 0.2704593035495808 }, - "weighted_cost": 0.4016794528646902 + "weighted_cost": 0.5732825782081709 }, { "seed": 1, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.0395652790471699, - "edge_length_cv": 0.09779512391099372, + "crowding": 0.0, + "sprawl": 0.9933311851645652, + "long_connectors": 0.0, + "edge_length_cv": 0.08964727609905875, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.24567843839715708, + "misalignment": 0.2857142857142857, + "loop_compactness": 0.24235909976409753, "flow_bends": 0.0, - "loop_straightness": 0.25920663156109086 + "loop_straightness": 0.2609680507050972 }, - "weighted_cost": 0.3658550943244059 + "weighted_cost": 0.3999446698387186 }, { "seed": 2, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.1092486763864682, + "crowding": 0.0, + "sprawl": 1.0303129118167984, + "long_connectors": 0.0, "edge_length_cv": 0.2048318622612551, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.34827343583463755, "flow_bends": 0.0, "loop_straightness": 0.28998100631839646 }, - "weighted_cost": 0.42390721024298833 + "weighted_cost": 0.48302856006275147 }, { "seed": 3, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.06856751600669905, "crossings": 0.0, - "sprawl": 1.2132131759638447, + "crowding": 0.0, + "sprawl": 1.1270030438398368, + "long_connectors": 0.0, "edge_length_cv": 0.2376883029323176, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4285714285714286, "loop_compactness": 0.29443278938398476, "flow_bends": 0.0, "loop_straightness": 0.28968087957424843 }, - "weighted_cost": 0.4231338389037877 + "weighted_cost": 0.5742003815381694 }, { "seed": 4, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.03654326338218004, "crossings": 0.0, - "sprawl": 1.0808038959563016, + "crowding": 0.0, + "sprawl": 1.0263730401930906, + "long_connectors": 0.0, "edge_length_cv": 0.2746507084970142, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.3012663960181087, "flow_bends": 0.0, "loop_straightness": 0.26422946609706655 }, - "weighted_cost": 0.3968402842082105 + "weighted_cost": 0.51548051728135 }, { "seed": 5, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.03222493555027321, "crossings": 0.14285714285714285, - "sprawl": 1.1935142282151967, + "crowding": 0.0, + "sprawl": 1.1077019117828182, + "long_connectors": 0.0, "edge_length_cv": 0.3279090325353458, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2857142857142857, "loop_compactness": 0.6135303257902378, "flow_bends": 0.0, "loop_straightness": 0.292909775195911 }, - "weighted_cost": 0.6900130963358684 + "weighted_cost": 0.771394560535372 }, { "seed": 6, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.015907927926438914, "crossings": 0.0, - "sprawl": 1.207703269013744, + "crowding": 0.0, + "sprawl": 1.12169891980271, + "long_connectors": 0.0, "edge_length_cv": 0.13999832182315233, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.3935464903662237, "flow_bends": 0.0, "loop_straightness": 0.29753287145471186 }, - "weighted_cost": 0.4624625370947095 + "weighted_cost": 0.5486013622751538 }, { "seed": 7, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.018415418329942642, "crossings": 0.0, - "sprawl": 1.2206273000422716, + "crowding": 0.0, + "sprawl": 1.134156585450223, + "long_connectors": 0.0, "edge_length_cv": 0.1631996331385811, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.2873942369624405, "flow_bends": 0.0, "loop_straightness": 0.28677457779580046 }, - "weighted_cost": 0.42151061257301053 + "weighted_cost": 0.5119402835648831 }, { "seed": 42, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.011641854067886023, "crossings": 0.0, - "sprawl": 1.0977413618095229, + "crowding": 0.0, + "sprawl": 1.019546646391941, + "long_connectors": 0.0, "edge_length_cv": 0.1935296097191283, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2857142857142857, "loop_compactness": 0.35644245450619727, "flow_bends": 0.0, "loop_straightness": 0.2596785934540375 }, - "weighted_cost": 0.42184311350978726 + "weighted_cost": 0.4694657124191255 }, { "seed": 123, "metrics": { - "node_overlap": 0.033750000000000016, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.019368693806034232, "crossings": 0.0, - "sprawl": 1.165857662264709, + "crowding": 0.0, + "sprawl": 1.0836671526136021, + "long_connectors": 0.0, "edge_length_cv": 0.15792800546333421, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.35449169623420484, "flow_bends": 0.0, "loop_straightness": 0.2780848549440337 }, - "weighted_cost": 0.4365266964410271 + "weighted_cost": 0.5267178499933943 }, { "seed": 456, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.06517461950284437, "crossings": 0.0, - "sprawl": 1.001103334507119, + "crowding": 0.0, + "sprawl": 0.9645752245172804, + "long_connectors": 0.0, "edge_length_cv": 0.19498350334051162, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5714285714285714, "loop_compactness": 0.5224671471393787, "flow_bends": 0.0, "loop_straightness": 0.21077167161141264 }, - "weighted_cost": 0.46403469291831656 + "weighted_cost": 0.6261126185433366 }, { "seed": 789, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.009579521376098, - "edge_length_cv": 0.26640944699663793, + "crowding": 0.0, + "sprawl": 0.9402449256880457, + "long_connectors": 0.0, + "edge_length_cv": 0.260374516153528, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.32260826169216406, + "misalignment": 0.2857142857142857, + "loop_compactness": 0.3215757251462962, "flow_bends": 0.0, - "loop_straightness": 0.24558496226773144 + "loop_straightness": 0.26055607003504544 }, - "weighted_cost": 0.3892677051788584 + "weighted_cost": 0.418318557055463 } ], - "median_cost": 0.4224884762067875, + "median_cost": 0.5210991836373722, "spread": [ - 0.4004696607005702, - 0.4430106566044477 + 0.47963784815184496, + 0.5735120290406704 ], - "best_of_k_cost": 0.3892677051788584, + "best_of_k_cost": 0.418318557055463, "best_seed": 1, - "median_seed": 3, + "median_seed": 4, "worst_seed": 5 }, + { + "model": "population", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3236092942819244, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220203, + "aspect_penalty": 0.46452359255643705, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.3309023235704811 + } + ], + "median_cost": 0.3309023235704811, + "spread": [ + 0.3309023235704811, + 0.3309023235704811 + ], + "best_of_k_cost": 0.3309023235704811, + "best_seed": 0, + "median_seed": 0, + "worst_seed": 0 + }, { "model": "fishbanks", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.08568423033828318, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.042102345290493094, + "label_overlap": 0.0, + "label_connector_overlap": 0.06553822127566591, "crossings": 0.3333333333333333, - "sprawl": 1.6143573398683797, - "edge_length_cv": 0.43495266699420937, + "crowding": 0.0, + "sprawl": 2.2462060717947483, + "long_connectors": 0.0, + "edge_length_cv": 0.4252921239131767, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8452303428015734, + "misalignment": 0.4117647058823529, + "loop_compactness": 0.8312623423442776, "flow_bends": 0.0, - "loop_straightness": 0.4630819207904695 + "loop_straightness": 0.43027969649994674 }, - "weighted_cost": 1.2021583573915746 + "weighted_cost": 1.4941062509524468 }, { "seed": 1, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.006962267313529018, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.010443247137025317, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "sprawl": 1.3590826636926427, + "crowding": 0.17647058823529413, + "sprawl": 1.3956262778951116, + "long_connectors": 0.0, "edge_length_cv": 0.3431770636033404, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.17647058823529416, "loop_compactness": 0.8598427576691204, "flow_bends": 0.0, "loop_straightness": 0.39076126579718773 }, - "weighted_cost": 0.8709943595793639 + "weighted_cost": 1.1929692266468392 }, { "seed": 2, "metrics": { - "node_overlap": 0.034763948497854066, - "node_connector_overlap": 0.006884632504338363, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.006884643227649289, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.020304421681628753, "crossings": 0.06666666666666667, - "sprawl": 1.4104035925158442, - "edge_length_cv": 0.39531305968667735, + "crowding": 0.17647058823529413, + "sprawl": 1.458072207253222, + "long_connectors": 0.0, + "edge_length_cv": 0.39532563544817556, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.17647058823529416, "loop_compactness": 0.6736648685514584, "flow_bends": 0.0, "loop_straightness": 0.43350788146500446 }, - "weighted_cost": 0.7443177497878637 + "weighted_cost": 0.9879141158002509 }, { "seed": 3, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.005547168048489169, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.000011202542721830981, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "sprawl": 1.6164780320802155, - "edge_length_cv": 0.6184917294273167, + "crowding": 0.17647058823529413, + "sprawl": 1.6277153206557222, + "long_connectors": 0.0, + "edge_length_cv": 0.629801673006797, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.38449846215069705, + "misalignment": 0.3529411764705882, + "loop_compactness": 0.39894748422337123, "flow_bends": 0.0, - "loop_straightness": 0.3205882211495155 + "loop_straightness": 0.39624163645771643 }, - "weighted_cost": 0.723903311319702 + "weighted_cost": 1.0639643846596674 }, { "seed": 4, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.006962267313529018, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.010443247137025317, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "sprawl": 1.3590826636926427, + "crowding": 0.17647058823529413, + "sprawl": 1.3956262778951116, + "long_connectors": 0.0, "edge_length_cv": 0.3431770636033404, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.17647058823529416, "loop_compactness": 0.8598427576691204, "flow_bends": 0.0, "loop_straightness": 0.39076126579718773 }, - "weighted_cost": 0.8709943595793639 + "weighted_cost": 1.1929692266468392 }, { "seed": 5, "metrics": { - "node_overlap": 0.03476394849785408, + "node_overlap": 0.0, "node_connector_overlap": 0.0, - "label_overlap": 0.04110504804875194, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.0, "crossings": 0.13333333333333333, - "sprawl": 1.3246103376847838, - "edge_length_cv": 0.42943805128756574, + "crowding": 0.17647058823529413, + "sprawl": 1.3644014590436446, + "long_connectors": 0.0, + "edge_length_cv": 0.4148582598924626, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.3594978082239031, + "misalignment": 0.23529411764705888, + "loop_compactness": 0.3737982363666196, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 0.6845901873731239 + "weighted_cost": 0.8961887550241885 }, { "seed": 6, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.02102888590378746, - "label_overlap": 0.04110504804875208, + "node_overlap": 0.0, + "node_connector_overlap": 0.016950561755362952, + "label_overlap": 0.0015911702047513103, + "label_connector_overlap": 0.06848921404951298, "crossings": 0.2, - "sprawl": 1.6575940164596439, + "crowding": 0.17647058823529413, + "sprawl": 1.6922294299280263, + "long_connectors": 0.0, "edge_length_cv": 0.5031401402309018, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.3529411764705882, "loop_compactness": 0.6493083733978511, "flow_bends": 0.0, "loop_straightness": 0.3081093890430895 }, - "weighted_cost": 0.9189509740057717 + "weighted_cost": 1.267560391929434 }, { "seed": 7, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.01586118306043599, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.014806678434385297, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.13981623684865277, "crossings": 0.06666666666666667, - "sprawl": 1.5989740024432533, - "edge_length_cv": 0.45338724107118894, + "crowding": 0.17647058823529413, + "sprawl": 1.6241583223310254, + "long_connectors": 0.0, + "edge_length_cv": 0.4529341737892187, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.3529411764705882, "loop_compactness": 0.5775948806524611, "flow_bends": 0.0, "loop_straightness": 0.36560054449676893 }, - "weighted_cost": 0.7457896534730207 + "weighted_cost": 1.1969757677008166 }, { "seed": 42, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.01896177280711285, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.0189412605675763, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "sprawl": 1.4147746629675255, - "edge_length_cv": 0.3704016124212521, + "crowding": 0.18458679653650567, + "sprawl": 1.4604617019709278, + "long_connectors": 0.0, + "edge_length_cv": 0.36088212844591855, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7149476673872288, + "misalignment": 0.4117647058823529, + "loop_compactness": 0.724698316725964, "flow_bends": 0.0, - "loop_straightness": 0.4478158538908568 + "loop_straightness": 0.442280222829501 }, - "weighted_cost": 0.8418796876245346 + "weighted_cost": 1.2089138489187812 }, { "seed": 123, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.015398834072719523, - "label_overlap": 0.04110504804875194, - "crossings": 0.06666666666666667, - "sprawl": 1.5967429369599595, - "edge_length_cv": 0.5159877166443811, + "node_overlap": 0.0, + "node_connector_overlap": 0.013523078691317067, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.12912017767611159, + "crossings": 0.13333333333333333, + "crowding": 0.17647058823529413, + "sprawl": 1.633569427687596, + "long_connectors": 0.0, + "edge_length_cv": 0.5140646593053726, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8652906278835424, + "misalignment": 0.3529411764705882, + "loop_compactness": 0.8700934705537196, "flow_bends": 0.0, - "loop_straightness": 0.3184356721684644 + "loop_straightness": 0.35385406015170545 }, - "weighted_cost": 0.8552429030482476 + "weighted_cost": 1.3632087099876748 }, { "seed": 456, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.006962267313529018, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.010443247137025317, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "sprawl": 1.3590826636926427, + "crowding": 0.17647058823529413, + "sprawl": 1.3956262778951116, + "long_connectors": 0.0, "edge_length_cv": 0.3431770636033404, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.17647058823529416, "loop_compactness": 0.8598427576691204, "flow_bends": 0.0, "loop_straightness": 0.39076126579718773 }, - "weighted_cost": 0.8709943595793639 + "weighted_cost": 1.1929692266468392 }, { "seed": 789, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.011711803570043349, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.011460500259803375, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.11288023459216052, "crossings": 0.2, - "sprawl": 1.6603987448123414, - "edge_length_cv": 0.4317694697031315, + "crowding": 0.17647058823529413, + "sprawl": 1.7213479263074025, + "long_connectors": 0.0, + "edge_length_cv": 0.43125539140305297, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7334237377417374, + "misalignment": 0.4117647058823529, + "loop_compactness": 0.7224548499212392, "flow_bends": 0.0, - "loop_straightness": 0.2477220589807632 + "loop_straightness": 0.22904361594146794 }, - "weighted_cost": 0.9378022500738888 + "weighted_cost": 1.3576807900874996 } ], - "median_cost": 0.8631186313138057, + "median_cost": 1.1949724971738278, "spread": [ - 0.7454216775517315, - 0.8829835131859658 + 1.1607180161500463, + 1.2900904914689504 ], - "best_of_k_cost": 0.8418796876245346, + "best_of_k_cost": 1.1929692266468392, "best_seed": 5, "median_seed": 1, "worst_seed": 0 }, { - "model": "dp_logistic_growth", + "model": "reliability", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.008265677667601688, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0695290083991442, - "edge_length_cv": 0.17217974049680632, + "label_connector_overlap": 0.23816201992846991, + "crossings": 0.13043478260869565, + "crowding": 0.03369140625, + "sprawl": 1.2946902622233476, + "long_connectors": 0.0, + "edge_length_cv": 0.362844395381303, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.31744430207475827, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.5597425593094773, "flow_bends": 0.0, - "loop_straightness": 0.2704593035495808 + "loop_straightness": 0.29938947865144294 }, - "weighted_cost": 0.4016794528646902 + "weighted_cost": 1.1570757778980427 }, { "seed": 1, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0395652790471699, - "edge_length_cv": 0.09779512391099372, + "label_connector_overlap": 0.22077137000646457, + "crossings": 0.08695652173913043, + "crowding": 0.02698707268960003, + "sprawl": 1.2946409991818164, + "long_connectors": 0.0, + "edge_length_cv": 0.4111066744900999, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.24567843839715708, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.5108629319882905, "flow_bends": 0.0, - "loop_straightness": 0.25920663156109086 + "loop_straightness": 0.27592496070277484 }, - "weighted_cost": 0.3658550943244059 + "weighted_cost": 1.0423652347661418 }, { "seed": 2, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.1092486763864682, - "edge_length_cv": 0.2048318622612551, + "label_connector_overlap": 0.2557227106807807, + "crossings": 0.13043478260869565, + "crowding": 0.004069010416666667, + "sprawl": 1.208444442119752, + "long_connectors": 0.0, + "edge_length_cv": 0.3518721660252704, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.34827343583463755, + "misalignment": 0.375, + "loop_compactness": 0.5859438451467865, "flow_bends": 0.0, - "loop_straightness": 0.28998100631839646 + "loop_straightness": 0.3196040910090745 }, - "weighted_cost": 0.42390721024298833 + "weighted_cost": 1.1240369167360933 }, { "seed": 3, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2132131759638447, - "edge_length_cv": 0.2376883029323176, + "label_connector_overlap": 0.3203554975825508, + "crossings": 0.13043478260869565, + "crowding": 0.0, + "sprawl": 1.2327400821305925, + "long_connectors": 0.0, + "edge_length_cv": 0.3924468315253192, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.29443278938398476, + "misalignment": 0.375, + "loop_compactness": 0.5879680436888179, "flow_bends": 0.0, - "loop_straightness": 0.28968087957424843 + "loop_straightness": 0.38216139748609135 }, - "weighted_cost": 0.4231338389037877 + "weighted_cost": 1.2300564067393063 }, { "seed": 4, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.017608053232295456, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0808038959563016, - "edge_length_cv": 0.2746507084970142, + "label_connector_overlap": 0.24148129310790792, + "crossings": 0.17391304347826086, + "crowding": 0.011442917860129508, + "sprawl": 1.4019160475931465, + "long_connectors": 0.0, + "edge_length_cv": 0.4624551097823725, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.3012663960181087, + "misalignment": 0.375, + "loop_compactness": 0.645909902362035, "flow_bends": 0.0, - "loop_straightness": 0.26422946609706655 + "loop_straightness": 0.31811265944508005 }, - "weighted_cost": 0.3968402842082105 + "weighted_cost": 1.2609482462524517 }, { "seed": 5, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.14285714285714285, - "sprawl": 1.1935142282151967, - "edge_length_cv": 0.3279090325353458, + "label_connector_overlap": 0.26531879320146456, + "crossings": 0.13043478260869565, + "crowding": 0.0, + "sprawl": 1.19308161683157, + "long_connectors": 0.0, + "edge_length_cv": 0.33626627001651355, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6135303257902378, + "misalignment": 0.375, + "loop_compactness": 0.5857287163762109, "flow_bends": 0.0, - "loop_straightness": 0.292909775195911 + "loop_straightness": 0.33413277376950734 }, - "weighted_cost": 0.6900130963358684 + "weighted_cost": 1.13188814054622 }, { "seed": 6, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.0059383484576128985, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.207703269013744, - "edge_length_cv": 0.13999832182315233, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.3935464903662237, + "label_connector_overlap": 0.24252778590222152, + "crossings": 0.13043478260869565, + "crowding": 0.0, + "sprawl": 1.8651813761484, + "long_connectors": 0.0, + "edge_length_cv": 0.4673694424647331, + "aspect_penalty": 0.17065742907074588, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.6440253129993527, "flow_bends": 0.0, - "loop_straightness": 0.29753287145471186 + "loop_straightness": 0.45268154624588436 }, - "weighted_cost": 0.4624625370947095 + "weighted_cost": 1.31694344890535 }, { "seed": 7, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007671439509169212, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2206273000422716, - "edge_length_cv": 0.1631996331385811, + "label_connector_overlap": 0.39868299238010657, + "crossings": 0.21739130434782608, + "crowding": 0.004069010416666667, + "sprawl": 1.4004517881583005, + "long_connectors": 0.0, + "edge_length_cv": 0.4029477902491051, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.2873942369624405, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.6348843392039653, "flow_bends": 0.0, - "loop_straightness": 0.28677457779580046 + "loop_straightness": 0.35556746793305993 }, - "weighted_cost": 0.42151061257301053 + "weighted_cost": 1.5077844452007918 }, { "seed": 42, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.010176431802497717, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0977413618095229, - "edge_length_cv": 0.1935296097191283, + "label_connector_overlap": 0.27472474742269154, + "crossings": 0.17391304347826086, + "crowding": 0.0001174903518746014, + "sprawl": 1.3599389745725352, + "long_connectors": 0.0019109208822499163, + "edge_length_cv": 0.5317599552844218, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.35644245450619727, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.5858511381448802, "flow_bends": 0.0, - "loop_straightness": 0.2596785934540375 + "loop_straightness": 0.4634359137526274 }, - "weighted_cost": 0.42184311350978726 + "weighted_cost": 1.2697614359533085 }, { "seed": 123, "metrics": { - "node_overlap": 0.033750000000000016, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.0030925233724442397, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.165857662264709, - "edge_length_cv": 0.15792800546333421, + "label_connector_overlap": 0.26788467876560634, + "crossings": 0.13043478260869565, + "crowding": 0.004257739706929184, + "sprawl": 1.240155384650455, + "long_connectors": 0.0, + "edge_length_cv": 0.46018064416194987, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.35449169623420484, + "misalignment": 0.29166666666666663, + "loop_compactness": 0.5333654363515483, "flow_bends": 0.0, - "loop_straightness": 0.2780848549440337 + "loop_straightness": 0.3501709982997821 }, - "weighted_cost": 0.4365266964410271 + "weighted_cost": 1.1302733744088005 }, { "seed": 456, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.001103334507119, - "edge_length_cv": 0.19498350334051162, + "label_connector_overlap": 0.26531879320146456, + "crossings": 0.13043478260869565, + "crowding": 0.0, + "sprawl": 1.19308161683157, + "long_connectors": 0.0, + "edge_length_cv": 0.33626627001651355, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5224671471393787, + "misalignment": 0.375, + "loop_compactness": 0.5857287163762109, "flow_bends": 0.0, - "loop_straightness": 0.21077167161141264 + "loop_straightness": 0.33413277376950734 }, - "weighted_cost": 0.46403469291831656 + "weighted_cost": 1.13188814054622 }, { "seed": 789, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.018072279372278226, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.009579521376098, - "edge_length_cv": 0.26640944699663793, + "label_connector_overlap": 0.2314158484527029, + "crossings": 0.17391304347826086, + "crowding": 0.0, + "sprawl": 1.5342383319123571, + "long_connectors": 0.0, + "edge_length_cv": 0.5562754595332245, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.32260826169216406, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.674276852188812, "flow_bends": 0.0, - "loop_straightness": 0.24558496226773144 + "loop_straightness": 0.4116513268461032 }, - "weighted_cost": 0.3892677051788584 + "weighted_cost": 1.2849501647734294 } ], - "median_cost": 0.4224884762067875, + "median_cost": 1.1935660923186746, "spread": [ - 0.4004696607005702, - 0.4430106566044477 + 1.1314844490118652, + 1.2735586181583387 ], - "best_of_k_cost": 0.3892677051788584, + "best_of_k_cost": 1.1302733744088005, "best_seed": 1, "median_seed": 3, - "worst_seed": 5 + "worst_seed": 7 }, { - "model": "population", + "model": "lotka_volterra", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.02346760824682478, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "label_connector_overlap": 0.03709829278069446, + "crossings": 0.1, + "crowding": 0.0, + "sprawl": 1.2920381800408944, + "long_connectors": 0.0, + "edge_length_cv": 0.33348459139131154, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5705922006749149 }, { "seed": 1, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.022161395358868782, "label_overlap": 0.0, + "label_connector_overlap": 0.07384141068976431, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3693358003411529, + "long_connectors": 0.0, + "edge_length_cv": 0.36253283652988494, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5424188568376723 }, { "seed": 2, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.023265920999116684, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "label_connector_overlap": 0.06224642446212526, + "crossings": 0.05, + "crowding": 0.0, + "sprawl": 1.3040644887277486, + "long_connectors": 0.0, + "edge_length_cv": 0.3572843237989019, + "aspect_penalty": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5559176008733584 }, { "seed": 3, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.02296537700770215, "label_overlap": 0.0, + "label_connector_overlap": 0.05425683728778813, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3123673600673518, + "long_connectors": 0.0, + "edge_length_cv": 0.35978229743802104, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5004078499639245 }, { "seed": 4, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.019519701056219835, "label_overlap": 0.0, + "label_connector_overlap": 0.08575477358688569, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3013825149720775, + "long_connectors": 0.0, + "edge_length_cv": 0.38454051754138796, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5380171912357876 }, { "seed": 5, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.012180155833699807, "label_overlap": 0.0, + "label_connector_overlap": 0.04221245977985824, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3976066055447958, + "long_connectors": 0.0, + "edge_length_cv": 0.3338797401772162, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.4820806527233859 }, { "seed": 6, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.022174586513571876, "label_overlap": 0.0, + "label_connector_overlap": 0.03753592519695266, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3395944918568847, + "long_connectors": 0.0, + "edge_length_cv": 0.3339335985135578, + "aspect_penalty": 0.0, + "misalignment": 0.5, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.4855516837867939 }, { "seed": 7, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.035833282280642836, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "label_connector_overlap": 0.04560043408388389, + "crossings": 0.1, + "crowding": 0.0, + "sprawl": 1.28543060098147, + "long_connectors": 0.0, + "edge_length_cv": 0.38789030782008993, + "aspect_penalty": 0.0, + "misalignment": 0.35, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.596424865932479 }, { "seed": 42, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.015391746459467039, "label_overlap": 0.0, + "label_connector_overlap": 0.047188973981440355, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.432797667484931, + "long_connectors": 0.0, + "edge_length_cv": 0.4282951747176336, + "aspect_penalty": 0.006136355668222793, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5047663707623273 }, { "seed": 123, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.02346760824682478, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "label_connector_overlap": 0.03709829278069446, + "crossings": 0.1, + "crowding": 0.0, + "sprawl": 1.2920381800408944, + "long_connectors": 0.0, + "edge_length_cv": 0.33348459139131154, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5705922006749149 }, { "seed": 456, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.024538137828128092, "label_overlap": 0.0, + "label_connector_overlap": 0.05092980347847856, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3287647924745603, + "long_connectors": 0.0, + "edge_length_cv": 0.3724028742453718, + "aspect_penalty": 0.0, + "misalignment": 0.44999999999999996, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 0.5026621789926141 }, { "seed": 789, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.02381840436955773, + "label_overlap": 0.0, + "label_connector_overlap": 0.09244678862113376, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.3738068067015583, + "long_connectors": 0.0, + "edge_length_cv": 0.33779134720935144, + "aspect_penalty": 0.0, + "misalignment": 0.35, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5647586933462058 + } + ], + "median_cost": 0.54021802403673, + "spread": [ + 0.5020985967354418, + 0.5662170701783831 + ], + "best_of_k_cost": 0.5026621789926141, + "best_seed": 5, + "median_seed": 1, + "worst_seed": 7 + }, + { + "model": "workforce", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.017146032863112688, + "label_overlap": 0.0, + "label_connector_overlap": 0.18360895824554635, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 1.4260046812482687, + "long_connectors": 0.0, + "edge_length_cv": 0.40279064974933687, + "aspect_penalty": 0.0, + "misalignment": 0.5517241379310345, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.8593101216824742 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.02776835401010422, + "label_overlap": 0.0, + "label_connector_overlap": 0.10230548256668706, + "crossings": 0.06896551724137931, + "crowding": 0.0, + "sprawl": 1.3983717985673354, + "long_connectors": 0.0, + "edge_length_cv": 0.4632082190555329, + "aspect_penalty": 0.0, + "misalignment": 0.5517241379310345, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.6827258125465556 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.02479744691244747, + "label_overlap": 0.0, + "label_connector_overlap": 0.1258218292364085, + "crossings": 0.06896551724137931, + "crowding": 0.0, + "sprawl": 1.4985880635396216, + "long_connectors": 0.0, + "edge_length_cv": 0.4152880511308788, + "aspect_penalty": 0.0, + "misalignment": 0.5172413793103448, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.7336643087368269 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.01672743961399548, + "label_overlap": 0.0, + "label_connector_overlap": 0.08513070625825366, + "crossings": 0.06896551724137931, + "crowding": 0.0, + "sprawl": 1.4603066908316127, + "long_connectors": 0.0, + "edge_length_cv": 0.4250280254049808, + "aspect_penalty": 0.0, + "misalignment": 0.3793103448275862, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.6331241630474125 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.00814542284205888, + "label_overlap": 0.0, + "label_connector_overlap": 0.14442561667233497, + "crossings": 0.06896551724137931, + "crowding": 0.002130096332936282, + "sprawl": 1.502583771308146, + "long_connectors": 0.0, + "edge_length_cv": 0.45496480581890303, + "aspect_penalty": 0.0, + "misalignment": 0.4137931034482759, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.7210501374387998 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.017782334862105377, + "label_overlap": 0.0, + "label_connector_overlap": 0.21978870573567452, + "crossings": 0.1724137931034483, + "crowding": 0.0005387931034482759, + "sprawl": 1.4901200045786953, + "long_connectors": 0.0, + "edge_length_cv": 0.40500084958439386, + "aspect_penalty": 0.0, + "misalignment": 0.31034482758620685, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.9417647984379136 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.016909961215100675, + "label_overlap": 0.0, + "label_connector_overlap": 0.20746347615603422, + "crossings": 0.034482758620689655, + "crowding": 0.0, + "sprawl": 1.4544656422902518, + "long_connectors": 0.0, + "edge_length_cv": 0.4125871346381366, + "aspect_penalty": 0.0, + "misalignment": 0.5862068965517242, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.8017349955126777 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0228706946843528, + "label_overlap": 0.0, + "label_connector_overlap": 0.21121290373900045, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 1.4806563104871846, + "long_connectors": 0.0, + "edge_length_cv": 0.4191869234144257, + "aspect_penalty": 0.0, + "misalignment": 0.5862068965517242, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.9292765467369334 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.018420993186962144, + "label_overlap": 0.0, + "label_connector_overlap": 0.1617679597305636, + "crossings": 0.06896551724137931, + "crowding": 0.0053230631791927295, + "sprawl": 1.4746040963544904, + "long_connectors": 0.0, + "edge_length_cv": 0.4668744386087121, + "aspect_penalty": 0.0, + "misalignment": 0.3793103448275862, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.7603645649617229 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.013024338994816217, + "label_overlap": 0.0, + "label_connector_overlap": 0.20213285028177888, + "crossings": 0.06896551724137931, + "crowding": 0.013276634761051115, + "sprawl": 1.5854897800428382, + "long_connectors": 0.0, + "edge_length_cv": 0.4254578498452355, + "aspect_penalty": 0.0, + "misalignment": 0.5172413793103448, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.8595866883564752 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.008331988268351954, + "label_overlap": 0.0, + "label_connector_overlap": 0.18051992197517397, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 1.4893764280344155, + "long_connectors": 0.0, + "edge_length_cv": 0.37968783355283625, + "aspect_penalty": 0.0, + "misalignment": 0.3793103448275862, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.835650035473586 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.017262537043145617, + "label_overlap": 0.0, + "label_connector_overlap": 0.21293780758834416, + "crossings": 0.10344827586206896, + "crowding": 0.0, + "sprawl": 1.435268123600494, + "long_connectors": 0.0, + "edge_length_cv": 0.39877856335743417, + "aspect_penalty": 0.0, + "misalignment": 0.5862068965517242, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.8748177818861724 + } + ], + "median_cost": 0.8186925154931318, + "spread": [ + 0.7305107659123201, + 0.8633944617388996 + ], + "best_of_k_cost": 0.7603645649617229, + "best_seed": 3, + "median_seed": 6, + "worst_seed": 5 + }, + { + "model": "bathtub", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.02481036801335007, + "label_overlap": 0.0, + "label_connector_overlap": 0.05247536896546643, + "crossings": 0.2413793103448276, + "crowding": 0.006772602500018941, + "sprawl": 1.9636989818820405, + "long_connectors": 0.0, + "edge_length_cv": 0.5234301260715261, + "aspect_penalty": 0.0, + "misalignment": 0.6129032258064516, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.9287007703709016 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.032189087861847925, + "label_overlap": 0.0, + "label_connector_overlap": 0.03123557026293422, + "crossings": 0.3103448275862069, + "crowding": 0.000041273719404344137, + "sprawl": 2.325003480759551, + "long_connectors": 0.0, + "edge_length_cv": 0.5043842894628963, + "aspect_penalty": 0.0, + "misalignment": 0.4838709677419355, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 1.0512555993877897 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.05745198224055181, "label_overlap": 0.0, + "label_connector_overlap": 0.09243103286573354, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.7802306465821613, + "long_connectors": 0.0, + "edge_length_cv": 0.5846152082831353, + "aspect_penalty": 0.0, + "misalignment": 0.3548387096774194, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.7340920463929862 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.049538170903595725, + "label_overlap": 0.0, + "label_connector_overlap": 0.04688724108275967, + "crossings": 0.1724137931034483, + "crowding": 0.0, + "sprawl": 1.9464762123080672, + "long_connectors": 0.0, + "edge_length_cv": 0.5154440749911563, + "aspect_penalty": 0.0, + "misalignment": 0.5483870967741935, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.8832787592892154 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.010846704231684401, + "label_overlap": 0.0, + "label_connector_overlap": 0.03449205136024432, + "crossings": 0.20689655172413793, + "crowding": 0.0, + "sprawl": 3.01341633347209, + "long_connectors": 0.0, + "edge_length_cv": 0.5009443768271582, + "aspect_penalty": 0.0, + "misalignment": 0.5806451612903225, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 1.091746636724928 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.025364584795034076, + "label_overlap": 0.0, + "label_connector_overlap": 0.02241110110367858, + "crossings": 0.20689655172413793, + "crowding": 0.0, + "sprawl": 2.2250166293005287, + "long_connectors": 0.0, + "edge_length_cv": 0.5399511130534949, + "aspect_penalty": 0.0, + "misalignment": 0.5806451612903225, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.9055610464238883 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.02367263115481229, + "label_overlap": 0.0, + "label_connector_overlap": 0.02264713943294708, + "crossings": 0.1724137931034483, + "crowding": 0.0, + "sprawl": 2.3852719569164007, + "long_connectors": 0.0, + "edge_length_cv": 0.5458513793126593, + "aspect_penalty": 0.0, + "misalignment": 0.59375, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.9094227537915937 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.015813514807159715, + "label_overlap": 0.00450746746684597, + "label_connector_overlap": 0.06928701082998893, + "crossings": 0.20689655172413793, + "crowding": 0.03458960214280174, + "sprawl": 2.3122511589971873, + "long_connectors": 0.0, + "edge_length_cv": 0.549937528940735, + "aspect_penalty": 0.0, + "misalignment": 0.5161290322580645, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 1.0224955288353066 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.06697476397037916, + "label_overlap": 0.0, + "label_connector_overlap": 0.05413813842732086, + "crossings": 0.034482758620689655, + "crowding": 0.0, + "sprawl": 2.140618537747331, + "long_connectors": 0.0, + "edge_length_cv": 0.5198030808975015, + "aspect_penalty": 0.0, + "misalignment": 0.625, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.847294128639262 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04720966810879345, + "label_overlap": 0.0, + "label_connector_overlap": 0.062051054705971734, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 2.3180620659755564, + "long_connectors": 0.0, + "edge_length_cv": 0.47676677337257317, + "aspect_penalty": 0.0, + "misalignment": 0.6451612903225806, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.9694585982854502 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04857225254569967, + "label_overlap": 0.0, + "label_connector_overlap": 0.03708101497530043, + "crossings": 0.10344827586206896, + "crowding": 1.1122471202096628e-6, + "sprawl": 1.765129544572536, + "long_connectors": 0.0, + "edge_length_cv": 0.5160068500493691, + "aspect_penalty": 0.0, + "misalignment": 0.6875, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.7662478018066731 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.046854552476137394, + "label_overlap": 0.0, + "label_connector_overlap": 0.07790063084635164, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 2.3608732188556263, + "long_connectors": 0.0012795721258273508, + "edge_length_cv": 0.5777739169634689, + "aspect_penalty": 0.0, + "misalignment": 0.6875, "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.0 + }, + "weighted_cost": 1.0080991764813811 + } + ], + "median_cost": 0.9190617620812476, + "spread": [ + 0.8742826016267271, + 1.0116982645698624 + ], + "best_of_k_cost": 0.7662478018066731, + "best_seed": 2, + "median_seed": 6, + "worst_seed": 4 + }, + { + "model": "catastrophe", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04041924425739792, + "label_overlap": 3.3202912220723327e-6, + "label_connector_overlap": 0.1507121963355881, + "crossings": 0.43137254901960786, + "crowding": 0.010992005813953489, + "sprawl": 1.7830778591007101, + "long_connectors": 0.0, + "edge_length_cv": 0.4200608133724496, + "aspect_penalty": 0.0, + "misalignment": 0.41860465116279066, + "loop_compactness": 0.9263991210931695, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.6824725371997409 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.046274698410600945, + "label_overlap": 0.00003353806607949168, + "label_connector_overlap": 0.18462236190381964, + "crossings": 0.47058823529411764, + "crowding": 0.0, + "sprawl": 1.733615066110582, + "long_connectors": 0.0, + "edge_length_cv": 0.41424993144874206, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.9031938681344511, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.7432032053170865 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.03625714820450659, + "label_overlap": 0.00002182160602897371, + "label_connector_overlap": 0.16080371367138407, + "crossings": 0.5294117647058824, + "crowding": 0.0000633579327941701, + "sprawl": 1.775492971658912, + "long_connectors": 0.0, + "edge_length_cv": 0.4329572602871554, + "aspect_penalty": 0.0, + "misalignment": 0.3571428571428571, + "loop_compactness": 0.9239720119166108, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.7674476985715255 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.045136863805078274, + "label_overlap": 0.000012033572273076312, + "label_connector_overlap": 0.2170475972088688, + "crossings": 0.5294117647058824, + "crowding": 1.8208981353919457e-7, + "sprawl": 1.732192282421844, + "long_connectors": 0.0, + "edge_length_cv": 0.4285523636389374, + "aspect_penalty": 0.0, + "misalignment": 0.38095238095238093, + "loop_compactness": 0.9195834651524788, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.859275882483802 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.05931927309792383, + "label_overlap": 8.878838478512998e-6, + "label_connector_overlap": 0.17856172590693845, + "crossings": 0.5294117647058824, + "crowding": 0.0, + "sprawl": 1.8077781260125438, + "long_connectors": 0.0, + "edge_length_cv": 0.4236950626383284, + "aspect_penalty": 0.0, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.9201935000971966, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.8574575351458038 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04896535561341255, + "label_overlap": 7.085815015695829e-6, + "label_connector_overlap": 0.16120142845495627, + "crossings": 0.43137254901960786, + "crowding": 0.0, + "sprawl": 1.7358863070754067, + "long_connectors": 0.0, + "edge_length_cv": 0.4205189649170576, + "aspect_penalty": 0.0, + "misalignment": 0.41860465116279066, + "loop_compactness": 0.9201935000971966, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.6900396452054318 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04801358713980316, + "label_overlap": 2.0550848881118374e-6, + "label_connector_overlap": 0.19103399448712213, + "crossings": 0.4117647058823529, + "crowding": 0.0, + "sprawl": 1.8030160480737099, + "long_connectors": 0.0, + "edge_length_cv": 0.45914873838330356, + "aspect_penalty": 0.0, + "misalignment": 0.5348837209302326, + "loop_compactness": 0.9145798199778197, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.7394243767923294 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.040069955672230165, + "label_overlap": 0.002331769304293829, + "label_connector_overlap": 0.11316085143910172, + "crossings": 0.35294117647058826, + "crowding": 0.021287249363860325, + "sprawl": 1.7001049515442073, + "long_connectors": 0.0028160452391889305, + "edge_length_cv": 0.5599189613105932, + "aspect_penalty": 0.0, + "misalignment": 0.5106382978723405, + "loop_compactness": 0.9227103289514283, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.5538530287760415 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.03706766373250551, + "label_overlap": 0.00003911415443765516, + "label_connector_overlap": 0.17198203556677455, + "crossings": 0.5490196078431373, + "crowding": 0.0, + "sprawl": 1.7592678714371377, + "long_connectors": 0.0, + "edge_length_cv": 0.40074476809002546, + "aspect_penalty": 0.0, + "misalignment": 0.38095238095238093, + "loop_compactness": 0.9257989501172955, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.8044966742002828 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.05531263898607713, + "label_overlap": 0.00001857730685097953, + "label_connector_overlap": 0.11102266109980935, + "crossings": 0.43137254901960786, + "crowding": 0.0, + "sprawl": 1.7082845473325325, + "long_connectors": 0.0, + "edge_length_cv": 0.44283960615502593, + "aspect_penalty": 0.0, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.9138813525937537, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.622732144993066 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04736187757764565, + "label_overlap": 0.000030838813043480426, + "label_connector_overlap": 0.1977171025989828, + "crossings": 0.47058823529411764, + "crowding": 0.008545084467701513, + "sprawl": 1.6953027507528515, + "long_connectors": 0.0, + "edge_length_cv": 0.49967562418431816, + "aspect_penalty": 0.0, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.9229517522002332, + "flow_bends": 0.0, + "loop_straightness": 0.75 + }, + "weighted_cost": 1.7850586811365199 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04833190369191045, + "label_overlap": 9.167684850771754e-6, + "label_connector_overlap": 0.17730270803970577, + "crossings": 0.45098039215686275, + "crowding": 0.0003633720930232558, + "sprawl": 1.6927326320974232, + "long_connectors": 0.0, + "edge_length_cv": 0.4609263677137596, + "aspect_penalty": 0.0, + "misalignment": 0.4883720930232558, + "loop_compactness": 0.9241908885457356, + "flow_bends": 0.0, + "loop_straightness": 0.75 }, - "weighted_cost": 0.3014527029273928 + "weighted_cost": 1.7306904433352188 } ], - "median_cost": 0.3014527029273928, + "median_cost": 1.7413137910547078, "spread": [ - 0.3014527029273928, - 0.3014527029273928 + 1.688147868204009, + 1.7899181794024606 ], - "best_of_k_cost": 0.3014527029273928, - "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "best_of_k_cost": 1.622732144993066, + "best_seed": 7, + "median_seed": 6, + "worst_seed": 3 }, { - "model": "reliability", + "model": "groupon", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.018261840782698664, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.17184587407183752, + "crossings": 0.5, + "crowding": 0.00022779949539925189, + "sprawl": 1.195134551862684, + "long_connectors": 0.012416466811165913, + "edge_length_cv": 0.63112056321348, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.4878048780487805, + "loop_compactness": 0.7592916830900419, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.5366247091960863 }, { "seed": 1, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0014414446307170312, - "label_overlap": 0.0, - "crossings": 0.08695652173913043, - "sprawl": 1.3832868223999235, - "edge_length_cv": 0.37189676423713974, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5200378462318094, + "node_overlap": 0.0, + "node_connector_overlap": 0.02185027002075638, + "label_overlap": 0.0003525478384161562, + "label_connector_overlap": 0.24415793353000023, + "crossings": 0.3269230769230769, + "crowding": 0.03166615500746818, + "sprawl": 1.109877437136083, + "long_connectors": 0.010999836431679197, + "edge_length_cv": 0.6072041960534316, + "aspect_penalty": 0.0, + "misalignment": 0.36585365853658536, + "loop_compactness": 0.6966944284441143, "flow_bends": 0.0, - "loop_straightness": 0.31123117240504933 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.6289566559807986 + "weighted_cost": 1.4526083890480639 }, { "seed": 2, "metrics": { - "node_overlap": 0.011844697676119335, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.003576853674553667, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 3.244912288623023, - "edge_length_cv": 0.2695427912851834, + "label_connector_overlap": 0.07449111241640821, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 3.468756726221285, + "long_connectors": 0.00963577612279461, + "edge_length_cv": 0.5855321946208963, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6717892770164458, + "misalignment": 0.8292682926829268, + "loop_compactness": 0.6864017876544161, "flow_bends": 0.0, - "loop_straightness": 0.36178073107784336 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.096155721923782 + "weighted_cost": 1.683000374535882 }, { "seed": 3, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.004242871133919603, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.2638022287176784, - "edge_length_cv": 0.34166080818268335, + "label_connector_overlap": 0.17535626201767357, + "crossings": 0.36538461538461536, + "crowding": 0.0, + "sprawl": 1.4815606531592327, + "long_connectors": 0.008780605035324664, + "edge_length_cv": 0.5509160345307278, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5849770287913719, + "misalignment": 0.5853658536585367, + "loop_compactness": 0.6781310668833803, "flow_bends": 0.0, - "loop_straightness": 0.37234476093767166 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.679183585360285 + "weighted_cost": 1.426089613221026 }, { "seed": 4, "metrics": { - "node_overlap": 0.02476306939773773, - "node_connector_overlap": 0.01757739223256019, + "node_overlap": 0.0, + "node_connector_overlap": 0.006467238025044093, "label_overlap": 0.0, - "crossings": 0.17391304347826086, - "sprawl": 1.410765434804514, - "edge_length_cv": 0.46016881737394383, + "label_connector_overlap": 0.19564532651268163, + "crossings": 0.21153846153846154, + "crowding": 0.007239999543487354, + "sprawl": 1.143932706922349, + "long_connectors": 0.022455934193643074, + "edge_length_cv": 0.7305166211452498, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6440847033872134, + "misalignment": 0.36585365853658536, + "loop_compactness": 0.676799973109859, "flow_bends": 0.0, - "loop_straightness": 0.3182416417478747 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.7878646375991344 + "weighted_cost": 1.214312810441455 }, { "seed": 5, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.0065649101731851955, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.13337797688354924, + "crossings": 0.5576923076923077, + "crowding": 0.0, + "sprawl": 1.4853346042824516, + "long_connectors": 0.012278476033263666, + "edge_length_cv": 0.5829166981393601, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.5365853658536586, + "loop_compactness": 0.7447213670844698, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.5845244504857852 }, { "seed": 6, "metrics": { - "node_overlap": 0.02476306939773773, - "node_connector_overlap": 0.009487406438905726, + "node_overlap": 0.0, + "node_connector_overlap": 0.029197746840089796, "label_overlap": 0.0, - "crossings": 0.17391304347826086, - "sprawl": 1.3929498812353063, - "edge_length_cv": 0.49623777740045866, - "aspect_penalty": 0.004879319457522735, - "chain_straightness": 0.0, - "loop_compactness": 0.6422994704752761, + "label_connector_overlap": 0.2430528954955041, + "crossings": 0.28846153846153844, + "crowding": 0.00038109756097560977, + "sprawl": 1.0756126032947733, + "long_connectors": 0.012268805470624294, + "edge_length_cv": 0.6172705401865981, + "aspect_penalty": 0.0, + "misalignment": 0.3414634146341463, + "loop_compactness": 0.6776104818452727, "flow_bends": 0.0, - "loop_straightness": 0.44404714253744454 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.7880779980058206 + "weighted_cost": 1.3766609453218634 }, { "seed": 7, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.007675591013226364, + "node_overlap": 0.0, + "node_connector_overlap": 0.025734463759432072, "label_overlap": 0.0, - "crossings": 0.21739130434782608, - "sprawl": 1.3869543741108474, - "edge_length_cv": 0.40221938316813444, + "label_connector_overlap": 0.1594158434817649, + "crossings": 0.3076923076923077, + "crowding": 0.007676517573201661, + "sprawl": 1.1314956121113848, + "long_connectors": 0.008570006581510912, + "edge_length_cv": 0.6052235911952707, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6346648307435367, + "misalignment": 0.4878048780487805, + "loop_compactness": 0.671587493155455, "flow_bends": 0.0, - "loop_straightness": 0.35645147611838324 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.8167319194902127 + "weighted_cost": 1.2951512940080672 }, { "seed": 42, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.0065649101731851955, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.13337797688354924, + "crossings": 0.5576923076923077, + "crowding": 0.0, + "sprawl": 1.4853346042824516, + "long_connectors": 0.012278476033263666, + "edge_length_cv": 0.5829166981393601, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.5365853658536586, + "loop_compactness": 0.7447213670844698, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.5845244504857852 }, { "seed": 123, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.005377274496934227, + "node_overlap": 0.0, + "node_connector_overlap": 0.018451801803241366, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.2485781479016096, - "edge_length_cv": 0.46492254670069055, + "label_connector_overlap": 0.16079109521480334, + "crossings": 0.4423076923076923, + "crowding": 0.0, + "sprawl": 1.3660453560826917, + "long_connectors": 0.0053431519642770965, + "edge_length_cv": 0.5172911377533695, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5334625826645447, + "misalignment": 0.5853658536585367, + "loop_compactness": 0.7809940982612654, "flow_bends": 0.0, - "loop_straightness": 0.35421195756431567 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.6590969849059389 + "weighted_cost": 1.520130463024936 }, { "seed": 456, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.009206896087871837, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.20146057334392928, + "crossings": 0.34615384615384615, + "crowding": 0.00524505800361516, + "sprawl": 1.1217916122002025, + "long_connectors": 0.010027654510039598, + "edge_length_cv": 0.6127620864850281, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.6097560975609756, + "loop_compactness": 0.741364715692521, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.39960216730266 }, { "seed": 789, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.020534662882022013, + "node_overlap": 0.0, + "node_connector_overlap": 0.023865068243973823, "label_overlap": 0.0, - "crossings": 0.17391304347826086, - "sprawl": 1.5255943560534784, - "edge_length_cv": 0.5612590549553415, + "label_connector_overlap": 0.1912495539229951, + "crossings": 0.4423076923076923, + "crowding": 0.0004072382630569672, + "sprawl": 1.2105840491489348, + "long_connectors": 0.0125899476825741, + "edge_length_cv": 0.6671387378625079, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.674276852188812, + "misalignment": 0.6341463414634146, + "loop_compactness": 0.7953868914057837, "flow_bends": 0.0, - "loop_straightness": 0.4116513268461032 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.8352055205288513 + "weighted_cost": 1.5524451593957498 } ], - "median_cost": 0.6706039899312279, + "median_cost": 1.4863694260365, "spread": [ - 0.6620243945021708, - 0.7952414783769186 + 1.3938668618074608, + 1.5604649821682588 ], - "best_of_k_cost": 0.6590969849059389, - "best_seed": 1, - "median_seed": 3, + "best_of_k_cost": 1.39960216730266, + "best_seed": 4, + "median_seed": 1, "worst_seed": 2 }, { - "model": "hares_and_foxes", + "model": "delays", "samples": [ { "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.01459161967711902, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.228990217870891, - "edge_length_cv": 0.28606799997289617, + "label_connector_overlap": 0.17532415736198848, + "crossings": 0.3695652173913043, + "crowding": 0.006525213068181818, + "sprawl": 1.911329625521727, + "long_connectors": 0.0, + "edge_length_cv": 0.40201899638723515, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2727272727272727, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2457980435741782 + "weighted_cost": 1.1733650395098658 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.007522068489343559, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2218901467164682, - "edge_length_cv": 0.1961122230011898, + "label_connector_overlap": 0.1137277301029934, + "crossings": 0.2391304347826087, + "crowding": 0.009299544578204302, + "sprawl": 2.4611071034778846, + "long_connectors": 0.0, + "edge_length_cv": 0.43943748758321477, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.303030303030303, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.24437802934329367 + "weighted_cost": 1.0796455176664916 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.02866564431885437, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.1669861552324692, - "edge_length_cv": 0.24054977453011245, - "aspect_penalty": 0.1323078953140473, - "chain_straightness": 0.0, + "label_connector_overlap": 0.13509215888541945, + "crossings": 0.21739130434782608, + "crowding": 0.0042142922555424645, + "sprawl": 1.302406614731556, + "long_connectors": 0.0, + "edge_length_cv": 0.46060764440234764, + "aspect_penalty": 0.0, + "misalignment": 0.18181818181818177, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.23339723104649385 + "weighted_cost": 0.8253585954339137 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0443126968786972, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.4385141963465509, - "edge_length_cv": 0.4452967243084567, + "label_connector_overlap": 0.2215609891401972, + "crossings": 0.43478260869565216, + "crowding": 0.0026267125490166696, + "sprawl": 1.336144070780343, + "long_connectors": 0.0, + "edge_length_cv": 0.359659911688514, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.18181818181818177, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2877028392693102 + "weighted_cost": 1.210594034589263 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.030644676375648265, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.9765182312183323, - "edge_length_cv": 0.41962997971172394, - "aspect_penalty": 0.06333039731030454, - "chain_straightness": 0.0, + "label_connector_overlap": 0.2003507256594535, + "crossings": 0.34782608695652173, + "crowding": 0.0012359619140625, + "sprawl": 1.7719172388846078, + "long_connectors": 0.0, + "edge_length_cv": 0.39743045565835433, + "aspect_penalty": 0.0, + "misalignment": 0.1875, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3953036462436665 + "weighted_cost": 1.1726067998322132 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.03343950373670096, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.19525981983137, - "edge_length_cv": 0.4896083755529902, + "label_connector_overlap": 0.20644147677405747, + "crossings": 0.3695652173913043, + "crowding": 0.0, + "sprawl": 1.3503109775855036, + "long_connectors": 0.0018540276823021922, + "edge_length_cv": 0.47400856852904255, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.24242424242424243, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.239051963966274 + "weighted_cost": 1.1088536225057435 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.04654796233780711, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.174037344705379, - "edge_length_cv": 0.3113074663188617, - "aspect_penalty": 0.3669957725717232, - "chain_straightness": 0.0, + "label_connector_overlap": 0.3041837316509213, + "crossings": 0.41304347826086957, + "crowding": 7.444853021419913e-6, + "sprawl": 2.105573812522329, + "long_connectors": 0.018976909619305215, + "edge_length_cv": 0.6138215348790338, + "aspect_penalty": 0.0, + "misalignment": 0.18181818181818177, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2348074689410758 + "weighted_cost": 1.51648617138794 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.03265613551205643, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.9658275858003817, - "edge_length_cv": 0.3876784910946535, + "label_connector_overlap": 0.14278021088831827, + "crossings": 0.2826086956521739, + "crowding": 0.000032289971904877035, + "sprawl": 1.8070007721131631, + "long_connectors": 0.0, + "edge_length_cv": 0.3709480842464356, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.1515151515151515, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19316551716007635 + "weighted_cost": 1.0290252811604748 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.024179649943143172, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.1905681438631572, - "edge_length_cv": 0.1729579260593722, - "aspect_penalty": 0.31504279445043437, - "chain_straightness": 0.0, + "label_connector_overlap": 0.21257565745044152, + "crossings": 0.32608695652173914, + "crowding": 0.0, + "sprawl": 1.8552472597963248, + "long_connectors": 0.0, + "edge_length_cv": 0.34095204398408596, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.23811362877263145 + "weighted_cost": 1.1904548908661023 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.05755964068162532, + "node_connector_overlap": 0.03477521342347458, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.1539380074938739, - "edge_length_cv": 0.3783713713241633, + "label_connector_overlap": 0.07043144858990329, + "crossings": 0.2826086956521739, + "crowding": 0.04247055928744711, + "sprawl": 1.7581801914322206, + "long_connectors": 0.0, + "edge_length_cv": 0.45466558053923956, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2727272727272727, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2883472421804001 + "weighted_cost": 0.9670946298022075 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.04348749323609555, + "node_connector_overlap": 0.013019165107408458, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.8026240922742207, - "edge_length_cv": 0.4208061767745891, + "label_connector_overlap": 0.14230340849310655, + "crossings": 0.2826086956521739, + "crowding": 0.0004734848484848485, + "sprawl": 1.8070450409823386, + "long_connectors": 0.0, + "edge_length_cv": 0.2911023387067692, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.24242424242424243, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.40401231169093976 + "weighted_cost": 0.9985793079431445 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0067014522267324515, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0856519054096903, - "edge_length_cv": 0.3352621885189167, + "label_connector_overlap": 0.02125928986005374, + "crossings": 0.5217391304347826, + "crowding": 0.0, + "sprawl": 5.994854405117601, + "long_connectors": 0.0, + "edge_length_cv": 0.3124068925559253, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.33333333333333337, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.21713038108193805 + "weighted_cost": 2.0990779042910614 } ], - "median_cost": 0.24171499665478385, + "median_cost": 1.1407302111689783, "spread": [ - 0.2344549094674303, - 0.28786393999708265 + 1.0214137878561422, + 1.1954896767968926 ], - "best_of_k_cost": 0.21713038108193805, - "best_seed": 7, - "median_seed": 1, - "worst_seed": 456 + "best_of_k_cost": 0.9670946298022075, + "best_seed": 2, + "median_seed": 4, + "worst_seed": 789 }, { - "model": "multipoint", + "model": "arms_race", "samples": [ { "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.009937973184269299, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.13674971689010884, + "crossings": 0.3888888888888889, + "crowding": 0.019806367602091764, + "sprawl": 1.660573571412186, + "long_connectors": 0.0, + "edge_length_cv": 0.4454751511985133, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.8668743181653887, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.30664089367463093 }, - "weighted_cost": 0.0 + "weighted_cost": 1.4595863210146809 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0018844267729336735, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.05545305371528578, + "crossings": 0.2777777777777778, + "crowding": 0.00027338014907867897, + "sprawl": 1.6174579111511378, + "long_connectors": 0.0, + "edge_length_cv": 0.381111992954584, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8893512810155005, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.24283378154310556 }, - "weighted_cost": 0.0 + "weighted_cost": 1.1760546270606145 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.029972232551334292, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.08396684599430672, + "crossings": 0.3888888888888889, + "crowding": 0.0, + "sprawl": 1.6303956575922558, + "long_connectors": 0.0, + "edge_length_cv": 0.39217576414899175, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.847673189478536, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.3068399223031358 }, - "weighted_cost": 0.0 + "weighted_cost": 1.378802472069476 }, { "seed": 3, @@ -1804,84 +3117,99 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.14054998474230357, + "crossings": 0.3888888888888889, + "crowding": 0.000024581119871140646, + "sprawl": 1.637195959486641, + "long_connectors": 0.0, + "edge_length_cv": 0.3278464593264952, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.837247242477636, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2538015005563948 }, - "weighted_cost": 0.0 + "weighted_cost": 1.395983150707236 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.010617004210914587, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.13861385147854124, + "crossings": 0.3888888888888889, + "crowding": 0.020025265634639924, + "sprawl": 1.6094410794546148, + "long_connectors": 0.0, + "edge_length_cv": 0.40396901763651744, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8083395177685666, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2811022532730657 }, - "weighted_cost": 0.0 + "weighted_cost": 1.4185419091282234 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.021914327515018903, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.33498040835373805, + "crossings": 0.3888888888888889, + "crowding": 0.0019751600431562534, + "sprawl": 1.7331520455978435, + "long_connectors": 0.0, + "edge_length_cv": 0.4063781867528364, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.7574179127896663, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2506614018824678 }, - "weighted_cost": 0.0 + "weighted_cost": 1.7318179665295979 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0032497261672938225, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.11867646192686691, + "crossings": 0.3333333333333333, + "crowding": 0.023811468836172283, + "sprawl": 1.5628805003439654, + "long_connectors": 0.0, + "edge_length_cv": 0.41221488550108776, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.7649235160576087, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.3925211919880592 }, - "weighted_cost": 0.0 + "weighted_cost": 1.3109339314355677 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0362427333693954, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.25183380260935984, + "crossings": 0.4444444444444444, + "crowding": 0.0, + "sprawl": 1.7364922301277, + "long_connectors": 0.0, + "edge_length_cv": 0.2977657150117305, + "aspect_penalty": 0.0, + "misalignment": 0.4, + "loop_compactness": 0.9035005219799066, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.3166102396508354 }, - "weighted_cost": 0.0 + "weighted_cost": 1.7618649053862463 }, { "seed": 42, @@ -1889,16 +3217,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.14054998474230357, + "crossings": 0.3888888888888889, + "crowding": 0.000024581119871140646, + "sprawl": 1.637195959486641, + "long_connectors": 0.0, + "edge_length_cv": 0.3278464593264952, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.837247242477636, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2538015005563948 }, - "weighted_cost": 0.0 + "weighted_cost": 1.395983150707236 }, { "seed": 123, @@ -1906,16 +3237,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.14054998474230357, + "crossings": 0.3888888888888889, + "crowding": 0.000024581119871140646, + "sprawl": 1.637195959486641, + "long_connectors": 0.0, + "edge_length_cv": 0.3278464593264952, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.837247242477636, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2538015005563948 }, - "weighted_cost": 0.0 + "weighted_cost": 1.395983150707236 }, { "seed": 456, @@ -1923,16 +3257,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.12822979281779895, + "crossings": 0.3888888888888889, + "crowding": 0.003303446622815623, + "sprawl": 1.59371354964202, + "long_connectors": 0.0, + "edge_length_cv": 0.400630225206048, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8324049346606536, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.24976274299507603 }, - "weighted_cost": 0.0 + "weighted_cost": 1.3675703269793438 }, { "seed": 789, @@ -1940,902 +3277,1049 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.9022222222222225, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.14054998474230376, + "crossings": 0.4444444444444444, + "crowding": 0.000024581119871140646, + "sprawl": 1.6210985021704356, + "long_connectors": 0.0, + "edge_length_cv": 0.34086042024828334, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.85207027154579, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2518242922561193 }, - "weighted_cost": 0.0 + "weighted_cost": 1.4599124993976416 } ], - "median_cost": 0.0, + "median_cost": 1.395983150707236, "spread": [ - 0.0, - 0.0 + 1.375994435796943, + 1.459667865610421 ], - "best_of_k_cost": 0.0, - "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "best_of_k_cost": 1.3675703269793438, + "best_seed": 1, + "median_seed": 3, + "worst_seed": 7 }, { - "model": "alias1", + "model": "hares_and_foxes", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 0.752155180038625, + "long_connectors": 0.0, + "edge_length_cv": 0.3397561533882427, + "aspect_penalty": 0.2632535387189576, + "misalignment": 0.5, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.23803879500965625 }, { "seed": 1, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.09003253694763302, + "sprawl": 0.5042550452661156, + "long_connectors": 0.0, + "edge_length_cv": 0.3594182691592225, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.31609629826416197 }, { "seed": 2, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.17960557202015576, + "sprawl": 0.5486457635781365, + "long_connectors": 0.0, + "edge_length_cv": 0.41318526454201693, + "aspect_penalty": 0.4782652052612435, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.3167670129146899 }, { "seed": 3, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.012774205681329993, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.0, + "sprawl": 1.175057315976473, + "long_connectors": 0.0, + "edge_length_cv": 0.4547629612716285, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.41931274035677824 }, { "seed": 4, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.045422434609506956, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.0, + "sprawl": 1.1519098142099826, + "long_connectors": 0.0, + "edge_length_cv": 0.49542007510345726, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.4788223227715096 }, { "seed": 5, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.0, + "sprawl": 0.9738119711461916, + "long_connectors": 0.0, + "edge_length_cv": 0.48580093166568084, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.2934529927865479 }, { "seed": 6, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.16930455973219807, + "sprawl": 0.9358746585727514, + "long_connectors": 0.0, + "edge_length_cv": 0.29469423584205523, + "aspect_penalty": 0.5342813300316331, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.5032732243753859 }, { "seed": 7, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.0, + "sprawl": 0.8465567355773407, + "long_connectors": 0.0, + "edge_length_cv": 0.37887650492363323, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.3116391838943352 }, { "seed": 42, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.006217967812834432, + "sprawl": 0.6187802337681223, + "long_connectors": 0.0, + "edge_length_cv": 0.27613365439208487, + "aspect_penalty": 0.23774617680322763, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.260913026254865 }, { "seed": 123, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.08230853725271599, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.22647874083020253, + "sprawl": 0.6605197001872328, + "long_connectors": 0.0, + "edge_length_cv": 0.44319453774726203, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.6562257403824427 }, { "seed": 456, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.04851785355486016, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.0003430732662794328, + "sprawl": 1.238706178979523, + "long_connectors": 0.0, + "edge_length_cv": 0.43881644645615436, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.5070553251208805 }, { "seed": 789, "metrics": { - "node_overlap": 0.04231974921630094, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "crowding": 0.1746939529456357, + "sprawl": 0.8478070935253363, + "long_connectors": 0.0, + "edge_length_cv": 0.3226107256234914, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 0.4866457263269698 } ], - "median_cost": 0.290269958781899, + "median_cost": 0.36803987663573406, "spread": [ - 0.290269958781899, - 0.290269958781899 + 0.30709263611738835, + 0.4908026008390738 ], - "best_of_k_cost": 0.290269958781899, + "best_of_k_cost": 0.260913026254865, "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "median_seed": 2, + "worst_seed": 123 }, { - "model": "cross_element", + "model": "ai_modules_arrays", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.14292550977944257, - "node_connector_overlap": 0.07874643194986122, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.5, - "sprawl": 1.3507796714696096, - "edge_length_cv": 0.45542381940066795, - "aspect_penalty": 0.8396982093245615, - "chain_straightness": 0.0, - "loop_compactness": 0.917997052867288, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, + "aspect_penalty": 0.0, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.2748289317589672 + "loop_straightness": 0.0 }, - "weighted_cost": 1.3865095903460378 + "weighted_cost": 0.19925874648885983 }, { "seed": 1, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05368923480833391, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.2497817625149574, - "edge_length_cv": 0.15661518692207232, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5141352860514037, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.16166439453184614 + "loop_straightness": 0.0 }, - "weighted_cost": 1.043391650964514 + "weighted_cost": 0.19925874648885983 }, { "seed": 2, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05739384839998312, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.125, - "sprawl": 1.105830775826482, - "edge_length_cv": 0.21414163583637771, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4783146914388712, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.23006389967349836 + "loop_straightness": 0.0 }, - "weighted_cost": 0.7608177798876204 + "weighted_cost": 0.19925874648885983 }, { "seed": 3, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.03366922572189482, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.125, - "sprawl": 1.2854106234824583, - "edge_length_cv": 0.33748941379701564, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7829428048865084, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.45759328372739344 + "loop_straightness": 0.0 }, - "weighted_cost": 0.9176133105251717 + "weighted_cost": 0.19925874648885983 }, { "seed": 4, "metrics": { - "node_overlap": 0.1429255097794431, - "node_connector_overlap": 0.03376620751651381, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2989223527440266, - "edge_length_cv": 0.4699894313443017, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5470879543433413, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.048567333695646774 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6601681029516635 + "weighted_cost": 0.19925874648885983 }, { "seed": 5, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05232996826889643, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.598872259770682, - "edge_length_cv": 0.2979907298096308, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5040015759096583, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.1374144658794832 + "loop_straightness": 0.0 }, - "weighted_cost": 1.1053720069542872 + "weighted_cost": 0.19925874648885983 }, { "seed": 6, "metrics": { - "node_overlap": 0.09884281581485074, - "node_connector_overlap": 0.017585427164416256, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.25, - "sprawl": 1.2240757649666012, - "edge_length_cv": 0.21785931759294788, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.649219675628467, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.3333333333333333 + "loop_straightness": 0.0 }, - "weighted_cost": 0.9042645995573074 + "weighted_cost": 0.19925874648885983 }, { "seed": 7, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.03976287212374372, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.1030807484299563, - "edge_length_cv": 0.2540338479893443, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.565158858437151, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.15641986418561762 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6450100613825998 + "weighted_cost": 0.19925874648885983 }, { "seed": 42, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.03823283129751742, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.1472951165480416, - "edge_length_cv": 0.28925706340995067, - "aspect_penalty": 0.24895073500693243, - "chain_straightness": 0.0, - "loop_compactness": 0.5518054002419153, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, + "aspect_penalty": 0.0, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.10498812980413981 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6418383374637484 + "weighted_cost": 0.19925874648885983 }, { "seed": 123, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05368923480833391, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.2497817625149574, - "edge_length_cv": 0.15661518692207232, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5141352860514037, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.16166439453184614 + "loop_straightness": 0.0 }, - "weighted_cost": 1.043391650964514 + "weighted_cost": 0.19925874648885983 }, { "seed": 456, "metrics": { - "node_overlap": 0.14292550977944202, - "node_connector_overlap": 0.07717328372897628, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.25, - "sprawl": 1.1987847095608246, - "edge_length_cv": 0.3761450197916289, - "aspect_penalty": 0.023546725533480695, - "chain_straightness": 0.0, - "loop_compactness": 0.4873195293257049, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, + "aspect_penalty": 0.0, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.127070122338004 + "loop_straightness": 0.0 }, - "weighted_cost": 0.9174905593846656 + "weighted_cost": 0.19925874648885983 }, { "seed": 789, "metrics": { - "node_overlap": 0.1429255097794431, - "node_connector_overlap": 0.04985300122850721, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.7157507559852456, - "edge_length_cv": 0.7146821530127622, + "label_connector_overlap": 0.0, + "crossings": 0.0, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, + "long_connectors": 0.0, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.9070368311076114, + "misalignment": 1.0, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.27482893175896594 + "loop_straightness": 0.0 }, - "weighted_cost": 1.3012262878239407 + "weighted_cost": 0.19925874648885983 } ], - "median_cost": 0.9175519349549186, + "median_cost": 0.19925874648885983, "spread": [ - 0.7356553606536311, - 1.0588867399619573 + 0.19925874648885983, + 0.19925874648885983 ], - "best_of_k_cost": 0.6418383374637484, - "best_seed": 42, - "median_seed": 456, + "best_of_k_cost": 0.19925874648885983, + "best_seed": 0, + "median_seed": 0, "worst_seed": 0 }, { - "model": "arrayed_pop", + "model": "alias1", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 1, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 2, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 3, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 4, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 5, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 6, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 7, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 42, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 123, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 456, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 }, { "seed": 789, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.2311486818289008, + "long_connectors": 0.0, + "edge_length_cv": 0.09822018543912521, + "aspect_penalty": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 0.3077871704572252 } ], - "median_cost": 0.3076673033832737, + "median_cost": 0.3077871704572252, "spread": [ - 0.3076673033832737, - 0.3076673033832737 + 0.3077871704572252, + 0.3077871704572252 ], - "best_of_k_cost": 0.3076673033832737, + "best_of_k_cost": 0.3077871704572252, "best_seed": 0, "median_seed": 0, "worst_seed": 0 }, { - "model": "ai_pure_human", + "model": "cross_element", "samples": [ { "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0048160911362588365, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.10145753189600365, + "crossings": 0.125, + "crowding": 0.0, + "sprawl": 1.5616664463040213, + "long_connectors": 0.0, + "edge_length_cv": 0.2535445270584675, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.7638490316069791, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.28857883654232325 }, - "weighted_cost": 0.0 + "weighted_cost": 1.0338548102117748 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.049849881647552566, "label_overlap": 0.0, + "label_connector_overlap": 0.16666666666666666, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.05804136728366248, + "sprawl": 1.303485120317416, + "long_connectors": 0.0, + "edge_length_cv": 0.23764533830878432, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.9008027958936617, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2697951232153641 }, - "weighted_cost": 0.0 + "weighted_cost": 1.143135263559345 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, + "long_connectors": 0.0, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 0.0 + "weighted_cost": 1.2527178631834184 } ], - "median_cost": 0.0, + "median_cost": 1.2527178631834184, "spread": [ - 0.0, - 0.0 + 1.2527178631834184, + 1.2527178631834184 ], - "best_of_k_cost": 0.0, - "best_seed": 0, + "best_of_k_cost": 1.143135263559345, + "best_seed": 2, "median_seed": 0, "worst_seed": 0 }, { - "model": "ai_pure_ai", + "model": "arrayed_pop", "samples": [ { "seed": 0, @@ -2843,16 +4327,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 1, @@ -2860,16 +4347,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 2, @@ -2877,16 +4367,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 3, @@ -2894,16 +4387,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 4, @@ -2911,16 +4407,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 5, @@ -2928,16 +4427,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 6, @@ -2945,16 +4447,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 7, @@ -2962,16 +4467,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 42, @@ -2979,16 +4487,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 123, @@ -2996,16 +4507,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 456, @@ -3013,16 +4527,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 }, { "seed": 789, @@ -3030,24 +4547,27 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3768378555706637, + "long_connectors": 0.0, + "edge_length_cv": 0.07918465788220196, + "aspect_penalty": 0.3350271896787391, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.34420946389266593 } ], - "median_cost": 0.0, + "median_cost": 0.34420946389266593, "spread": [ - 0.0, - 0.0 + 0.34420946389266593, + 0.34420946389266593 ], - "best_of_k_cost": 0.0, + "best_of_k_cost": 0.34420946389266593, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -3058,1089 +4578,1523 @@ { "seed": 0, "metrics": { - "node_overlap": 0.03366774358102364, - "node_connector_overlap": 0.01735845466376796, - "label_overlap": 0.060548107082135955, - "crossings": 0.2, - "sprawl": 1.663341406617301, - "edge_length_cv": 0.5298837404528474, + "node_overlap": 0.0, + "node_connector_overlap": 0.008353593271737953, + "label_overlap": 0.00031674236445642544, + "label_connector_overlap": 0.13449207014992087, + "crossings": 0.24074074074074073, + "crowding": 0.020778224133066688, + "sprawl": 1.56579607306806, + "long_connectors": 0.0033699208951008136, + "edge_length_cv": 0.6012877711057878, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.715042092339224, + "misalignment": 0.5425531914893618, + "loop_compactness": 0.7127089582784343, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.026687995014649 + "weighted_cost": 1.31127300881991 }, { "seed": 1, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.013472977620639466, - "label_overlap": 0.06283506533948285, - "crossings": 0.22857142857142856, - "sprawl": 1.6155862337658629, - "edge_length_cv": 0.5449424414263883, + "node_overlap": 0.0, + "node_connector_overlap": 0.011248594386911477, + "label_overlap": 0.0057026149372812794, + "label_connector_overlap": 0.14410024000211866, + "crossings": 0.2037037037037037, + "crowding": 0.020716844678588173, + "sprawl": 1.580304195894545, + "long_connectors": 0.0032662409405006232, + "edge_length_cv": 0.618063571742671, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7232043134115199, + "misalignment": 0.46808510638297873, + "loop_compactness": 0.6710310101046746, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0471699185762975 + "weighted_cost": 1.2926846062911044 }, { "seed": 2, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.015900682028631895, - "label_overlap": 0.06683812846987626, - "crossings": 0.19047619047619047, - "sprawl": 1.6160756372671428, - "edge_length_cv": 0.5656860344263355, + "node_overlap": 0.0, + "node_connector_overlap": 0.016112031518525503, + "label_overlap": 0.0002798671924669289, + "label_connector_overlap": 0.1380283843072549, + "crossings": 0.25, + "crowding": 0.02182955358379727, + "sprawl": 1.6092863067352767, + "long_connectors": 0.0, + "edge_length_cv": 0.5513179367434378, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7271292194699819, + "misalignment": 0.5052631578947369, + "loop_compactness": 0.7140963095961294, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.017173291143086 + "weighted_cost": 1.348289417294382 }, { "seed": 3, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.01615058449441615, - "label_overlap": 0.06447294443191919, - "crossings": 0.2, - "sprawl": 1.6089293631969956, - "edge_length_cv": 0.5585503711799498, + "node_overlap": 0.0, + "node_connector_overlap": 0.006014050858342206, + "label_overlap": 0.0002995121860785773, + "label_connector_overlap": 0.15098526989789157, + "crossings": 0.24074074074074073, + "crowding": 0.020719401869460658, + "sprawl": 1.5766068517771612, + "long_connectors": 0.0, + "edge_length_cv": 0.5788203720904966, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.718913716417784, + "misalignment": 0.43617021276595747, + "loop_compactness": 0.6871118007358982, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0198663630598142 + "weighted_cost": 1.3113551690675163 }, { "seed": 4, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.015417390186207742, - "label_overlap": 0.06283506533948285, - "crossings": 0.20952380952380953, - "sprawl": 1.6270633792334788, - "edge_length_cv": 0.5531268810625588, + "node_overlap": 0.0, + "node_connector_overlap": 0.0070552641398192876, + "label_overlap": 0.0002995121860785773, + "label_connector_overlap": 0.135226166485093, + "crossings": 0.24074074074074073, + "crowding": 0.020719401869460658, + "sprawl": 1.5756993522188742, + "long_connectors": 0.00027681331549262583, + "edge_length_cv": 0.5704866312799656, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.700679022277094, + "misalignment": 0.46808510638297873, + "loop_compactness": 0.695460456043868, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0233520247339996 + "weighted_cost": 1.2962414237643372 }, { "seed": 5, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.013472977620639466, - "label_overlap": 0.06283506533948285, - "crossings": 0.22857142857142856, - "sprawl": 1.6155862337658629, - "edge_length_cv": 0.5449424414263883, + "node_overlap": 0.0, + "node_connector_overlap": 0.004141183627814233, + "label_overlap": 0.000023681825051383677, + "label_connector_overlap": 0.12699922252804816, + "crossings": 0.24074074074074073, + "crowding": 0.00025972406914893617, + "sprawl": 2.1396205826246795, + "long_connectors": 0.0, + "edge_length_cv": 0.5388242853014795, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7232043134115199, + "misalignment": 0.574468085106383, + "loop_compactness": 0.7031025063934301, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0471699185762975 + "weighted_cost": 1.411184781696723 }, { "seed": 6, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.01466003480923588, - "label_overlap": 0.05729748444171206, - "crossings": 0.19047619047619047, - "sprawl": 1.627721576710515, - "edge_length_cv": 0.5403344574084297, + "node_overlap": 0.0, + "node_connector_overlap": 0.010982018809702314, + "label_overlap": 0.0002316451627214185, + "label_connector_overlap": 0.17951933738840828, + "crossings": 0.2222222222222222, + "crowding": 0.020694813829787235, + "sprawl": 1.5778543995436263, + "long_connectors": 0.0004178582111758408, + "edge_length_cv": 0.5711943093811339, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.723816866157302, + "misalignment": 0.5531914893617021, + "loop_compactness": 0.7164279200885126, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0073962464591282 + "weighted_cost": 1.369260956513894 }, { "seed": 7, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.013594953737298719, - "label_overlap": 0.06283506533948285, - "crossings": 0.22857142857142856, - "sprawl": 1.6133976739656597, - "edge_length_cv": 0.5420324031953525, + "node_overlap": 0.0, + "node_connector_overlap": 0.0070552641398192876, + "label_overlap": 0.0002995121860785773, + "label_connector_overlap": 0.135226166485093, + "crossings": 0.24074074074074073, + "crowding": 0.020719401869460658, + "sprawl": 1.5756993522188742, + "long_connectors": 0.00027681331549262583, + "edge_length_cv": 0.5704866312799656, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7232043134115199, + "misalignment": 0.46808510638297873, + "loop_compactness": 0.695460456043868, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.046854182732916 + "weighted_cost": 1.2962414237643372 }, { "seed": 42, "metrics": { - "node_overlap": 0.03346290349839446, - "node_connector_overlap": 0.01454922826263932, - "label_overlap": 0.06283506533948285, - "crossings": 0.22857142857142856, - "sprawl": 1.6310839691456696, - "edge_length_cv": 0.53627086427199, + "node_overlap": 0.0, + "node_connector_overlap": 0.012266982581583215, + "label_overlap": 0.0002120185572723131, + "label_connector_overlap": 0.14733290879997535, + "crossings": 0.2037037037037037, + "crowding": 0.029221162532900184, + "sprawl": 1.6022119438777633, + "long_connectors": 0.00017161151531788996, + "edge_length_cv": 0.5887571896519365, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7252669344572478, + "misalignment": 0.5957446808510638, + "loop_compactness": 0.7415123387438681, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0521707647125498 + "weighted_cost": 1.3337457275872124 }, { "seed": 123, "metrics": { - "node_overlap": 0.03366774358102364, - "node_connector_overlap": 0.01627810987593352, - "label_overlap": 0.05977738457724403, - "crossings": 0.2, - "sprawl": 1.6154929575825405, - "edge_length_cv": 0.5535939290017915, + "node_overlap": 0.0, + "node_connector_overlap": 0.0083277176274523, + "label_overlap": 0.0002672614848138316, + "label_connector_overlap": 0.11508786344453581, + "crossings": 0.2037037037037037, + "crowding": 0.024184544313114648, + "sprawl": 1.586170358344051, + "long_connectors": 0.00006480945956035868, + "edge_length_cv": 0.5735834128593663, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7222903559191249, + "misalignment": 0.5368421052631579, + "loop_compactness": 0.702951391888293, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.018166543346931 + "weighted_cost": 1.2472779279600736 }, { "seed": 456, "metrics": { - "node_overlap": 0.03366774358102364, - "node_connector_overlap": 0.016906785395203783, - "label_overlap": 0.063616208710647, - "crossings": 0.18095238095238095, - "sprawl": 1.6190603607797767, - "edge_length_cv": 0.5563941299831441, + "node_overlap": 0.0, + "node_connector_overlap": 0.0070552641398192876, + "label_overlap": 0.0002995121860785773, + "label_connector_overlap": 0.135226166485093, + "crossings": 0.24074074074074073, + "crowding": 0.020719401869460658, + "sprawl": 1.5756993522188742, + "long_connectors": 0.00027681331549262583, + "edge_length_cv": 0.5704866312799656, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7202424271245069, + "misalignment": 0.46808510638297873, + "loop_compactness": 0.695460456043868, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0034807330735849 + "weighted_cost": 1.2962414237643372 }, { "seed": 789, "metrics": { - "node_overlap": 0.033260540903746014, - "node_connector_overlap": 0.0164985781224376, - "label_overlap": 0.06283506533948285, - "crossings": 0.23809523809523808, - "sprawl": 1.624963826611921, - "edge_length_cv": 0.5466254311343512, + "node_overlap": 0.0, + "node_connector_overlap": 0.004680983964818663, + "label_overlap": 0.000041115988770483325, + "label_connector_overlap": 0.11006866872949017, + "crossings": 0.24074074074074073, + "crowding": 0.00025972406914893617, + "sprawl": 2.097973204809212, + "long_connectors": 0.00019418055297708292, + "edge_length_cv": 0.5607820202054622, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7118726224687183, + "misalignment": 0.5531914893617021, + "loop_compactness": 0.6958525742107434, "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 + "loop_straightness": 0.9772727272727273 }, - "weighted_cost": 1.0568598081993477 + "weighted_cost": 1.3715871846209906 } ], - "median_cost": 1.0250200098743243, + "median_cost": 1.3113140889437132, "spread": [ - 1.0179182302959697, - 1.0471699185762975 + 1.2962414237643372, + 1.3535323020992602 ], - "best_of_k_cost": 1.0034807330735849, - "best_seed": 456, + "best_of_k_cost": 1.2472779279600736, + "best_seed": 123, "median_seed": 0, - "worst_seed": 789 + "worst_seed": 5 }, { - "model": "ai_modules_arrays", + "model": "wrld3_03", "samples": [ { "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.008026816545998182, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.32863392909226635, + "crossings": 1.5953878406708595, + "crowding": 0.0, + "sprawl": 3.4979027169207693, + "long_connectors": 0.012561578298697017, + "edge_length_cv": 0.681950570278355, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.8088642659279779, + "loop_compactness": 0.800515656422774, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 3.475130413831593 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.009837847663096097, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.3606380644070527, + "crossings": 1.2536687631027255, + "crowding": 0.000022305643945242406, + "sprawl": 2.5958929316802246, + "long_connectors": 0.014446424155703453, + "edge_length_cv": 0.701544406786923, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.73109243697479, + "loop_compactness": 0.7664329771091912, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 2.939091629111395 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.007780783689326532, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.3250421749143157, + "crossings": 1.4591194968553458, + "crowding": 0.00004340277777777778, + "sprawl": 3.5140071472897283, + "long_connectors": 0.010594879704714786, + "edge_length_cv": 0.674743869722051, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.7694444444444444, + "loop_compactness": 0.8185122070909749, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 3.3393251722277637 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.02169153729684181, + "label_overlap": 0.00041878633207823035, + "label_connector_overlap": 0.47093643146128, + "crossings": 1.2557651991614256, + "crowding": 0.014146376726474166, + "sprawl": 1.981634561852721, + "long_connectors": 0.0109790558510803, + "edge_length_cv": 0.6895580579620383, + "aspect_penalty": 0.0, + "misalignment": 0.6537396121883656, + "loop_compactness": 0.68699286477665, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 2.951123214242883 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.007201451131395623, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.26124695390324576, + "crossings": 1.440251572327044, + "crowding": 0.0, + "sprawl": 4.4693877804493365, + "long_connectors": 0.012301513039476492, + "edge_length_cv": 0.674854852176404, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.8072625698324023, + "loop_compactness": 0.7107217310097007, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.4239264453527856 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.00692182700799812, + "label_overlap": 0.0, + "label_connector_overlap": 0.22721330162790418, + "crossings": 1.4549266247379455, + "crowding": 0.00008729050279329609, + "sprawl": 4.594871501324827, + "long_connectors": 0.013302775413785639, + "edge_length_cv": 0.6919034815727394, + "aspect_penalty": 0.0, + "misalignment": 0.8156424581005587, + "loop_compactness": 0.7894590802356498, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.451283551529896 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.006147718628178235, + "label_overlap": 0.0, + "label_connector_overlap": 0.30100467495794064, + "crossings": 1.4318658280922432, + "crowding": 0.0, + "sprawl": 3.4750807261727865, + "long_connectors": 0.009583178026084537, + "edge_length_cv": 0.6799434839812865, + "aspect_penalty": 0.0, + "misalignment": 0.8027777777777778, + "loop_compactness": 0.8067075695820783, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.2610797428412477 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0048681119174894105, + "label_overlap": 0.0, + "label_connector_overlap": 0.24830303277610918, + "crossings": 1.5178197064989518, + "crowding": 0.0, + "sprawl": 4.535680684998682, + "long_connectors": 0.0117843078691261, + "edge_length_cv": 0.6752733134973321, + "aspect_penalty": 0.0, + "misalignment": 0.807799442896936, + "loop_compactness": 0.7791596719462608, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.521155506639415 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0025254017781143885, + "label_overlap": 0.0, + "label_connector_overlap": 0.15512922308064764, + "crossings": 1.4528301886792452, + "crowding": 0.0, + "sprawl": 7.774047119072551, + "long_connectors": 0.010120067261248783, + "edge_length_cv": 0.6568781624922692, + "aspect_penalty": 0.0, + "misalignment": 0.8770949720670391, + "loop_compactness": 0.8070612114478872, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 4.138569510929956 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.011326402386120798, + "label_overlap": 0.0008235958106648744, + "label_connector_overlap": 0.4066194391206702, + "crossings": 1.4737945492662474, + "crowding": 0.005622447745015326, + "sprawl": 2.6731061981714475, + "long_connectors": 0.009988764773961037, + "edge_length_cv": 0.6626533775056191, + "aspect_penalty": 0.0, + "misalignment": 0.7170868347338936, + "loop_compactness": 0.7483095609041643, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.248073874455624 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0066160370933501435, + "label_overlap": 0.0, + "label_connector_overlap": 0.3223389452419177, + "crossings": 1.509433962264151, + "crowding": 4.977586137704487e-7, + "sprawl": 3.426085941270105, + "long_connectors": 0.011385657165408413, + "edge_length_cv": 0.6827980257227713, + "aspect_penalty": 0.0, + "misalignment": 0.7737430167597765, + "loop_compactness": 0.828742074520958, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.3661492863458222 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.01011006801876132, + "label_overlap": 0.0, + "label_connector_overlap": 0.3880428727558457, + "crossings": 1.310272536687631, + "crowding": 5.400179570377301e-8, + "sprawl": 2.6652348863129007, + "long_connectors": 0.01028599734978802, + "edge_length_cv": 0.6781502854559125, + "aspect_penalty": 0.0, + "misalignment": 0.7036011080332409, + "loop_compactness": 0.6979058407413977, + "flow_bends": 0.0, + "loop_straightness": 0.8888888888888888 + }, + "weighted_cost": 3.0224200921026094 + } + ], + "median_cost": 3.352737229286793, + "spread": [ + 3.1916604288673707, + 3.4572452671053204 + ], + "best_of_k_cost": 3.0224200921026094, + "best_seed": 1, + "median_seed": 2, + "worst_seed": 42 + }, + { + "model": "beer_game", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.011597200092945304, + "label_overlap": 0.0, + "label_connector_overlap": 0.24272100182576142, + "crossings": 0.8648648648648649, + "crowding": 0.0, + "sprawl": 4.076505242698807, + "long_connectors": 0.005775497743803308, + "edge_length_cv": 0.5946410279231453, + "aspect_penalty": 0.0, + "misalignment": 0.5609756097560976, + "loop_compactness": 0.8336198883450799, + "flow_bends": 0.0, + "loop_straightness": 0.9375 + }, + "weighted_cost": 2.7574503436496425 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.002548513047123638, + "label_overlap": 0.0, + "label_connector_overlap": 0.1579084941045578, + "crossings": 0.963963963963964, + "crowding": 0.0, + "sprawl": 9.486589399232612, + "long_connectors": 0.0016161458017166115, + "edge_length_cv": 0.5211854812126919, + "aspect_penalty": 0.19665526690833013, + "misalignment": 0.7, + "loop_compactness": 0.7477933355965509, + "flow_bends": 0.0, + "loop_straightness": 0.9375 + }, + "weighted_cost": 4.041246488162679 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0018289559301755962, + "label_overlap": 0.0, + "label_connector_overlap": 0.09120697042008448, + "crossings": 1.2522522522522523, + "crowding": 0.0, + "sprawl": 9.301847514277082, + "long_connectors": 0.0004879305138856911, + "edge_length_cv": 0.5075196085321145, + "aspect_penalty": 0.0, + "misalignment": 0.7073170731707317, + "loop_compactness": 0.8347964038472144, + "flow_bends": 0.0, + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 4.216826732424902 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.04128807427904672, + "label_overlap": 0.0, + "label_connector_overlap": 0.2369088479658354, + "crossings": 0.6216216216216216, + "crowding": 0.000035329564102232977, + "sprawl": 2.227006055968894, + "long_connectors": 0.00521826898471204, + "edge_length_cv": 0.6018022853224918, + "aspect_penalty": 0.0, + "misalignment": 0.4819277108433735, + "loop_compactness": 0.6013892825448071, + "flow_bends": 0.0, + "loop_straightness": 0.9375 + }, + "weighted_cost": 2.0014555042794098 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0027473606635675463, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.16098373487134257, + "crossings": 1.027027027027027, + "crowding": 0.0, + "sprawl": 9.241089093142188, + "long_connectors": 0.0011168185354784616, + "edge_length_cv": 0.49863871765008, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.7, + "loop_compactness": 0.7557695839224665, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 4.050885866783448 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.020388227690251805, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.26533091902058464, + "crossings": 0.7567567567567568, + "crowding": 0.0, + "sprawl": 2.283509287132927, + "long_connectors": 0.0016836714909639955, + "edge_length_cv": 0.5248855259358367, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.47560975609756095, + "loop_compactness": 0.6838778741379732, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 2.1821108734617964 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.00218934956689381, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.1323371197267114, + "crossings": 1.072072072072072, + "crowding": 0.0, + "sprawl": 9.036784598622166, + "long_connectors": 0.003700456094287436, + "edge_length_cv": 0.5335482903730038, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.691358024691358, + "loop_compactness": 0.8444477837628889, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 4.036667744472903 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.007272669602617505, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.16333942195382328, + "crossings": 0.8648648648648649, + "crowding": 0.0, + "sprawl": 4.071305797005736, + "long_connectors": 0.00041677916161364514, + "edge_length_cv": 0.5142876677690255, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.5731707317073171, + "loop_compactness": 0.7570749935258998, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 2.596351246414167 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.002284031496054041, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.1253093850638709, + "crossings": 0.918918918918919, + "crowding": 0.0, + "sprawl": 9.102950246523699, + "long_connectors": 0.002904705605059207, + "edge_length_cv": 0.5431729005356473, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.6867469879518072, + "loop_compactness": 0.7718761657607727, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 3.8598161390397774 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.028430870064508396, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.26912918428984983, + "crossings": 0.7207207207207207, + "crowding": 0.00015953412359173523, + "sprawl": 2.3549890093734325, + "long_connectors": 0.00008135792724038736, + "edge_length_cv": 0.5414824765166847, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.46341463414634143, + "loop_compactness": 0.8036309982333548, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 2.2317675654230587 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0016425351149707166, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.12498801767677953, + "crossings": 0.8468468468468469, + "crowding": 0.0, + "sprawl": 8.949992420177233, + "long_connectors": 0.0014750772741769435, + "edge_length_cv": 0.5085243390937986, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.7073170731707317, + "loop_compactness": 0.7073085252488032, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 3.7232547046899485 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.018331555266042483, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "label_connector_overlap": 0.19073499998707746, + "crossings": 0.6756756756756757, + "crowding": 8.236954345153825e-7, + "sprawl": 2.2355630139448803, + "long_connectors": 0.008001275330801015, + "edge_length_cv": 0.6195170135067359, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.426829268292683, + "loop_compactness": 0.7735632392621542, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.9375 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 2.007191723569562 } ], - "median_cost": 0.07465389888464857, + "median_cost": 3.2403525241697952, "spread": [ - 0.07465389888464857, - 0.07465389888464857 + 2.219353392432743, + 4.037812430395347 ], - "best_of_k_cost": 0.07465389888464857, - "best_seed": 0, + "best_of_k_cost": 2.007191723569562, + "best_seed": 3, "median_seed": 0, - "worst_seed": 0 + "worst_seed": 2 }, { - "model": "wrld3_03", + "model": "wonderland", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.015735131757445838, - "node_connector_overlap": 0.01687604720051637, - "label_overlap": 0.0, - "crossings": 1.2738693467336684, - "sprawl": 2.4109057250978414, - "edge_length_cv": 0.6912592137719059, + "node_overlap": 0.0, + "node_connector_overlap": 0.024614846067099926, + "label_overlap": 0.0005320955853165031, + "label_connector_overlap": 0.15865199779323008, + "crossings": 0.15254237288135594, + "crowding": 0.026618505570676878, + "sprawl": 1.527203416467361, + "long_connectors": 0.0, + "edge_length_cv": 0.4830628607712997, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8557404213973576, + "misalignment": 0.6938775510204082, + "loop_compactness": 0.8632290322943468, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.215740447965794 + "weighted_cost": 1.3429719935265227 }, { "seed": 1, "metrics": { - "node_overlap": 0.01707989948865733, - "node_connector_overlap": 0.02650524392495415, - "label_overlap": 0.407996627283499, - "crossings": 1.2160804020100502, - "sprawl": 1.8737197256961, - "edge_length_cv": 0.6762832295466932, + "node_overlap": 0.0, + "node_connector_overlap": 0.016397792128972605, + "label_overlap": 0.0004920797192771971, + "label_connector_overlap": 0.12322028479536737, + "crossings": 0.22033898305084745, + "crowding": 0.02040816326530612, + "sprawl": 1.497130567346961, + "long_connectors": 0.0, + "edge_length_cv": 0.49387258459703576, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8315990150948197, + "misalignment": 0.5918367346938775, + "loop_compactness": 0.8690561880904417, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.4598283325799604 + "weighted_cost": 1.3194450968921423 }, { "seed": 2, "metrics": { - "node_overlap": 0.012919003008107444, - "node_connector_overlap": 0.007081133335195029, - "label_overlap": 0.0, - "crossings": 1.379396984924623, - "sprawl": 4.006889579365737, - "edge_length_cv": 0.6322929780685507, + "node_overlap": 0.0, + "node_connector_overlap": 0.00559548836059899, + "label_overlap": 0.00042080268910618977, + "label_connector_overlap": 0.16057999096930342, + "crossings": 0.22033898305084745, + "crowding": 0.02615552068104627, + "sprawl": 1.5812405924293143, + "long_connectors": 0.0, + "edge_length_cv": 0.5259760951989745, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6399223889676684, + "misalignment": 0.6326530612244898, + "loop_compactness": 0.8897056640927612, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.5415266014237927 + "weighted_cost": 1.392746865751018 }, { "seed": 3, "metrics": { - "node_overlap": 0.015818098378156713, - "node_connector_overlap": 0.01694000359458034, - "label_overlap": 0.0, - "crossings": 1.3618090452261307, - "sprawl": 2.3962451728808656, - "edge_length_cv": 0.6990074254863038, + "node_overlap": 0.0, + "node_connector_overlap": 0.01336485733933006, + "label_overlap": 0.0005421072437995579, + "label_connector_overlap": 0.11625956143570938, + "crossings": 0.2033898305084746, + "crowding": 0.020754423157771873, + "sprawl": 1.5455309090611011, + "long_connectors": 0.0, + "edge_length_cv": 0.5167975320668912, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8649300916516701, + "misalignment": 0.6326530612244898, + "loop_compactness": 0.8381453631700538, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.304570827131361 + "weighted_cost": 1.2903277340727324 }, { "seed": 4, "metrics": { - "node_overlap": 0.01587389723774591, - "node_connector_overlap": 0.016112267552554186, - "label_overlap": 0.0, - "crossings": 1.4723618090452262, - "sprawl": 2.495825459016063, - "edge_length_cv": 0.6591462795229761, + "node_overlap": 0.0, + "node_connector_overlap": 0.011081131074383882, + "label_overlap": 0.00048205866406747836, + "label_connector_overlap": 0.1350228994319736, + "crossings": 0.2033898305084746, + "crowding": 0.02, + "sprawl": 1.600429153131836, + "long_connectors": 0.002024174598618401, + "edge_length_cv": 0.4629979610857943, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.838047164531921, + "misalignment": 0.54, + "loop_compactness": 0.865805649676201, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.4235145401471594 + "weighted_cost": 1.329476152147405 }, { "seed": 5, "metrics": { - "node_overlap": 0.017112374782134118, - "node_connector_overlap": 0.016970091259992937, - "label_overlap": 0.3058879664103606, - "crossings": 1.2010050251256281, - "sprawl": 1.8455558030746888, - "edge_length_cv": 0.7007325492948168, + "node_overlap": 0.0, + "node_connector_overlap": 0.012940878994714092, + "label_overlap": 0.0005314752402402324, + "label_connector_overlap": 0.1212609671781236, + "crossings": 0.15254237288135594, + "crowding": 0.02045953648947134, + "sprawl": 1.5721801048245283, + "long_connectors": 0.0, + "edge_length_cv": 0.5013612141185545, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8483740428550308, + "misalignment": 0.4693877551020408, + "loop_compactness": 0.8232809739499274, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.334218844030718 + "weighted_cost": 1.2301923423298062 }, { "seed": 6, "metrics": { - "node_overlap": 0.015818098378156703, - "node_connector_overlap": 0.01499822034986526, - "label_overlap": 0.0, - "crossings": 1.3165829145728642, - "sprawl": 2.399513149544941, - "edge_length_cv": 0.6904307463123497, + "node_overlap": 0.0, + "node_connector_overlap": 0.0039392718625720444, + "label_overlap": 0.00004822490533561227, + "label_connector_overlap": 0.09638373915610475, + "crossings": 0.22033898305084745, + "crowding": 0.0, + "sprawl": 2.14834235113396, + "long_connectors": 0.0, + "edge_length_cv": 0.5196323725647901, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.839578355794327, + "misalignment": 0.5714285714285714, + "loop_compactness": 0.8852199962531863, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.2479158142232576 + "weighted_cost": 1.3995392356716625 }, { "seed": 7, "metrics": { - "node_overlap": 0.015818098378156703, - "node_connector_overlap": 0.01173347309505542, - "label_overlap": 0.0, - "crossings": 1.5125628140703518, - "sprawl": 2.4468128896798063, - "edge_length_cv": 0.6924514002039581, + "node_overlap": 0.0, + "node_connector_overlap": 0.016003987506300263, + "label_overlap": 0.0003763487003807963, + "label_connector_overlap": 0.17500910340528275, + "crossings": 0.22033898305084745, + "crowding": 0.02428488254308402, + "sprawl": 1.5231750893411737, + "long_connectors": 0.0, + "edge_length_cv": 0.4980539374315547, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8199274977010701, + "misalignment": 0.6326530612244898, + "loop_compactness": 0.8642616574174716, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.4422305712556054 + "weighted_cost": 1.4084873271557372 }, { "seed": 42, "metrics": { - "node_overlap": 0.012919003008107444, - "node_connector_overlap": 0.007544837134564105, - "label_overlap": 0.0, - "crossings": 1.3241206030150754, - "sprawl": 3.959859595876896, - "edge_length_cv": 0.6558804297021629, + "node_overlap": 0.0, + "node_connector_overlap": 0.017245842022919976, + "label_overlap": 0.0004516425382721541, + "label_connector_overlap": 0.10660909903480745, + "crossings": 0.1864406779661017, + "crowding": 0.02170313311954605, + "sprawl": 1.5065055858677434, + "long_connectors": 0.0, + "edge_length_cv": 0.5526097220855366, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8555238862147909, + "misalignment": 0.5714285714285714, + "loop_compactness": 0.8494260286117654, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.563548525514695 + "weighted_cost": 1.255930427187368 }, { "seed": 123, "metrics": { - "node_overlap": 0.01576269042530074, - "node_connector_overlap": 0.014448863578830453, - "label_overlap": 4.510330596205672e-16, - "crossings": 1.57035175879397, - "sprawl": 2.4431617299306247, - "edge_length_cv": 0.7071325917899842, + "node_overlap": 0.0, + "node_connector_overlap": 0.017022559907518397, + "label_overlap": 0.0004754963631034609, + "label_connector_overlap": 0.1988712164551245, + "crossings": 0.1694915254237288, + "crowding": 0.023164028580342038, + "sprawl": 1.51605168286922, + "long_connectors": 0.0, + "edge_length_cv": 0.5113700983831928, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8311110329168642, + "misalignment": 0.5306122448979591, + "loop_compactness": 0.8422461036313983, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.5064226806466245 + "weighted_cost": 1.373905191997534 }, { "seed": 456, "metrics": { - "node_overlap": 0.015790345795576758, - "node_connector_overlap": 0.011323093092217297, - "label_overlap": 0.2608695652173913, - "crossings": 1.4321608040201006, - "sprawl": 2.4684532050078287, - "edge_length_cv": 0.6707229278420446, + "node_overlap": 0.0, + "node_connector_overlap": 0.007010893136208081, + "label_overlap": 0.00004074416855710981, + "label_connector_overlap": 0.1025005470462755, + "crossings": 0.1694915254237288, + "crowding": 0.0006950887426889074, + "sprawl": 2.114570237502011, + "long_connectors": 0.0, + "edge_length_cv": 0.47944032897708916, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8601128693414721, + "misalignment": 0.7142857142857143, + "loop_compactness": 0.8056614695700871, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.6426622055590925 + "weighted_cost": 1.3386984137955233 }, { "seed": 789, "metrics": { - "node_overlap": 0.015790345795576755, - "node_connector_overlap": 0.011526400216425531, - "label_overlap": 0.0, - "crossings": 1.4949748743718594, - "sprawl": 2.442018504558439, - "edge_length_cv": 0.6897355492379993, + "node_overlap": 0.0, + "node_connector_overlap": 0.008106343361455813, + "label_overlap": 0.0004682454381075622, + "label_connector_overlap": 0.14277459267292383, + "crossings": 0.1864406779661017, + "crowding": 0.02040816326530612, + "sprawl": 1.5909756590923612, + "long_connectors": 0.0014301614614078135, + "edge_length_cv": 0.518064321255102, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8680142533534396, + "misalignment": 0.4897959183673469, + "loop_compactness": 0.84096301991364, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 2.4426836313325775 + "weighted_cost": 1.300946940868284 } ], - "median_cost": 2.4424571012940914, + "median_cost": 1.3340872829714643, "spread": [ - 2.326806839805879, - 2.515198660840917 + 1.298292139169396, + 1.378615610435905 ], - "best_of_k_cost": 2.4426836313325775, - "best_seed": 0, - "median_seed": 7, - "worst_seed": 456 + "best_of_k_cost": 1.255930427187368, + "best_seed": 5, + "median_seed": 456, + "worst_seed": 7 }, { - "model": "beer_game", + "model": "mortgage_econ", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.029446246966763904, - "node_connector_overlap": 0.017133000809880207, - "label_overlap": 0.0, - "crossings": 0.65, - "sprawl": 2.5150137808120516, - "edge_length_cv": 0.44009345332440797, + "node_overlap": 0.0, + "node_connector_overlap": 0.010672539274783514, + "label_overlap": 0.00020739819598908607, + "label_connector_overlap": 0.17371831495167653, + "crossings": 0.23157894736842105, + "crowding": 0.043454033102976415, + "sprawl": 1.3287703151522499, + "long_connectors": 0.0, + "edge_length_cv": 0.531840030275402, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.685757139303958, + "misalignment": 0.5813953488372092, + "loop_compactness": 0.6661316141689497, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2830326965057852 }, - "weighted_cost": 1.558500244276022 + "weighted_cost": 1.2427694541243828 }, { "seed": 1, "metrics": { - "node_overlap": 0.0328717090245016, - "node_connector_overlap": 0.02531304420242634, - "label_overlap": 0.0, - "crossings": 0.7, - "sprawl": 2.010687433973158, - "edge_length_cv": 0.46350612105814093, + "node_overlap": 0.0, + "node_connector_overlap": 0.010580821385900012, + "label_overlap": 0.00028248837870680567, + "label_connector_overlap": 0.16012685924478773, + "crossings": 0.24210526315789474, + "crowding": 0.04306447991750685, + "sprawl": 1.3147322913597914, + "long_connectors": 0.0, + "edge_length_cv": 0.4945370530218952, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7055014757109696, + "misalignment": 0.5465116279069768, + "loop_compactness": 0.6372235830025625, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2962730566010116 }, - "weighted_cost": 1.527138214921332 + "weighted_cost": 1.2153613585316287 }, { "seed": 2, "metrics": { - "node_overlap": 0.029446246966763904, - "node_connector_overlap": 0.017133000809880207, - "label_overlap": 0.0, - "crossings": 0.65, - "sprawl": 2.5150137808120516, - "edge_length_cv": 0.44009345332440797, + "node_overlap": 0.0, + "node_connector_overlap": 0.009368406220582114, + "label_overlap": 0.0003302586267798442, + "label_connector_overlap": 0.13543107962755505, + "crossings": 0.24210526315789474, + "crowding": 0.04183974650174411, + "sprawl": 1.3207158662660246, + "long_connectors": 0.000526200459977005, + "edge_length_cv": 0.5096186758978344, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.685757139303958, + "misalignment": 0.5348837209302326, + "loop_compactness": 0.623258285966177, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2988799200909044 }, - "weighted_cost": 1.558500244276022 + "weighted_cost": 1.1701060920209443 }, { "seed": 3, "metrics": { - "node_overlap": 0.033052792654934965, - "node_connector_overlap": 0.024737522862067245, - "label_overlap": 0.0, - "crossings": 0.63, - "sprawl": 2.082306682948479, - "edge_length_cv": 0.4872128821275265, + "node_overlap": 0.0, + "node_connector_overlap": 0.00867417161956954, + "label_overlap": 0.00028223966158706594, + "label_connector_overlap": 0.16909401652189554, + "crossings": 0.22105263157894736, + "crowding": 0.04306447991750685, + "sprawl": 1.3074539984093536, + "long_connectors": 0.0, + "edge_length_cv": 0.5009390595640214, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6988574379764088, + "misalignment": 0.5930232558139534, + "loop_compactness": 0.6274870443684626, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2940273278898046 }, - "weighted_cost": 1.4684100119126462 + "weighted_cost": 1.2026576940540907 }, { "seed": 4, "metrics": { - "node_overlap": 0.032692598758892084, - "node_connector_overlap": 0.024086730915249828, - "label_overlap": 0.0, - "crossings": 0.64, - "sprawl": 1.994634735862889, - "edge_length_cv": 0.47796737186002575, + "node_overlap": 0.0, + "node_connector_overlap": 0.013388004050894728, + "label_overlap": 0.00029772788332303225, + "label_connector_overlap": 0.1388224671448919, + "crossings": 0.21052631578947367, + "crowding": 0.04205398968215234, + "sprawl": 1.3366786217837965, + "long_connectors": 0.0016039894785231745, + "edge_length_cv": 0.5256498023780974, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6881830187329596, + "misalignment": 0.5813953488372092, + "loop_compactness": 0.6208022117607791, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.3159352635785473 }, - "weighted_cost": 1.455594868955288 + "weighted_cost": 1.1616576580134819 }, { "seed": 5, "metrics": { - "node_overlap": 0.02944624696676391, - "node_connector_overlap": 0.014162277200417177, - "label_overlap": 0.0, - "crossings": 0.7, - "sprawl": 2.5379489326508335, - "edge_length_cv": 0.4868670559551512, + "node_overlap": 0.0, + "node_connector_overlap": 0.010464395602741056, + "label_overlap": 0.007144668597889619, + "label_connector_overlap": 0.14757679766348542, + "crossings": 0.21052631578947367, + "crowding": 0.04306447991750685, + "sprawl": 1.322503380465777, + "long_connectors": 0.00011480378999080146, + "edge_length_cv": 0.5213388622487195, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7406286565308288, + "misalignment": 0.5697674418604651, + "loop_compactness": 0.6310846028826644, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.28955345839759666 }, - "weighted_cost": 1.632065157925064 + "weighted_cost": 1.1899403016906158 }, { "seed": 6, "metrics": { - "node_overlap": 0.02944624696676391, - "node_connector_overlap": 0.015091972533178967, - "label_overlap": 0.0, - "crossings": 0.66, - "sprawl": 2.499588682224831, - "edge_length_cv": 0.46767479443480164, + "node_overlap": 0.0, + "node_connector_overlap": 0.007383060799794553, + "label_overlap": 0.0003347025419236909, + "label_connector_overlap": 0.14242733941302685, + "crossings": 0.21052631578947367, + "crowding": 0.0426368207930911, + "sprawl": 1.3189836563931971, + "long_connectors": 0.003603609948633535, + "edge_length_cv": 0.5338474827133699, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7318127728530209, + "misalignment": 0.4767441860465116, + "loop_compactness": 0.5971586321951298, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.29922749178903774 }, - "weighted_cost": 1.581796449701502 + "weighted_cost": 1.1307500659326502 }, { "seed": 7, "metrics": { - "node_overlap": 0.03323588244345284, - "node_connector_overlap": 0.0290500856866868, - "label_overlap": 0.0, - "crossings": 0.59, - "sprawl": 2.0204139515431367, - "edge_length_cv": 0.48988640469882405, + "node_overlap": 0.0, + "node_connector_overlap": 0.012827836148362538, + "label_overlap": 0.002480100190855403, + "label_connector_overlap": 0.15455180771503502, + "crossings": 0.23157894736842105, + "crowding": 0.05217286971306358, + "sprawl": 1.3248001006943708, + "long_connectors": 0.0016451054463759796, + "edge_length_cv": 0.5627219459024096, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6779565732111612, + "misalignment": 0.5697674418604651, + "loop_compactness": 0.6345572600307907, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.3118693596100894 }, - "weighted_cost": 1.412166772338616 + "weighted_cost": 1.2239247136749085 }, { "seed": 42, "metrics": { - "node_overlap": 0.029446246966763904, - "node_connector_overlap": 0.017133000809880207, - "label_overlap": 0.0, - "crossings": 0.65, - "sprawl": 2.5150137808120516, - "edge_length_cv": 0.44009345332440797, + "node_overlap": 0.0, + "node_connector_overlap": 0.010981996436882524, + "label_overlap": 0.002415329568112486, + "label_connector_overlap": 0.12767504726058124, + "crossings": 0.2, + "crowding": 0.0501175249269945, + "sprawl": 1.3089581867954418, + "long_connectors": 0.001746279833688508, + "edge_length_cv": 0.5276627346758643, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.685757139303958, + "misalignment": 0.5348837209302326, + "loop_compactness": 0.6032611759643869, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.28946496949152034 }, - "weighted_cost": 1.558500244276022 + "weighted_cost": 1.1238997682236598 }, { "seed": 123, "metrics": { - "node_overlap": 0.03323588244345284, - "node_connector_overlap": 0.025411267521769615, - "label_overlap": 0.0, - "crossings": 0.67, - "sprawl": 2.0596465582892427, - "edge_length_cv": 0.4570418348351993, + "node_overlap": 0.0, + "node_connector_overlap": 0.013791014984015231, + "label_overlap": 0.00028248837870680117, + "label_connector_overlap": 0.1956644293255869, + "crossings": 0.21052631578947367, + "crowding": 0.04306447991750685, + "sprawl": 1.3183227342040855, + "long_connectors": 0.0, + "edge_length_cv": 0.49990826108699443, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7338360215760534, + "misalignment": 0.627906976744186, + "loop_compactness": 0.6135968465124343, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2889552594771149 }, - "weighted_cost": 1.5187262548688771 + "weighted_cost": 1.2423638247669901 }, { "seed": 456, "metrics": { - "node_overlap": 0.0328717090245016, - "node_connector_overlap": 0.06150163637319987, - "label_overlap": 0.0, - "crossings": 0.59, - "sprawl": 2.009170025682547, - "edge_length_cv": 0.5209063958879556, + "node_overlap": 0.0, + "node_connector_overlap": 0.014147287566934383, + "label_overlap": 0.0002627456320778522, + "label_connector_overlap": 0.1228876876663613, + "crossings": 0.21052631578947367, + "crowding": 0.042667942797928946, + "sprawl": 1.3203510776695007, + "long_connectors": 0.0, + "edge_length_cv": 0.4962441978461723, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7200247915273112, + "misalignment": 0.5697674418604651, + "loop_compactness": 0.6335358877577004, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2794888991536159 }, - "weighted_cost": 1.45883265176052 + "weighted_cost": 1.135167733554949 }, { "seed": 789, "metrics": { - "node_overlap": 0.029446246966763904, - "node_connector_overlap": 0.019095047467563353, - "label_overlap": 0.0, - "crossings": 0.63, - "sprawl": 2.5520377966516854, - "edge_length_cv": 0.4357793844159173, + "node_overlap": 0.0, + "node_connector_overlap": 0.008693595426729943, + "label_overlap": 0.00038502552754009293, + "label_connector_overlap": 0.16127278638737025, + "crossings": 0.25263157894736843, + "crowding": 0.04306447991750685, + "sprawl": 1.3403942046686164, + "long_connectors": 0.0, + "edge_length_cv": 0.5046736463214392, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6857571393039583, + "misalignment": 0.6395348837209303, + "loop_compactness": 0.6125987210639157, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.3006742739200878 }, - "weighted_cost": 1.5478670941016321 + "weighted_cost": 1.230498974002603 } ], - "median_cost": 1.537502654511482, + "median_cost": 1.1962989978723533, "spread": [ - 1.4660156718746147, - 1.558500244276022 + 1.1550351768988487, + 1.225568278756832 ], - "best_of_k_cost": 1.45883265176052, - "best_seed": 7, - "median_seed": 1, - "worst_seed": 5 + "best_of_k_cost": 1.1238997682236598, + "best_seed": 42, + "median_seed": 3, + "worst_seed": 0 }, { - "model": "wonderland", + "model": "land_use", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.02617864501272576, - "node_connector_overlap": 0.012005305088275325, - "label_overlap": 0.05793480117648287, - "crossings": 0.1346153846153846, - "sprawl": 1.513349360347758, - "edge_length_cv": 0.5657597957286353, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.02952131526045543, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.22594733639939796, + "crossings": 0.5606936416184971, + "crowding": 0.0077785423663197, + "sprawl": 1.7101627010736276, + "long_connectors": 0.0037781271220035635, + "edge_length_cv": 0.5770867854056329, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6521605242362543, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, + "flow_bends": 0.4, + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 0.8558066791953833 + "weighted_cost": 1.8218174952606974 }, { "seed": 1, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.005542364861480269, - "label_overlap": 0.05479369808549754, - "crossings": 0.1346153846153846, - "sprawl": 1.4571803053552428, - "edge_length_cv": 0.5918294099145847, + "node_overlap": 0.008800834202294057, + "node_connector_overlap": 0.031050937851133265, + "label_overlap": 0.000701413335280272, + "label_connector_overlap": 0.23073547176567094, + "crossings": 0.5144508670520231, + "crowding": 0.00730295850811679, + "sprawl": 1.725900036129931, + "long_connectors": 0.0028915938842465016, + "edge_length_cv": 0.584077501570503, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.48166469605376566, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7007299270072993, + "loop_compactness": 0.55274023579804, + "flow_bends": 0.4, + "loop_straightness": 0.3429727814326075 }, - "weighted_cost": 0.7667704936061046 + "weighted_cost": 1.7816039464302358 }, { "seed": 2, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.014472351378418189, - "label_overlap": 0.43491124978192264, - "crossings": 0.15384615384615385, - "sprawl": 1.4504382942734781, - "edge_length_cv": 0.5680100171972058, + "node_overlap": 0.008865546218487395, + "node_connector_overlap": 0.03089631173126713, + "label_overlap": 0.0007161706883309815, + "label_connector_overlap": 0.23222406709686572, + "crossings": 0.5895953757225434, + "crowding": 0.00772407613538288, + "sprawl": 1.7121458685522053, + "long_connectors": 0.003755239100279532, + "edge_length_cv": 0.5863377839332292, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5379659453871359, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7132352941176471, + "loop_compactness": 0.5594868380156446, + "flow_bends": 0.4, + "loop_straightness": 0.28796431293799735 }, - "weighted_cost": 1.196220898567232 + "weighted_cost": 1.8548129677396372 }, { "seed": 3, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.00923938598761689, - "label_overlap": 0.05744041490857642, - "crossings": 0.15384615384615385, - "sprawl": 1.5219965364277397, - "edge_length_cv": 0.5661788104752684, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.03121511126875865, + "label_overlap": 0.0007307123785869989, + "label_connector_overlap": 0.242954465641968, + "crossings": 0.5838150289017341, + "crowding": 0.0077785423663197, + "sprawl": 1.7221017921746558, + "long_connectors": 0.0033640750374505234, + "edge_length_cv": 0.5788375428927165, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.47216302249773134, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7111111111111111, + "loop_compactness": 0.5676849738885731, + "flow_bends": 0.4, + "loop_straightness": 0.3284550128058558 }, - "weighted_cost": 0.8015075775781749 + "weighted_cost": 1.8755103323623519 }, { "seed": 4, "metrics": { - "node_overlap": 0.02584041153247996, - "node_connector_overlap": 0.014761322169851415, - "label_overlap": 0.06251303305094898, - "crossings": 0.11538461538461539, - "sprawl": 1.4557173274998958, - "edge_length_cv": 0.6473545550538722, + "node_overlap": 0.008865546218487395, + "node_connector_overlap": 0.031135540137223375, + "label_overlap": 0.0007586528609277127, + "label_connector_overlap": 0.24199491920758404, + "crossings": 0.5895953757225434, + "crowding": 0.007352941176470588, + "sprawl": 1.732713626161434, + "long_connectors": 0.002636529933414602, + "edge_length_cv": 0.5765728746347553, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5335232531681342, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7279411764705883, + "loop_compactness": 0.5730185063011031, + "flow_bends": 0.4, + "loop_straightness": 0.31434790516676125 }, - "weighted_cost": 0.7845906104435901 + "weighted_cost": 1.8838294549540318 }, { "seed": 5, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.016171780908997326, - "label_overlap": 0.05684712256717363, - "crossings": 0.21153846153846154, - "sprawl": 1.446409522320342, - "edge_length_cv": 0.6119508656460726, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.030176224752544487, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.23427099260704026, + "crossings": 0.5953757225433526, + "crowding": 0.0077785423663197, + "sprawl": 1.7111161630647354, + "long_connectors": 0.0037781271220035635, + "edge_length_cv": 0.5763614413579428, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.49107389469874596, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, + "flow_bends": 0.4, + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 0.8579859339093865 + "weighted_cost": 1.8705332449789713 }, { "seed": 6, "metrics": { - "node_overlap": 0.01719667036336946, - "node_connector_overlap": 0.008420348194647155, - "label_overlap": 0.029966413478587774, - "crossings": 0.19230769230769232, - "sprawl": 5.68910187773312, - "edge_length_cv": 0.4770500841408981, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.030157103865643176, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.23512208166138845, + "crossings": 0.5953757225433526, + "crowding": 0.0077785423663197, + "sprawl": 1.7122010835124923, + "long_connectors": 0.003643113227583198, + "edge_length_cv": 0.5756319450452297, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5343484186756164, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.762962962962963, + "loop_compactness": 0.5629785818037425, + "flow_bends": 0.4, + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 1.660989328899629 + "weighted_cost": 1.8734568414329014 }, { "seed": 7, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.013015432708125456, - "label_overlap": 0.061726216331882314, - "crossings": 0.15384615384615385, - "sprawl": 1.4542258813732665, - "edge_length_cv": 0.5631581836353289, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.030026409752167468, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.2397565457546822, + "crossings": 0.6069364161849711, + "crowding": 0.0077785423663197, + "sprawl": 1.7218185575004905, + "long_connectors": 0.003643113227583198, + "edge_length_cv": 0.5741701610574186, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5240520484752231, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5822745507532274, + "flow_bends": 0.4, + "loop_straightness": 0.31185349909251703 }, - "weighted_cost": 0.8167709051020915 + "weighted_cost": 1.8992221332143338 }, { "seed": 42, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.006561082178295447, - "label_overlap": 0.055264268121373555, - "crossings": 0.11538461538461539, - "sprawl": 1.4824994069404818, - "edge_length_cv": 0.5716290091088273, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.030176224752544487, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.23427099260704026, + "crossings": 0.5953757225433526, + "crowding": 0.0077785423663197, + "sprawl": 1.7111161630647354, + "long_connectors": 0.0037781271220035635, + "edge_length_cv": 0.5763614413579428, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5216707279949413, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, + "flow_bends": 0.4, + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 0.7700952448215446 + "weighted_cost": 1.8705332449789713 }, { "seed": 123, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.010153826828517025, - "label_overlap": 0.4486259662720202, - "crossings": 0.1346153846153846, - "sprawl": 1.501344051351043, - "edge_length_cv": 0.6350349742195764, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.030176224752544487, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.23427099260704026, + "crossings": 0.5953757225433526, + "crowding": 0.0077785423663197, + "sprawl": 1.7111161630647354, + "long_connectors": 0.0037781271220035635, + "edge_length_cv": 0.5763614413579428, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6231344218760133, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, + "flow_bends": 0.4, + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 1.230634863287723 + "weighted_cost": 1.8705332449789713 }, { "seed": 456, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.014151629345203216, - "label_overlap": 0.061726216331882314, - "crossings": 0.1346153846153846, - "sprawl": 1.4445477925760057, - "edge_length_cv": 0.5659945704654727, + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.030176224752544487, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.23427099260704026, + "crossings": 0.5953757225433526, + "crowding": 0.0077785423663197, + "sprawl": 1.7111161630647354, + "long_connectors": 0.0037781271220035635, + "edge_length_cv": 0.5763614413579428, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5878985778713302, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, + "flow_bends": 0.4, + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 0.8222793265073907 + "weighted_cost": 1.8705332449789713 }, { "seed": 789, "metrics": { - "node_overlap": 0.02617864501272573, - "node_connector_overlap": 0.0029599367444394075, - "label_overlap": 0.062330170086129566, - "crossings": 0.19230769230769232, - "sprawl": 1.3581715581303329, - "edge_length_cv": 0.7373413525099772, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7124993063621157, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 - }, - "weighted_cost": 0.9019489398603614 + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.029880688615973865, + "label_overlap": 0.0006783132151867693, + "label_connector_overlap": 0.2332467417345865, + "crossings": 0.6011560693641619, + "crowding": 0.0077785423663197, + "sprawl": 1.7164274391990735, + "long_connectors": 0.003799660012632557, + "edge_length_cv": 0.5789371514336874, + "aspect_penalty": 0.0, + "misalignment": 0.762962962962963, + "loop_compactness": 0.5617473834976028, + "flow_bends": 0.4, + "loop_straightness": 0.32395857140387685 + }, + "weighted_cost": 1.8765972537185318 } ], - "median_cost": 0.839043002851387, + "median_cost": 1.8705332449789713, "spread": [ - 0.7972783357945287, - 0.975516929537079 + 1.866603175669138, + 1.8757820627013968 ], - "best_of_k_cost": 0.7700952448215446, + "best_of_k_cost": 1.8705332449789713, "best_seed": 1, - "median_seed": 0, - "worst_seed": 6 + "median_seed": 5, + "worst_seed": 7 }, { "model": "scirev", @@ -4148,217 +6102,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.003054492961650663, - "node_connector_overlap": 0.03736832116527603, - "label_overlap": 0.03378478526787733, - "crossings": 2.5313807531380754, - "sprawl": 4.525226395378209, - "edge_length_cv": 0.4685234939589514, + "node_overlap": 0.0, + "node_connector_overlap": 0.0029173864358874066, + "label_overlap": 0.0, + "label_connector_overlap": 0.2522196036852104, + "crossings": 1.7286821705426356, + "crowding": 0.0, + "sprawl": 5.069547209849893, + "long_connectors": 0.00010134106674480778, + "edge_length_cv": 0.5125413637434898, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8051977327874777, - "flow_bends": 0.23076923076923078, + "misalignment": 0.7967032967032968, + "loop_compactness": 0.779923503731026, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.9673281093388972 + "weighted_cost": 3.8950004761777346 }, { "seed": 1, "metrics": { - "node_overlap": 0.003017934553240774, - "node_connector_overlap": 0.03676973187567564, - "label_overlap": 0.028225806451612902, - "crossings": 2.1255230125523012, - "sprawl": 4.185192037518877, - "edge_length_cv": 0.47433962886409825, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8150458038831379, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.014701401580220638, + "label_overlap": 9.251810143587649e-6, + "label_connector_overlap": 0.45713665743219617, + "crossings": 1.6046511627906976, + "crowding": 0.000037911014338457034, + "sprawl": 2.2131900701263247, + "long_connectors": 0.0013222108500341716, + "edge_length_cv": 0.5459114265444769, + "aspect_penalty": 0.0, + "misalignment": 0.7071823204419889, + "loop_compactness": 0.8379213373309454, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.502747060643707 + "weighted_cost": 3.4027515574593727 }, { "seed": 2, "metrics": { - "node_overlap": 0.008844783277554252, - "node_connector_overlap": 0.04589306070638068, - "label_overlap": 0.0, - "crossings": 2.288702928870293, - "sprawl": 2.3028538477848266, - "edge_length_cv": 0.4915751705844835, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8655895834130762, + "node_overlap": 0.0, + "node_connector_overlap": 0.010557825450090109, + "label_overlap": 5.836603605025521e-6, + "label_connector_overlap": 0.4429488110813908, + "crossings": 1.7829457364341086, + "crowding": 0.0, + "sprawl": 2.2959767269512854, + "long_connectors": 0.0004854382566712309, + "edge_length_cv": 0.529051765883256, + "aspect_penalty": 0.0, + "misalignment": 0.7213114754098361, + "loop_compactness": 0.7739439674900197, "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.273324298853347 + "weighted_cost": 3.5475275905490635 }, { "seed": 3, "metrics": { - "node_overlap": 0.0031765293795181287, - "node_connector_overlap": 0.033910146327249154, - "label_overlap": 0.07108433734940683, - "crossings": 2.3682008368200838, - "sprawl": 4.474547407415176, - "edge_length_cv": 0.5499335572840354, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8823655387695712, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.008757233502346437, + "label_overlap": 9.980047420857627e-6, + "label_connector_overlap": 0.4715739633808158, + "crossings": 1.5968992248062015, + "crowding": 0.0, + "sprawl": 2.2386973001338037, + "long_connectors": 0.0005197116204556585, + "edge_length_cv": 0.5234566152373308, + "aspect_penalty": 0.0, + "misalignment": 0.7016574585635359, + "loop_compactness": 0.8216233987728534, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.8703813930209674 + "weighted_cost": 3.403635776334188 }, { "seed": 4, "metrics": { - "node_overlap": 0.0031782122339010627, - "node_connector_overlap": 0.031132335514188043, - "label_overlap": 0.04216867469879518, - "crossings": 2.1715481171548117, - "sprawl": 4.272307821858353, - "edge_length_cv": 0.460931649762885, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8537049763028427, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.007467438137715281, + "label_overlap": 0.0004858186586813243, + "label_connector_overlap": 0.35989616851138495, + "crossings": 1.554263565891473, + "crowding": 0.005494505494505495, + "sprawl": 2.9499007548067513, + "long_connectors": 0.0024568839354751505, + "edge_length_cv": 0.5548854123849108, + "aspect_penalty": 0.0, + "misalignment": 0.7692307692307692, + "loop_compactness": 0.8167142164312676, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.5901247406483496 + "weighted_cost": 3.3816268829758043 }, { "seed": 5, "metrics": { - "node_overlap": 0.0033400887489442085, - "node_connector_overlap": 0.0410836590576568, - "label_overlap": 0.088941480206552, - "crossings": 2.2594142259414225, - "sprawl": 4.330850195537304, - "edge_length_cv": 0.4953082505302481, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8668556038568427, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.011934648346267162, + "label_overlap": 1.7704278135805483e-6, + "label_connector_overlap": 0.41204842244708323, + "crossings": 1.693798449612403, + "crowding": 0.00008632596685082873, + "sprawl": 2.2579854947341564, + "long_connectors": 0.000457482565769446, + "edge_length_cv": 0.5157671914347809, + "aspect_penalty": 0.0, + "misalignment": 0.7348066298342542, + "loop_compactness": 0.8095731645895687, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.75184558075862 + "weighted_cost": 3.4209448693023603 }, { "seed": 6, "metrics": { - "node_overlap": 0.0030163357188578927, - "node_connector_overlap": 0.03882600310163627, - "label_overlap": 0.02108433734939759, - "crossings": 2.5313807531380754, - "sprawl": 4.278187466149768, - "edge_length_cv": 0.489320221808483, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8575774501844523, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.011010206904139999, + "label_overlap": 0.00016166145219144651, + "label_connector_overlap": 0.4472147302608922, + "crossings": 1.751937984496124, + "crowding": 0.005498234756519497, + "sprawl": 2.2524458358944277, + "long_connectors": 0.0013110198483706227, + "edge_length_cv": 0.5351606229100173, + "aspect_penalty": 0.0, + "misalignment": 0.7472527472527473, + "loop_compactness": 0.7856831204021154, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.9391297487655477 + "weighted_cost": 3.526686958395768 }, { "seed": 7, "metrics": { - "node_overlap": 0.0030179345532407678, - "node_connector_overlap": 0.03879519501531315, - "label_overlap": 0.028225806451612902, - "crossings": 1.9665271966527196, - "sprawl": 4.182038063866251, - "edge_length_cv": 0.5055823162396889, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7797449999321169, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.01615379000948702, + "label_overlap": 0.0022682103674761967, + "label_connector_overlap": 0.5167070525288028, + "crossings": 1.5813953488372092, + "crowding": 0.02734375, + "sprawl": 1.6806768305847117, + "long_connectors": 0.0012896451145782556, + "edge_length_cv": 0.5305503491859076, + "aspect_penalty": 0.0, + "misalignment": 0.6574585635359116, + "loop_compactness": 0.8051167274034787, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.3310255915728297 + "weighted_cost": 3.355729494530927 }, { "seed": 42, "metrics": { - "node_overlap": 0.0030179345532407786, - "node_connector_overlap": 0.04504639924977587, - "label_overlap": 0.028225806451612902, - "crossings": 2.071129707112971, - "sprawl": 4.157823908112761, - "edge_length_cv": 0.4855521108372051, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7946962868114376, - "flow_bends": 0.3076923076923077, + "node_overlap": 0.0, + "node_connector_overlap": 0.019456419963357206, + "label_overlap": 0.0003558145898770924, + "label_connector_overlap": 0.48761316007358324, + "crossings": 1.813953488372093, + "crowding": 0.022048605663717583, + "sprawl": 1.7012355048514618, + "long_connectors": 0.0009753557932553301, + "edge_length_cv": 0.5463422762848004, + "aspect_penalty": 0.0, + "misalignment": 0.65, + "loop_compactness": 0.8198108151497282, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.4430169898685734 + "weighted_cost": 3.549377828383778 }, { "seed": 123, "metrics": { - "node_overlap": 0.00882951251094925, - "node_connector_overlap": 0.04678445545218922, - "label_overlap": 0.0, - "crossings": 2.0251046025104604, - "sprawl": 2.1468711179563367, - "edge_length_cv": 0.4851011766911578, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7343177052226957, + "node_overlap": 0.0, + "node_connector_overlap": 0.0185622162587873, + "label_overlap": 0.0009584345416756992, + "label_connector_overlap": 0.5136126474445379, + "crossings": 1.8527131782945736, + "crowding": 0.01879509149036756, + "sprawl": 1.7043044077703282, + "long_connectors": 0.0009001288328350575, + "edge_length_cv": 0.5481429405565325, + "aspect_penalty": 0.0, + "misalignment": 0.6850828729281768, + "loop_compactness": 0.7828117108698086, "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 2.926896799230868 + "weighted_cost": 3.6136422554418512 }, { "seed": 456, "metrics": { - "node_overlap": 0.0030163357188578528, - "node_connector_overlap": 0.037510367539945876, - "label_overlap": 0.02108433734940307, - "crossings": 2.062761506276151, - "sprawl": 4.269747690916647, - "edge_length_cv": 0.47446374755125054, + "node_overlap": 0.0, + "node_connector_overlap": 0.0023727949134482013, + "label_overlap": 0.0, + "label_connector_overlap": 0.24455618024157938, + "crossings": 1.926356589147287, + "crowding": 0.0, + "sprawl": 6.694497918918244, + "long_connectors": 0.0005749440788241743, + "edge_length_cv": 0.5075249492348995, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8442884162162339, - "flow_bends": 0.3076923076923077, + "misalignment": 0.8895027624309393, + "loop_compactness": 0.7933805614788217, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.4621912977080265 + "weighted_cost": 4.501227825017071 }, { "seed": 789, "metrics": { - "node_overlap": 0.006128788237794972, - "node_connector_overlap": 0.03405854750367748, - "label_overlap": 0.009157262889271461, - "crossings": 1.8451882845188285, - "sprawl": 2.7667208133505996, - "edge_length_cv": 0.50576315802094, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7159114140931172, + "node_overlap": 0.0, + "node_connector_overlap": 0.011454406929712, + "label_overlap": 8.026365631983558e-6, + "label_connector_overlap": 0.47813119006559807, + "crossings": 1.5891472868217054, + "crowding": 0.0, + "sprawl": 2.2433664829221094, + "long_connectors": 0.0011281682730390318, + "edge_length_cv": 0.5389440638945844, + "aspect_penalty": 0.0, + "misalignment": 0.7111111111111111, + "loop_compactness": 0.8423732841141143, "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 2.8573185345338623 + "weighted_cost": 3.4218240307599648 } ], - "median_cost": 3.482469179175867, + "median_cost": 3.474255494577866, "spread": [ - 3.3166002683929587, - 3.781479533824207 + 3.4034147216154844, + 3.5654439351482963 ], - "best_of_k_cost": 2.8573185345338623, - "best_seed": 789, - "median_seed": 1, - "worst_seed": 0 + "best_of_k_cost": 3.4218240307599648, + "best_seed": 7, + "median_seed": 789, + "worst_seed": 456 }, { "model": "thyroid", @@ -4366,215 +6356,251 @@ { "seed": 0, "metrics": { - "node_overlap": 0.9475094291329813, - "node_connector_overlap": 0.16311509059310642, - "label_overlap": 8.0, - "crossings": 1.5465116279069768, - "sprawl": 4.593256766713006, - "edge_length_cv": 0.6655162731800662, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8220632669372514, - "flow_bends": 0.5161290322580645, + "node_overlap": 0.0, + "node_connector_overlap": 0.047258394755699866, + "label_overlap": 0.003807179458524619, + "label_connector_overlap": 0.11969104559470285, + "crossings": 1.2965116279069768, + "crowding": 0.022467775932314665, + "sprawl": 5.487573832004016, + "long_connectors": 0.024706963878475086, + "edge_length_cv": 0.7094061194403837, + "aspect_penalty": 0.0, + "misalignment": 0.5414012738853503, + "loop_compactness": 0.7713710246875346, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.066242688905067 + "weighted_cost": 3.495568409496194 }, { "seed": 1, "metrics": { - "node_overlap": 0.9473664070893877, - "node_connector_overlap": 0.15699694971855294, - "label_overlap": 8.0, - "crossings": 1.5174418604651163, - "sprawl": 4.613256810418719, - "edge_length_cv": 0.6650933998969977, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8357804530190079, - "flow_bends": 0.5161290322580645, + "node_overlap": 0.0021131214637642734, + "node_connector_overlap": 0.05253034180175984, + "label_overlap": 0.006886710478175589, + "label_connector_overlap": 0.1497317115861618, + "crossings": 1.2848837209302326, + "crowding": 0.038461538461538464, + "sprawl": 4.179219618739655, + "long_connectors": 0.018484022203406458, + "edge_length_cv": 0.7003890490639603, + "aspect_penalty": 0.0, + "misalignment": 0.5064102564102564, + "loop_compactness": 0.7720254458711789, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.040398641718904 + "weighted_cost": 3.2402760843922582 }, { "seed": 2, "metrics": { - "node_overlap": 0.9474669000421844, - "node_connector_overlap": 0.15961585329189093, - "label_overlap": 8.0, - "crossings": 1.5348837209302326, - "sprawl": 4.589905760685724, - "edge_length_cv": 0.6508028889301408, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8232218390085322, - "flow_bends": 0.5161290322580645, + "node_overlap": 0.007493204991253309, + "node_connector_overlap": 0.06724175601966051, + "label_overlap": 0.010734710732554581, + "label_connector_overlap": 0.18577127882824251, + "crossings": 1.1511627906976745, + "crowding": 0.03455194629833066, + "sprawl": 3.129424564206718, + "long_connectors": 0.027244191681904055, + "edge_length_cv": 0.7269177322377651, + "aspect_penalty": 0.0, + "misalignment": 0.46875, + "loop_compactness": 0.7632464079854956, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.050866243159366 + "weighted_cost": 2.9530797148426684 }, { "seed": 3, "metrics": { - "node_overlap": 0.7150615602572254, - "node_connector_overlap": 0.15276654382128033, - "label_overlap": 6.249021709201575, - "crossings": 1.436046511627907, - "sprawl": 2.969212154628184, - "edge_length_cv": 0.6906897779485318, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.825165900336323, + "node_overlap": 0.0020732512474669032, + "node_connector_overlap": 0.054809855512164926, + "label_overlap": 0.01041706248039097, + "label_connector_overlap": 0.12426864355181942, + "crossings": 1.25, + "crowding": 0.025157232704402517, + "sprawl": 4.186972383788101, + "long_connectors": 0.017984047737966004, + "edge_length_cv": 0.6979725933044958, + "aspect_penalty": 0.0, + "misalignment": 0.5345911949685535, + "loop_compactness": 0.7879177931836382, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.619080158412975 + "weighted_cost": 3.181532406135104 }, { "seed": 4, "metrics": { - "node_overlap": 0.8120833602440553, - "node_connector_overlap": 0.15000562392360767, - "label_overlap": 6.032608695652174, - "crossings": 1.4186046511627908, - "sprawl": 3.4645371817711528, - "edge_length_cv": 0.674328783426335, + "node_overlap": 0.0, + "node_connector_overlap": 0.041231748490310305, + "label_overlap": 0.0, + "label_connector_overlap": 0.0901255322339576, + "crossings": 1.3372093023255813, + "crowding": 0.004944620253164557, + "sprawl": 9.681074043063228, + "long_connectors": 0.013566478339532064, + "edge_length_cv": 0.6812014195923911, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8280517525504121, + "misalignment": 0.620253164556962, + "loop_compactness": 0.7814603481562367, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.579705510801846 + "weighted_cost": 4.5037419660088895 }, { "seed": 5, "metrics": { - "node_overlap": 0.7174709354361297, - "node_connector_overlap": 0.15221434524351232, - "label_overlap": 6.283548608839896, - "crossings": 1.4593023255813953, - "sprawl": 3.010044657139332, - "edge_length_cv": 0.6824517450565238, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8167443447357939, + "node_overlap": 0.0020996620913836792, + "node_connector_overlap": 0.05289679263471125, + "label_overlap": 0.0123470539765291, + "label_connector_overlap": 0.13110200474912584, + "crossings": 1.2965116279069768, + "crowding": 0.03187919569391317, + "sprawl": 4.220435193695195, + "long_connectors": 0.017598140700117587, + "edge_length_cv": 0.6981595900364533, + "aspect_penalty": 0.0, + "misalignment": 0.47770700636942676, + "loop_compactness": 0.7683043364288739, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.683517926867939 + "weighted_cost": 3.242676268658867 }, { "seed": 6, "metrics": { - "node_overlap": 0.7156623857065336, - "node_connector_overlap": 0.15420855315247817, - "label_overlap": 6.28809697258654, - "crossings": 1.3662790697674418, - "sprawl": 3.0106158321082033, - "edge_length_cv": 0.6754181753025363, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8231536101246992, + "node_overlap": 0.0021131214637643437, + "node_connector_overlap": 0.05356998387912663, + "label_overlap": 0.010030175572326246, + "label_connector_overlap": 0.14353337810711755, + "crossings": 1.2906976744186047, + "crowding": 0.03205128205128205, + "sprawl": 4.222753891910113, + "long_connectors": 0.019844899600035975, + "edge_length_cv": 0.7036902055878534, + "aspect_penalty": 0.0, + "misalignment": 0.5512820512820513, + "loop_compactness": 0.7637375346693229, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.597906634129338 + "weighted_cost": 3.2561997152334357 }, { "seed": 7, "metrics": { - "node_overlap": 0.9474669000421844, - "node_connector_overlap": 0.15944760535598151, - "label_overlap": 8.0, - "crossings": 1.5406976744186047, - "sprawl": 4.590749653255894, - "edge_length_cv": 0.652666678982102, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8232218390085322, - "flow_bends": 0.5161290322580645, + "node_overlap": 0.0020863730908053015, + "node_connector_overlap": 0.05379530089044034, + "label_overlap": 0.009886887349864435, + "label_connector_overlap": 0.1287399750724205, + "crossings": 1.25, + "crowding": 0.03164556962025317, + "sprawl": 4.134266243257995, + "long_connectors": 0.01972429125905956, + "edge_length_cv": 0.7117246880242802, + "aspect_penalty": 0.0, + "misalignment": 0.5063291139240507, + "loop_compactness": 0.7645398908045561, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.056680727225862 + "weighted_cost": 3.1664051621551863 }, { "seed": 42, "metrics": { - "node_overlap": 0.9474194395239735, - "node_connector_overlap": 0.15951487553991753, - "label_overlap": 8.0, - "crossings": 1.4767441860465116, - "sprawl": 4.550391194448503, - "edge_length_cv": 0.6755652540408777, + "node_overlap": 0.0, + "node_connector_overlap": 0.040429360808722246, + "label_overlap": 0.0, + "label_connector_overlap": 0.08441626893521555, + "crossings": 1.3313953488372092, + "crowding": 0.0, + "sprawl": 12.085614161735727, + "long_connectors": 0.028595490868111337, + "edge_length_cv": 0.7132582302568253, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8245557247431585, - "flow_bends": 0.5161290322580645, + "misalignment": 0.7278481012658228, + "loop_compactness": 0.7910108756504212, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 11.985208911051867 + "weighted_cost": 5.106043962557037 }, { "seed": 123, "metrics": { - "node_overlap": 0.9475199381022458, - "node_connector_overlap": 0.16273336028752633, - "label_overlap": 8.0, - "crossings": 1.5465116279069768, - "sprawl": 4.650273848168665, - "edge_length_cv": 0.6458063838582816, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8232001305126443, - "flow_bends": 0.5161290322580645, + "node_overlap": 0.0020996620913837096, + "node_connector_overlap": 0.05387215926966996, + "label_overlap": 0.010270713992247342, + "label_connector_overlap": 0.12679258126478724, + "crossings": 1.255813953488372, + "crowding": 0.03184713375796178, + "sprawl": 4.179266349004306, + "long_connectors": 0.01825029545062915, + "edge_length_cv": 0.6952576207032689, + "aspect_penalty": 0.0, + "misalignment": 0.4713375796178344, + "loop_compactness": 0.787653590769455, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.077729629290038 + "weighted_cost": 3.1873025656663416 }, { "seed": 456, "metrics": { - "node_overlap": 0.8758707162888123, - "node_connector_overlap": 0.15065851254467985, - "label_overlap": 7.490550486574051, - "crossings": 1.3895348837209303, - "sprawl": 3.881120076093125, - "edge_length_cv": 0.6871482315467444, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8198932555549225, + "node_overlap": 0.0020863730908053015, + "node_connector_overlap": 0.05500952251318525, + "label_overlap": 0.009396126825366191, + "label_connector_overlap": 0.1412146275654062, + "crossings": 1.180232558139535, + "crowding": 0.02531645569620253, + "sprawl": 4.209680395630609, + "long_connectors": 0.024582826144026234, + "edge_length_cv": 0.7124819391629108, + "aspect_penalty": 0.0, + "misalignment": 0.4683544303797469, + "loop_compactness": 0.7873528403663758, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 11.153070959013892 + "weighted_cost": 3.136341883525829 }, { "seed": 789, "metrics": { - "node_overlap": 0.9475206750753469, - "node_connector_overlap": 0.16315133076397784, - "label_overlap": 8.044040066012924, - "crossings": 1.563953488372093, - "sprawl": 4.572091092052931, - "edge_length_cv": 0.6622663943485297, + "node_overlap": 0.0, + "node_connector_overlap": 0.03833678603089581, + "label_overlap": 0.0, + "label_connector_overlap": 0.09798655414118877, + "crossings": 1.3837209302325582, + "crowding": 0.0, + "sprawl": 12.646886122411344, + "long_connectors": 0.018915920030832704, + "edge_length_cv": 0.6862236997040172, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8220247257512111, - "flow_bends": 0.5161290322580645, + "misalignment": 0.713375796178344, + "loop_compactness": 0.7734185599631833, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.123523550089912 + "weighted_cost": 5.301533870172316 } ], - "median_cost": 12.012803776385386, + "median_cost": 3.2414761765255626, "spread": [ - 9.667408484754198, - 12.059071217645663 + 3.1777505951401244, + 3.7476117986243676 ], - "best_of_k_cost": 11.153070959013892, - "best_seed": 4, + "best_of_k_cost": 3.136341883525829, + "best_seed": 2, "median_seed": 1, "worst_seed": 789 }, @@ -4584,216 +6610,252 @@ { "seed": 0, "metrics": { - "node_overlap": 0.006305257786806489, - "node_connector_overlap": 0.024290261455352583, + "node_overlap": 0.0, + "node_connector_overlap": 0.0040316802525391954, "label_overlap": 0.0, - "crossings": 1.074468085106383, - "sprawl": 2.6162576353814475, - "edge_length_cv": 0.450891737122621, + "label_connector_overlap": 0.24975137270151623, + "crossings": 1.4260651629072683, + "crowding": 0.0, + "sprawl": 4.003551449497084, + "long_connectors": 0.0024800749950298706, + "edge_length_cv": 0.5239595257350682, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5162635378033302, + "misalignment": 0.8202614379084967, + "loop_compactness": 0.7583134932798062, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.9324676053696932 + "weighted_cost": 3.303882082262709 }, { "seed": 1, "metrics": { - "node_overlap": 0.008313379487320138, - "node_connector_overlap": 0.026635318481394193, - "label_overlap": 0.0, - "crossings": 1.0212765957446808, - "sprawl": 2.0267142784782166, - "edge_length_cv": 0.4806988985456621, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.504836724777955, - "flow_bends": 0.058823529411764705, - "loop_straightness": 0.8 + "node_overlap": 0.0, + "node_connector_overlap": 0.00601276933626423, + "label_overlap": 0.00031770918022459794, + "label_connector_overlap": 0.3085306442919431, + "crossings": 1.3709273182957393, + "crowding": 0.003257328990228013, + "sprawl": 2.988169251831432, + "long_connectors": 0.0028198176181011576, + "edge_length_cv": 0.5521566243668414, + "aspect_penalty": 0.0, + "misalignment": 0.755700325732899, + "loop_compactness": 0.709689589768022, + "flow_bends": 0.11764705882352941, + "loop_straightness": 0.9 }, - "weighted_cost": 1.7523263687319852 + "weighted_cost": 3.065663283598133 }, { "seed": 2, "metrics": { - "node_overlap": 0.006305257786806491, - "node_connector_overlap": 0.02635893165067456, + "node_overlap": 0.0, + "node_connector_overlap": 0.0070832425902664815, "label_overlap": 0.0, - "crossings": 1.0186170212765957, - "sprawl": 2.604097078015303, - "edge_length_cv": 0.45600725649134544, + "label_connector_overlap": 0.31767475463245753, + "crossings": 1.4210526315789473, + "crowding": 0.000051062091503267975, + "sprawl": 3.0759582481631775, + "long_connectors": 0.0023195666679399914, + "edge_length_cv": 0.5219608127650907, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5094623614827638, + "misalignment": 0.7875816993464052, + "loop_compactness": 0.7567324937102916, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.8735326297337724 + "weighted_cost": 3.171029882416721 }, { "seed": 3, "metrics": { - "node_overlap": 0.00829216842796186, - "node_connector_overlap": 0.028033870891733165, + "node_overlap": 0.0, + "node_connector_overlap": 0.006380611447971917, "label_overlap": 0.0, - "crossings": 0.9228723404255319, - "sprawl": 2.080055775759923, - "edge_length_cv": 0.5149695620472804, + "label_connector_overlap": 0.2985978421665494, + "crossings": 1.4260651629072683, + "crowding": 0.0025023671371847875, + "sprawl": 3.00445638791185, + "long_connectors": 0.0021207984383328155, + "edge_length_cv": 0.527492026431437, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4725335952064032, + "misalignment": 0.7483660130718954, + "loop_compactness": 0.7124573505598858, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.6618700318033022 + "weighted_cost": 3.1088666127420232 }, { "seed": 4, "metrics": { - "node_overlap": 0.008302760410691475, - "node_connector_overlap": 0.02768697190822113, - "label_overlap": 0.11206896551724138, - "crossings": 0.9069148936170213, - "sprawl": 1.990570589596074, - "edge_length_cv": 0.48239456714935713, + "node_overlap": 0.0, + "node_connector_overlap": 0.0040316802525391954, + "label_overlap": 0.0, + "label_connector_overlap": 0.24975137270151623, + "crossings": 1.4260651629072683, + "crowding": 0.0, + "sprawl": 4.003551449497084, + "long_connectors": 0.0024800749950298706, + "edge_length_cv": 0.5239595257350682, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.49650043175824465, + "misalignment": 0.8202614379084967, + "loop_compactness": 0.7583134932798062, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.7493349408992174 + "weighted_cost": 3.303882082262709 }, { "seed": 5, "metrics": { - "node_overlap": 0.006305257786806491, - "node_connector_overlap": 0.02635893165067456, + "node_overlap": 0.0, + "node_connector_overlap": 0.0061840021330689405, "label_overlap": 0.0, - "crossings": 1.0186170212765957, - "sprawl": 2.604097078015303, - "edge_length_cv": 0.45600725649134544, + "label_connector_overlap": 0.32266257316953983, + "crossings": 1.406015037593985, + "crowding": 0.0008146107493788327, + "sprawl": 3.125861942812828, + "long_connectors": 0.0022087710549828273, + "edge_length_cv": 0.5447017052690557, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5094623614827638, + "misalignment": 0.801948051948052, + "loop_compactness": 0.7341690060554753, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.8735326297337724 + "weighted_cost": 3.1672708500350346 }, { "seed": 6, "metrics": { - "node_overlap": 0.006299147364042278, - "node_connector_overlap": 0.024031182977694753, + "node_overlap": 0.0, + "node_connector_overlap": 0.012860844607951102, "label_overlap": 0.0, - "crossings": 0.9893617021276596, - "sprawl": 2.5798152761929267, - "edge_length_cv": 0.47564391468778217, + "label_connector_overlap": 0.37955252147232815, + "crossings": 1.3934837092731829, + "crowding": 0.0002638984459492339, + "sprawl": 2.3179207173661416, + "long_connectors": 0.0030111475121754505, + "edge_length_cv": 0.5473363957560841, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5157461335500366, + "misalignment": 0.6568627450980392, + "loop_compactness": 0.7633480807697297, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.8396005999515261 + "weighted_cost": 3.0484563978823753 }, { "seed": 7, "metrics": { - "node_overlap": 0.00830276041069147, - "node_connector_overlap": 0.02782907900703134, + "node_overlap": 0.0, + "node_connector_overlap": 0.007416353996088374, "label_overlap": 0.0, - "crossings": 0.9414893617021277, - "sprawl": 2.0455433440560014, - "edge_length_cv": 0.46983011799055296, + "label_connector_overlap": 0.31790517930781514, + "crossings": 1.4285714285714286, + "crowding": 1.9989467019680553e-6, + "sprawl": 3.0233030424690326, + "long_connectors": 0.0028977488250940407, + "edge_length_cv": 0.5487771303979583, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4705403791154808, + "misalignment": 0.8137254901960784, + "loop_compactness": 0.7264437214971283, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.6725930804007727 + "weighted_cost": 3.157135635943824 }, { "seed": 42, "metrics": { - "node_overlap": 0.006305257786806492, - "node_connector_overlap": 0.023858531887311775, + "node_overlap": 0.0, + "node_connector_overlap": 0.0040316802525391954, "label_overlap": 0.0, - "crossings": 1.0558510638297873, - "sprawl": 2.6011344357116646, - "edge_length_cv": 0.49473966671777525, + "label_connector_overlap": 0.24975137270151623, + "crossings": 1.4260651629072683, + "crowding": 0.0, + "sprawl": 4.003551449497084, + "long_connectors": 0.0024800749950298706, + "edge_length_cv": 0.5239595257350682, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.45250370363083936, + "misalignment": 0.8202614379084967, + "loop_compactness": 0.7583134932798062, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.8848902809221035 + "weighted_cost": 3.303882082262709 }, { "seed": 123, "metrics": { - "node_overlap": 0.006305257786806491, - "node_connector_overlap": 0.02635893165067456, + "node_overlap": 0.0, + "node_connector_overlap": 0.006359096406258597, "label_overlap": 0.0, - "crossings": 1.0186170212765957, - "sprawl": 2.604097078015303, - "edge_length_cv": 0.45600725649134544, + "label_connector_overlap": 0.30196272874725627, + "crossings": 1.3884711779448622, + "crowding": 0.00006387110798014177, + "sprawl": 2.9921416261467906, + "long_connectors": 0.0024958019331311324, + "edge_length_cv": 0.5420869031824768, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5094623614827638, + "misalignment": 0.762214983713355, + "loop_compactness": 0.7775885400884955, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.8735326297337724 + "weighted_cost": 3.09838461571977 }, { "seed": 456, "metrics": { - "node_overlap": 0.008313379487320145, - "node_connector_overlap": 0.03043818135113868, + "node_overlap": 0.0, + "node_connector_overlap": 0.0044068880015103945, "label_overlap": 0.0, - "crossings": 0.9521276595744681, - "sprawl": 2.0284642058228726, - "edge_length_cv": 0.4697240772395471, + "label_connector_overlap": 0.2679580532875862, + "crossings": 1.3208020050125313, + "crowding": 0.0, + "sprawl": 3.936591003999845, + "long_connectors": 0.003010807012671608, + "edge_length_cv": 0.5459296947893818, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4768223574178368, + "misalignment": 0.803921568627451, + "loop_compactness": 0.6810463180161683, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.6849480633681657 + "weighted_cost": 3.17766375834597 }, { "seed": 789, "metrics": { - "node_overlap": 0.006305257786806492, - "node_connector_overlap": 0.023880710798448255, + "node_overlap": 0.0, + "node_connector_overlap": 0.008931404876023322, "label_overlap": 0.0, - "crossings": 1.0159574468085106, - "sprawl": 2.6104826172140685, - "edge_length_cv": 0.4894754039643907, + "label_connector_overlap": 0.3174339321350458, + "crossings": 1.3934837092731829, + "crowding": 0.00006554566810633066, + "sprawl": 2.9946163752397768, + "long_connectors": 0.0026786900044870085, + "edge_length_cv": 0.5484304167377415, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.526890651939126, + "misalignment": 0.760655737704918, + "loop_compactness": 0.7501365477838865, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 1.876643258435759 + "weighted_cost": 3.1213236534156676 } ], - "median_cost": 1.8565666148426492, + "median_cost": 3.1622032429894293, "spread": [ - 1.7332382215164546, - 1.8743102869092692 + 3.10624611348646, + 3.2092183393251545 ], - "best_of_k_cost": 1.6849480633681657, - "best_seed": 3, - "median_seed": 6, + "best_of_k_cost": 3.09838461571977, + "best_seed": 6, + "median_seed": 5, "worst_seed": 0 }, { @@ -4802,218 +6864,254 @@ { "seed": 0, "metrics": { - "node_overlap": 0.028738690792974985, - "node_connector_overlap": 0.05124641556980498, - "label_overlap": 0.0, - "crossings": 0.8603351955307262, - "sprawl": 2.7139202176054287, - "edge_length_cv": 0.6655845813203781, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.48973275125194426, + "node_overlap": 0.0, + "node_connector_overlap": 0.008515647591446468, + "label_overlap": 0.0, + "label_connector_overlap": 0.045015958586544516, + "crossings": 0.7487684729064039, + "crowding": 0.0, + "sprawl": 8.041678599089511, + "long_connectors": 0.0013864829790203113, + "edge_length_cv": 0.5219536291649493, + "aspect_penalty": 0.0502482739700052, + "misalignment": 0.5694444444444444, + "loop_compactness": 0.749831844402728, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.7456641125820365 + "weighted_cost": 3.3013137794365375 }, { "seed": 1, "metrics": { - "node_overlap": 0.01524204971760771, - "node_connector_overlap": 0.011941337957154092, - "label_overlap": 0.13470682718383342, - "crossings": 0.9553072625698324, - "sprawl": 5.479561184498017, - "edge_length_cv": 0.5739533293346532, + "node_overlap": 0.0, + "node_connector_overlap": 0.030694046846615117, + "label_overlap": 0.0, + "label_connector_overlap": 0.08533487391319618, + "crossings": 0.625615763546798, + "crowding": 0.00039703339515398845, + "sprawl": 3.384094919362916, + "long_connectors": 0.0017960153527202536, + "edge_length_cv": 0.5676971461697253, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.49480668555743135, + "misalignment": 0.45070422535211263, + "loop_compactness": 0.7223916147741609, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 2.4776990552176708 + "weighted_cost": 2.0963520074669413 }, { "seed": 2, "metrics": { - "node_overlap": 0.028738690792974985, - "node_connector_overlap": 0.03833122664056828, - "label_overlap": 0.0, - "crossings": 0.6480446927374302, - "sprawl": 2.734698014412557, - "edge_length_cv": 0.6037978212862715, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5154635281687995, + "node_overlap": 0.0, + "node_connector_overlap": 0.033055604965445566, + "label_overlap": 0.0, + "label_connector_overlap": 0.0731514598644199, + "crossings": 0.6748768472906403, + "crowding": 0.00011961274450225488, + "sprawl": 3.407563196804997, + "long_connectors": 0.0029682752159589515, + "edge_length_cv": 0.5905833641154387, + "aspect_penalty": 0.29142120851349507, + "misalignment": 0.38888888888888884, + "loop_compactness": 0.7297070908635389, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.5349062909876714 + "weighted_cost": 2.1349815218061967 }, { "seed": 3, "metrics": { - "node_overlap": 0.028738690792975013, - "node_connector_overlap": 0.040597114630195295, - "label_overlap": 0.0, - "crossings": 0.8491620111731844, - "sprawl": 2.7110268081414146, - "edge_length_cv": 0.6414205715616035, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5118828556068377, + "node_overlap": 0.0, + "node_connector_overlap": 0.00421426814257865, + "label_overlap": 0.0, + "label_connector_overlap": 0.03402713857056825, + "crossings": 0.7241379310344828, + "crowding": 0.0, + "sprawl": 13.555615623825176, + "long_connectors": 0.0019085982440584072, + "edge_length_cv": 0.5233098244988732, + "aspect_penalty": 0.20660814485481005, + "misalignment": 0.7272727272727273, + "loop_compactness": 0.7551168535314788, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.7321229871340393 + "weighted_cost": 4.64823939439368 }, { "seed": 4, "metrics": { - "node_overlap": 0.014500762767488271, - "node_connector_overlap": 0.005843052674717507, - "label_overlap": 0.050231268456817, - "crossings": 1.089385474860335, - "sprawl": 5.896945633173063, - "edge_length_cv": 0.6392516724928246, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.50277865606712, + "node_overlap": 0.0, + "node_connector_overlap": 0.004389029571802292, + "label_overlap": 0.0, + "label_connector_overlap": 0.02577759418222489, + "crossings": 1.019704433497537, + "crowding": 0.0, + "sprawl": 13.801709313520252, + "long_connectors": 0.0036875660196993114, + "edge_length_cv": 0.5339934604428946, + "aspect_penalty": 0.2010157308450775, + "misalignment": 0.7285714285714286, + "loop_compactness": 0.7287471335500846, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 2.607127814487485 + "weighted_cost": 4.9837759915815685 }, { "seed": 5, "metrics": { - "node_overlap": 0.028738690792974985, - "node_connector_overlap": 0.03340234145379649, - "label_overlap": 0.0, - "crossings": 0.7597765363128491, - "sprawl": 2.6896286213088136, - "edge_length_cv": 0.649016564770906, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5155600178231583, + "node_overlap": 0.0, + "node_connector_overlap": 0.004345823618383122, + "label_overlap": 0.0, + "label_connector_overlap": 0.04206962776995305, + "crossings": 1.0492610837438423, + "crowding": 0.0, + "sprawl": 13.863702882811596, + "long_connectors": 0.0037852618400693646, + "edge_length_cv": 0.5236961798159608, + "aspect_penalty": 0.1795325798434475, + "misalignment": 0.7266187050359711, + "loop_compactness": 0.7243223387577219, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.6327339666173133 + "weighted_cost": 5.0512663302651575 }, { "seed": 6, "metrics": { - "node_overlap": 0.0316803557092571, - "node_connector_overlap": 0.07187165120137179, + "node_overlap": 0.0, + "node_connector_overlap": 0.047709297828687616, "label_overlap": 0.0, - "crossings": 0.659217877094972, - "sprawl": 2.191993340313167, - "edge_length_cv": 0.6808473537367026, + "label_connector_overlap": 0.09069503852209548, + "crossings": 0.6009852216748769, + "crowding": 0.006420353346659565, + "sprawl": 2.4984993555084647, + "long_connectors": 0.003452808972906324, + "edge_length_cv": 0.5967060519385843, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5226237460504322, + "misalignment": 0.28873239436619713, + "loop_compactness": 0.7414965963961185, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.476884717155074 + "weighted_cost": 1.8906898498206914 }, { "seed": 7, "metrics": { - "node_overlap": 0.02564326864198148, - "node_connector_overlap": 0.022500899187697838, + "node_overlap": 0.0, + "node_connector_overlap": 0.008170842572495412, "label_overlap": 0.0, - "crossings": 0.8324022346368715, - "sprawl": 3.3612716499710404, - "edge_length_cv": 0.636934626207713, + "label_connector_overlap": 0.05839700350647626, + "crossings": 0.8472906403940886, + "crowding": 0.0, + "sprawl": 7.801249729968184, + "long_connectors": 0.004087870159172304, + "edge_length_cv": 0.5494186037105947, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5536700212856426, + "misalignment": 0.5285714285714286, + "loop_compactness": 0.7294612726816907, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.8409354076416826 + "weighted_cost": 3.348225850300245 }, { "seed": 42, "metrics": { - "node_overlap": 0.014077173299414129, - "node_connector_overlap": 0.011590620172461768, + "node_overlap": 0.0, + "node_connector_overlap": 0.00960545897404366, "label_overlap": 0.0, - "crossings": 0.9217877094972067, - "sprawl": 5.433100230635522, - "edge_length_cv": 0.5651560939180177, + "label_connector_overlap": 0.0413807226557664, + "crossings": 0.8325123152709359, + "crowding": 0.0, + "sprawl": 8.188906359662944, + "long_connectors": 0.0013409854676792142, + "edge_length_cv": 0.5649172349784349, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5232375534703375, + "misalignment": 0.5442176870748299, + "loop_compactness": 0.733558178521075, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 2.3100372371509885 + "weighted_cost": 3.4095364399681616 }, { "seed": 123, "metrics": { - "node_overlap": 0.031680355709257126, - "node_connector_overlap": 0.061253576731312716, - "label_overlap": 0.0, - "crossings": 0.770949720670391, - "sprawl": 2.2024636554666874, - "edge_length_cv": 0.6604846197995592, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5277887129238317, + "node_overlap": 0.0, + "node_connector_overlap": 0.0041305959187599075, + "label_overlap": 0.0, + "label_connector_overlap": 0.028261307251576596, + "crossings": 1.0098522167487685, + "crowding": 0.0, + "sprawl": 13.950454227878101, + "long_connectors": 0.0037450326133398213, + "edge_length_cv": 0.5375881052584417, + "aspect_penalty": 0.14945616844116572, + "misalignment": 0.7112676056338028, + "loop_compactness": 0.7267041835901349, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.5821585360404977 + "weighted_cost": 5.011799876739282 }, { "seed": 456, "metrics": { - "node_overlap": 0.031680355709257105, - "node_connector_overlap": 0.05264428011455551, - "label_overlap": 0.0, - "crossings": 0.8491620111731844, - "sprawl": 2.2161925387452794, - "edge_length_cv": 0.6716631900653252, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5326227150881526, + "node_overlap": 0.0, + "node_connector_overlap": 0.0046051759560047706, + "label_overlap": 0.0, + "label_connector_overlap": 0.03408920214700816, + "crossings": 1.0246305418719213, + "crowding": 0.0, + "sprawl": 14.030551389114654, + "long_connectors": 0.0031701252753462603, + "edge_length_cv": 0.5254019861275493, + "aspect_penalty": 0.25237999187624727, + "misalignment": 0.7142857142857143, + "loop_compactness": 0.7283153928355636, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.6564409074479804 + "weighted_cost": 5.056952335483576 }, { "seed": 789, "metrics": { - "node_overlap": 0.02564326864198148, - "node_connector_overlap": 0.02281358006160794, + "node_overlap": 0.0, + "node_connector_overlap": 0.00968444216280995, "label_overlap": 0.0, - "crossings": 0.8994413407821229, - "sprawl": 3.2774152064318076, - "edge_length_cv": 0.606125352979846, + "label_connector_overlap": 0.031100462590551648, + "crossings": 0.6551724137931034, + "crowding": 0.0, + "sprawl": 7.748216277254208, + "long_connectors": 0.0012898192522716844, + "edge_length_cv": 0.5600065070338006, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.511267386135937, + "misalignment": 0.5555555555555556, + "loop_compactness": 0.7562928511833747, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 1.8745548518931157 + "weighted_cost": 3.116963666973144 } ], - "median_cost": 1.7388935498580378, + "median_cost": 3.378881145134203, "spread": [ - 1.6200901089731095, - 1.983425448207584 + 2.871468130681407, + 4.990781962870997 ], - "best_of_k_cost": 1.5821585360404977, + "best_of_k_cost": 3.116963666973144, "best_seed": 6, - "median_seed": 3, - "worst_seed": 4 + "median_seed": 7, + "worst_seed": 456 } ], - "aggregate_cost": 0.8011384042018516 + "aggregate_cost": 1.2076721409035505 } \ No newline at end of file diff --git a/src/simlin-engine/src/diagram/elements.rs b/src/simlin-engine/src/diagram/elements.rs index 04215e974..53b523747 100644 --- a/src/simlin-engine/src/diagram/elements.rs +++ b/src/simlin-engine/src/diagram/elements.rs @@ -176,7 +176,9 @@ pub fn render_module(element: &view_element::Module) -> String { svg } -pub fn module_bounds(element: &view_element::Module) -> Rect { +/// The module's bare *shape* box (the rounded rect), WITHOUT its label. See +/// `aux_shape_bounds` for why the label-free shape is exposed separately. +pub(crate) fn module_shape_bounds(element: &view_element::Module) -> Rect { let cx = element.x; let cy = element.y; let w = MODULE_WIDTH; @@ -189,6 +191,19 @@ pub fn module_bounds(element: &view_element::Module) -> Rect { } } +/// The module's drawn extent: its shape and its label, as the TS Canvas's +/// `moduleBounds` measures it. +pub fn module_bounds(element: &view_element::Module) -> Rect { + let label_props = LabelProps::new( + element.x, + element.y, + element.label_side, + display_name(&element.name), + ) + .with_radii(MODULE_WIDTH / 2.0, MODULE_HEIGHT / 2.0); + element_with_label_bounds(module_shape_bounds(element), &label_props) +} + // --- Cloud --- pub fn render_cloud(element: &view_element::Cloud) -> String { diff --git a/src/simlin-engine/src/diagram/render.rs b/src/simlin-engine/src/diagram/render.rs index ee7e8d2f0..68c10cb40 100644 --- a/src/simlin-engine/src/diagram/render.rs +++ b/src/simlin-engine/src/diagram/render.rs @@ -690,6 +690,78 @@ mod tests { ); } + #[test] + fn test_render_svg_view_box_holds_every_node_label() { + // The viewBox must hold each node's label as well as its shape, as the + // TS Canvas bounds do, or a name at the diagram's edge is clipped. One + // row per labeled node kind the renderer bounds, each at the same spot + // with its label below and the radii its renderer draws the label at + // (a flow needs two endpoints to be drawn at all). Aliases are + // deliberately unbounded, as on the TS Canvas. + use crate::diagram::constants::{ + AUX_RADIUS, MODULE_HEIGHT, MODULE_WIDTH, STOCK_HEIGHT, STOCK_WIDTH, + }; + use crate::diagram::label::{LabelProps, label_bounds}; + let name = "a fairly long name"; + let rows: Vec<(&str, Vec, (f64, f64))> = vec![ + ( + "aux", + vec![make_aux_ve(name, 1, 100.0, 100.0)], + (AUX_RADIUS, AUX_RADIUS), + ), + ( + "flow", + vec![ + make_cloud_ve(2, 1, 40.0, 100.0), + make_cloud_ve(3, 1, 160.0, 100.0), + make_flow_ve( + name, + 1, + 100.0, + 100.0, + vec![(40.0, 100.0, Some(2)), (160.0, 100.0, Some(3))], + ), + ], + (AUX_RADIUS, AUX_RADIUS), + ), + ( + "stock", + vec![make_stock_ve(name, 1, 100.0, 100.0)], + (STOCK_WIDTH / 2.0, STOCK_HEIGHT / 2.0), + ), + ( + "module", + vec![ViewElement::Module(view_element::Module { + name: name.to_string(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: LabelSide::Bottom, + })], + (MODULE_WIDTH / 2.0, MODULE_HEIGHT / 2.0), + ), + ]; + for (kind, elements, (rw, rh)) in rows { + let svg = render_svg(&make_simple_project(elements, vec![]), "main") + .unwrap_or_else(|e| panic!("{kind}: {e:?}")); + let vb = view_box_of(&svg); + let label = label_bounds( + &LabelProps::new(100.0, 100.0, LabelSide::Bottom, name.to_string()) + .with_radii(rw, rh), + ); + assert!( + vb[1] + vb[3] >= label.bottom + && vb[0] <= label.left + && vb[0] + vb[2] >= label.right, + "{kind}: viewBox {vb:?} must hold the label ({}, {})-({}, {})", + label.left, + label.top, + label.right, + label.bottom + ); + } + } + #[test] fn test_render_svg_z_order() { // Verify z-ordering: groups (0) < connectors (2) < flows (3) < stocks/clouds (4) < aux (5) diff --git a/src/simlin-engine/src/layout/annealing.rs b/src/simlin-engine/src/layout/annealing.rs index 988a77590..59860f3a9 100644 --- a/src/simlin-engine/src/layout/annealing.rs +++ b/src/simlin-engine/src/layout/annealing.rs @@ -49,13 +49,21 @@ pub struct FlowTemplate { /// Segments sharing an endpoint (same from_node or to_node) are NOT considered crossing. /// Parallel/collinear segments are NOT considered crossing. pub fn do_segments_intersect(s1: &LineSegment, s2: &LineSegment) -> bool { + segment_intersection(s1, s2).is_some() +} + +/// The point where two segments cross, under the crossing rules of +/// [`do_segments_intersect`] (the one owner of those rules): `None` for +/// segments sharing an endpoint node, parallel or collinear segments, and +/// segments whose lines meet outside either span. +pub fn segment_intersection(s1: &LineSegment, s2: &LineSegment) -> Option { // Adjacent edges (sharing any endpoint node) don't count as crossing if s1.from_node == s2.from_node || s1.from_node == s2.to_node || s1.to_node == s2.from_node || s1.to_node == s2.to_node { - return false; + return None; } // Direction vectors for each segment @@ -65,7 +73,7 @@ pub fn do_segments_intersect(s1: &LineSegment, s2: &LineSegment) -> bool { // Cross product of direction vectors gives the denominator let denom = d1.cross_2d(d2); if denom.abs() < 1e-10 { - return false; // Parallel or collinear + return None; // Parallel or collinear } // Vector from s1.start to s2.start @@ -76,7 +84,8 @@ pub fn do_segments_intersect(s1: &LineSegment, s2: &LineSegment) -> bool { let u = w.cross_2d(d1) / denom; // Segments intersect if both parameters are in [0, 1] - (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) + ((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)) + .then(|| Position::new(s1.start.x + t * d1.x, s1.start.y + t * d1.y)) } /// Count the number of edge crossings among a set of line segments. diff --git a/src/simlin-engine/src/layout/declutter.rs b/src/simlin-engine/src/layout/declutter.rs index 156c3e324..a83a5bf51 100644 --- a/src/simlin-engine/src/layout/declutter.rs +++ b/src/simlin-engine/src/layout/declutter.rs @@ -4,18 +4,17 @@ // pattern: Functional Core (geometry) + thin imperative shell (view mutation) // -// The layout-quality cost is dominated by `label_overlap`: auto-layout places -// nodes as near-points and ignores that each node carries a label box often far -// larger than the node itself, so labels pile onto neighbors and onto node -// shapes. That overlap is ALSO the entire source of seed-to-seed variance -- -// crossings are already near-optimal and low-variance, but where labels land is -// pure luck. This module makes the good outcome deterministic: it (1) picks each -// label's side to minimize its overlap with the rest of the diagram and (2) -// pushes overlapping element footprints (shape + label boxes) apart with a -// minimal-displacement, deterministic relaxation. Both operate on the EXACT -// geometry `layout::metrics` scores (`node_shape_box` / `element_label_props_for` -// + `label_bounds`), so reducing the boxes' overlap here reduces the metric by -// construction. +// Auto-layout places nodes as near-points and ignores that each node carries a +// label box often far larger than the node itself, so labels pile onto +// neighbors and node shapes, and lines run through names. Where labels land is +// also the main source of seed-to-seed variance. This module makes the good +// outcome deterministic: it (1) picks each label's side by what the metric +// charges for that label there -- covered by shapes and other labels, struck +// by links and pipes (`metrics::LabelScene`) -- and (2) pushes overlapping +// element footprints (shape + label boxes) apart with a minimal-displacement, +// deterministic relaxation. Both operate on the EXACT geometry `layout::metrics` +// scores (`node_shape_box` / `element_label_props_for` + `label_bounds`), so +// what they remove is what the metric counts. // // "Minimal displacement" is the key property: the relaxation only ever pushes // boxes the small distance needed to separate them (plus a fixed breathing @@ -28,17 +27,19 @@ use std::collections::HashMap; use crate::datamodel::ViewElement; use crate::datamodel::view_element::LabelSide; -use crate::diagram::common::{Rect, rect_overlap_area}; +use crate::diagram::common::{Rect, merge_bounds}; use crate::diagram::label::label_bounds; use super::metrics::{ - alias_label_props_for, alias_source_names, element_label_props_for, node_shape_box, + COMFORTABLE_CLEARANCE, LabelScene, MetricWeights, alias_label_props_for, alias_source_names, + element_label_props_for, node_shape_box, pipe_rects, }; /// Breathing room (logical units) enforced between any two element footprints -/// after decluttering. Small enough to stay compact, large enough that adjacent -/// boxes read as separate. ~half a label line-height. -const SEPARATION_MARGIN: f64 = 6.0; +/// after decluttering: the clearance below which the metric charges crowding, +/// so the tightest arrangement the declutter (and its compaction) reaches is +/// one the metric does not charge. +const SEPARATION_MARGIN: f64 = COMFORTABLE_CLEARANCE; /// Fraction of each iteration's accumulated push that is applied. Below 1.0 to /// damp oscillation when a node is squeezed between several neighbors; the loop @@ -162,13 +163,46 @@ pub fn remove_overlaps(items: &[Footprint], margin: f64) -> (Vec<(f64, f64)>, bo return (disp, true); } + // Each item's rects' union: the broad phase's box. + let bounds: Vec> = items + .iter() + .map(|item| item.rects.iter().copied().reduce(merge_bounds)) + .collect(); + let mut converged = false; for _ in 0..MAX_RELAX_ITERS { let mut net = vec![(0.0_f64, 0.0_f64); n]; let mut any_overlap = false; + // Broad phase: two items can be pushed apart only if their boxes, + // grown by the margin, meet. Candidates are visited in the (i, j) + // order a full pair scan uses and a skipped pair adds nothing, so the + // accumulated pushes are bit-identical to that scan. + let grown: Vec> = bounds + .iter() + .zip(&disp) + .map(|(b, d)| b.map(|b| grow(&translate(&b, d.0, d.1), margin))) + .collect(); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (k, b) in grown.iter().enumerate() { + if let Some(b) = b { + for cell in grid_cells(b) { + grid.entry(cell).or_default().push(k); + } + } + } + let mut candidates: Vec = Vec::new(); for i in 0..n { - for j in (i + 1)..n { + let Some(bi) = &grown[i] else { continue }; + candidates.clear(); + for cell in grid_cells(bi) { + if let Some(items_in_cell) = grid.get(&cell) { + candidates.extend(items_in_cell.iter().copied().filter(|&j| j > i)); + } + } + candidates.sort_unstable(); + candidates.dedup(); + for &j in &candidates { if !items[i].movable && !items[j].movable { continue; // two fixed obstacles never push each other } @@ -210,6 +244,26 @@ pub fn remove_overlaps(items: &[Footprint], margin: f64) -> (Vec<(f64, f64)>, bo (disp, converged) } +/// Cell size of the relaxation's broad-phase grid: about a node with its label. +const RELAX_GRID_CELL: f64 = 96.0; + +/// The broad-phase grid cells a box covers. +fn grid_cells(r: &Rect) -> impl Iterator { + let cell = |v: f64| (v / RELAX_GRID_CELL).floor() as i64; + let (x0, x1, y0, y1) = (cell(r.left), cell(r.right), cell(r.top), cell(r.bottom)); + (x0..=x1).flat_map(move |x| (y0..=y1).map(move |y| (x, y))) +} + +/// `r` grown by `d` on every side. +fn grow(r: &Rect, d: f64) -> Rect { + Rect { + top: r.top - d, + bottom: r.bottom + d, + left: r.left - d, + right: r.right + d, + } +} + /// A labeled element's per-side label-box options for side selection. pub struct LabelOptions { pub id: usize, @@ -217,61 +271,54 @@ pub struct LabelOptions { pub options: Vec<(LabelSide, Rect)>, } -/// Greedily choose each label's side to minimize the area of its label box -/// covered by (a) every OTHER element's shape box and (b) every OTHER label's -/// currently-chosen box. Mirrors the metric's `label_overlap` numerator (a -/// label is never charged against its own shape). Iterates `rounds` passes so a -/// choice can react to its neighbors' choices; ties keep the earlier (preferred) -/// side. Deterministic. PURE. -/// -/// `shape_boxes` is `(owner_id, shape)` for every element with a shape box; -/// entries whose `owner_id` equals the label's `id` are skipped. +/// Greedily choose each label's side to minimize `cost(id, box, label_of)`, +/// where `label_of(other_id)` is another label's currently-chosen box (`None` +/// for the label itself and for ids with no options). Iterates `rounds` passes +/// so a choice can react to its neighbors' choices; ties keep the earlier +/// (preferred) side. Deterministic. PURE. pub fn choose_label_sides( labels: &[LabelOptions], - shape_boxes: &[(usize, Rect)], + cost: impl Fn(usize, &Rect, &dyn Fn(usize) -> Option) -> f64, rounds: usize, ) -> HashMap { // Start each label on its first (preferred) option. let mut chosen: HashMap = labels .iter() - .filter(|l| !l.options.is_empty()) - .map(|l| (l.id, 0usize)) + .enumerate() + .filter(|(_, l)| !l.options.is_empty()) + .map(|(li, _)| (li, 0usize)) + .collect(); + let index_of: HashMap = labels + .iter() + .enumerate() + .map(|(li, l)| (l.id, li)) .collect(); - let label_box = |l: &LabelOptions, idx: usize| -> Rect { l.options[idx].1 }; - for _ in 0..rounds { let mut changed = false; - for l in labels { + for (li, l) in labels.iter().enumerate() { if l.options.is_empty() { continue; } + let label_of = |id: usize| -> Option { + let &oi = index_of.get(&id)?; + if oi == li { + return None; + } + chosen.get(&oi).map(|&idx| labels[oi].options[idx].1) + }; let mut best_idx = 0usize; let mut best_cost = f64::INFINITY; for (idx, (_side, lbox)) in l.options.iter().enumerate() { - let mut cost = 0.0; - for (owner, shape) in shape_boxes { - if *owner == l.id { - continue; // never charged against own shape - } - cost += rect_overlap_area(lbox, shape); - } - for other in labels { - if other.id == l.id { - continue; - } - if let Some(&oi) = chosen.get(&other.id) { - cost += rect_overlap_area(lbox, &label_box(other, oi)); - } - } + let c = cost(l.id, lbox, &label_of); // Strictly-less keeps the earlier (preferred) side on ties. - if cost < best_cost - 1e-9 { - best_cost = cost; + if c < best_cost - 1e-9 { + best_cost = c; best_idx = idx; } } - if chosen.get(&l.id) != Some(&best_idx) { - chosen.insert(l.id, best_idx); + if chosen.get(&li) != Some(&best_idx) { + chosen.insert(li, best_idx); changed = true; } } @@ -282,12 +329,7 @@ pub fn choose_label_sides( chosen .into_iter() - .map(|(id, idx)| { - ( - id, - labels[labels.iter().position(|l| l.id == id).unwrap()].options[idx].0, - ) - }) + .map(|(li, idx)| (labels[li].id, labels[li].options[idx].0)) .collect() } @@ -408,6 +450,26 @@ fn translate_element(element: &mut ViewElement, dx: f64, dy: f64) { } } +/// Every drawn shape box of an element, label excluded: its node shape plus, +/// for a flow, its pipe boxes -- what the relaxation keeps other footprints +/// off, since the metric charges a label or connector on any of them. +fn shape_rects(element: &ViewElement) -> Vec { + let mut rects: Vec = node_shape_box(element).into_iter().collect(); + if let ViewElement::Flow(f) = element { + rects.extend(pipe_rects(f)); + } + rects +} + +/// The uids a flow's pipe attaches to: a flow and those stocks/clouds touch by +/// construction, so their footprints meeting is not an overlap. +fn attached_uids(element: &ViewElement) -> Vec { + match element { + ViewElement::Flow(f) => f.points.iter().filter_map(|p| p.attached_to_uid).collect(), + _ => Vec::new(), + } +} + /// The label box an element currently occupies (its assigned side), or `None` /// for kinds with no scored label. An alias's label is its SOURCE element's /// name, resolved through `alias_names` (see `metrics::alias_source_names`); a @@ -480,7 +542,7 @@ fn scale_all_positions(elements: &mut [ViewElement], s: f64) { /// Mirrors `layout::resnap_flow_endpoints` but operates directly on the /// element slice this module works with. Uses the renderer's stock dimensions /// (`diagram::constants`), the geometry attachment is judged against. -fn resnap_flow_endpoints_to_stocks(elements: &mut [ViewElement]) { +pub(crate) fn resnap_flow_endpoints_to_stocks(elements: &mut [ViewElement]) { use crate::diagram::constants::{STOCK_HEIGHT, STOCK_WIDTH}; let stocks: HashMap = elements @@ -524,17 +586,23 @@ fn resnap_flow_endpoints_to_stocks(elements: &mut [ViewElement]) { } /// Re-choose label sides (for `relabels` kinds) on the current geometry, writing -/// the chosen sides back. Mutates `elements`. +/// the chosen sides back: each side is charged what the metric would charge +/// the label there -- covered by shapes and other labels, struck by links and +/// pipes -- so a name moves off a line running through it as readily as off a +/// shape. Mutates `elements`. fn optimize_label_sides(elements: &mut [ViewElement], alias_names: &HashMap) { - // Every element's shape box is an obstacle. Flow and alias labels need no - // separate obstacle entry: both are relabel-able (`relabels` includes - // them), so their label boxes participate as labels and are automatically - // avoided by every other label. - let obstacle_boxes: Vec<(usize, Rect)> = elements - .iter() - .enumerate() - .filter_map(|(i, e)| node_shape_box(e).map(|r| (i, r))) - .collect(); + optimize_label_sides_for(elements, alias_names, |_| true); +} + +/// [`optimize_label_sides`] for the elements whose uid `resides` accepts; every +/// other label keeps its side and counts against the chosen ones as it is. +fn optimize_label_sides_for( + elements: &mut [ViewElement], + alias_names: &HashMap, + resides: impl Fn(i32) -> bool, +) { + let scene = LabelScene::new(elements); + let weights = MetricWeights::default(); // The label box an element would occupy on `side`. Aliases resolve their // label text through their source element's name. @@ -549,7 +617,7 @@ fn optimize_label_sides(elements: &mut [ViewElement], alias_names: &HashMap = elements .iter() .enumerate() - .filter(|(_, e)| relabels(e)) + .filter(|(_, e)| relabels(e) && resides(e.get_uid())) .filter_map(|(i, e)| { let options: Vec<(LabelSide, Rect)> = candidate_sides(e) .iter() @@ -563,7 +631,26 @@ fn optimize_label_sides(elements: &mut [ViewElement], alias_names: &HashMap = elements.iter().map(ViewElement::get_uid).collect(); + let id_of_uid: HashMap = uids.iter().enumerate().map(|(i, &u)| (u, i)).collect(); + let placing: std::collections::HashSet = labels.iter().map(|l| l.id).collect(); + let drawn: HashMap = elements + .iter() + .filter_map(|e| current_label_box(e, alias_names).map(|r| (e.get_uid(), r))) + .collect(); + let chosen = choose_label_sides( + &labels, + |id, lbox, label_of| { + let current = |uid: i32| match id_of_uid.get(&uid) { + Some(other) if placing.contains(other) => label_of(*other), + _ => drawn.get(&uid).copied(), + }; + scene.label_cost(uids[id], lbox, current, &weights) + }, + LABEL_SIDE_ROUNDS, + ); for (id, side) in chosen { set_label_side(&mut elements[id], side); } @@ -575,14 +662,21 @@ fn optimize_label_sides(elements: &mut [ViewElement], alias_names: &HashMap) -> bool { + relax_positions_for(elements, alias_names, |_| true) +} + +/// [`relax_positions`] moving only the `is_movable` elements whose uid `moves` +/// accepts; everything else is an obstacle. +fn relax_positions_for( + elements: &mut [ViewElement], + alias_names: &HashMap, + moves: impl Fn(i32) -> bool, +) -> bool { let items: Vec = elements .iter() .enumerate() .filter_map(|(i, e)| { - let mut rects = Vec::with_capacity(2); - if let Some(shape) = node_shape_box(e) { - rects.push(shape); - } + let mut rects = shape_rects(e); if let Some(lbox) = current_label_box(e, alias_names) { rects.push(lbox); } @@ -592,7 +686,7 @@ fn relax_positions(elements: &mut [ViewElement], alias_names: &HashMap) -> bool { - let items: Vec> = elements + /// One element's footprint: its drawn shapes and its label, tagged so a + /// structural contact can be excused without excusing a label. + struct Item { + uid: i32, + attached: Vec, + rects: Vec<(bool, Rect)>, + } + let items: Vec = elements .iter() .filter_map(|e| { - let mut rects = Vec::with_capacity(2); - if let Some(shape) = node_shape_box(e) { - rects.push(shape); - } - if let Some(lbox) = current_label_box(e, alias_names) { - rects.push(lbox); + let rects: Vec<(bool, Rect)> = shape_rects(e) + .into_iter() + .map(|r| (false, r)) + .chain(current_label_box(e, alias_names).map(|r| (true, r))) + .collect(); + if rects.is_empty() { + None + } else { + Some(Item { + uid: e.get_uid(), + attached: attached_uids(e), + rects, + }) } - if rects.is_empty() { None } else { Some(rects) } }) .collect(); for i in 0..items.len() { for j in (i + 1)..items.len() { - for a in &items[i] { - for b in &items[j] { + let (a_item, b_item) = (&items[i], &items[j]); + // A flow's pipe meets the stock it attaches to by construction, so + // their SHAPES touching is not an overlap -- but either one's label + // landing on the other is. + let attached = + a_item.attached.contains(&b_item.uid) || b_item.attached.contains(&a_item.uid); + for (a_is_label, a) in &a_item.rects { + for (b_is_label, b) in &b_item.rects { + if attached && !*a_is_label && !*b_is_label { + continue; + } if separation_mtv(a, b, SEPARATION_MARGIN).is_some() { return true; } @@ -742,11 +858,36 @@ pub fn declutter_view(elements: &mut [ViewElement]) { // any over-zoom the jam recovery above introduced). This drives `sprawl` // down toward hand-drawn density without ever reintroducing an overlap. compact_view(elements, &alias_names); + // The compaction moved everything closer; choose the sides again on the + // final geometry. + optimize_label_sides(elements, &alias_names); +} + +/// Declutter part of a diagram around the rest, which stays exactly as it is: +/// choose label sides for the elements whose uid `resides` accepts, and push +/// the footprints of the free-floating elements `moves` accepts off everything +/// else. Nothing else changes -- no element outside the two sets moves or +/// changes sides, and there is no zoom and no compaction, which would move +/// them all -- so the incremental layout can polish what a patch added without +/// disturbing what was already drawn. +pub fn declutter_part( + elements: &mut [ViewElement], + resides: impl Fn(i32) -> bool, + moves: impl Fn(i32) -> bool, +) { + if elements.len() < 2 { + return; + } + let alias_names = alias_source_names(elements); + optimize_label_sides_for(elements, &alias_names, &resides); + relax_positions_for(elements, &alias_names, &moves); + optimize_label_sides_for(elements, &alias_names, &resides); } #[cfg(test)] mod tests { use super::*; + use crate::diagram::common::{rect_area, rect_overlap_area}; fn rect(left: f64, top: f64, right: f64, bottom: f64) -> Rect { Rect { @@ -919,6 +1060,98 @@ mod tests { assert!(converged, "already-clear layout converges immediately"); } + /// The all-pairs relaxation `remove_overlaps` must reproduce bit for bit. + fn remove_overlaps_by_full_scan(items: &[Footprint], margin: f64) -> (Vec<(f64, f64)>, bool) { + let n = items.len(); + let mut disp = vec![(0.0_f64, 0.0_f64); n]; + if n < 2 { + return (disp, true); + } + let mut converged = false; + for _ in 0..MAX_RELAX_ITERS { + let mut net = vec![(0.0_f64, 0.0_f64); n]; + let mut any_overlap = false; + for i in 0..n { + for j in (i + 1)..n { + if !items[i].movable && !items[j].movable { + continue; + } + let (si, sj) = match (items[i].movable, items[j].movable) { + (true, true) => (0.5, 0.5), + (true, false) => (1.0, 0.0), + (false, true) => (0.0, 1.0), + (false, false) => unreachable!(), + }; + for ra in &items[i].rects { + let ra = translate(ra, disp[i].0, disp[i].1); + for rb in &items[j].rects { + let rb = translate(rb, disp[j].0, disp[j].1); + if let Some((mx, my)) = separation_mtv(&ra, &rb, margin) { + any_overlap = true; + net[i].0 -= mx * si; + net[i].1 -= my * si; + net[j].0 += mx * sj; + net[j].1 += my * sj; + } + } + } + } + } + if !any_overlap { + converged = true; + break; + } + for k in 0..n { + if items[k].movable { + disp[k].0 += RELAX_STEP * net[k].0; + disp[k].1 += RELAX_STEP * net[k].1; + } + } + } + (disp, converged) + } + + #[test] + fn remove_overlaps_matches_a_full_pair_scan() { + // Crowded scenes of shape-plus-label footprints, a fifth of them fixed, + // dense enough that some jam: the broad phase must push exactly what + // the all-pairs scan pushes. + for scene in 0..4u64 { + let mut state = 0x9e37_79b9_7f4a_7c15u64 ^ scene; + let mut next = move || { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((state >> 11) as f64) / ((1u64 << 53) as f64) + }; + let count = 20 + 15 * scene as usize; + let span = 60.0 + 40.0 * scene as f64; + let items: Vec = (0..count) + .map(|id| { + let (x, y) = (next() * span, next() * span); + let shape = rect(x - 9.0, y - 9.0, x + 9.0, y + 9.0); + let w = 20.0 + next() * 80.0; + let label = rect(x - w / 2.0, y + 11.0, x + w / 2.0, y + 25.0); + Footprint { + id, + rects: vec![shape, label], + movable: next() > 0.2, + } + }) + .collect(); + let (disp, converged) = remove_overlaps(&items, SEPARATION_MARGIN); + let (want_disp, want_converged) = + remove_overlaps_by_full_scan(&items, SEPARATION_MARGIN); + assert_eq!(converged, want_converged, "scene {scene}"); + for (k, (got, want)) in disp.iter().zip(&want_disp).enumerate() { + assert!( + got.0.to_bits() == want.0.to_bits() && got.1.to_bits() == want.1.to_bits(), + "scene {scene} item {k}: {got:?} vs {want:?}" + ); + } + } + } + // ── choose_label_sides ── #[test] @@ -933,8 +1166,10 @@ mod tests { (LabelSide::Top, rect(-5.0, -20.0, 5.0, -11.0)), // clear ], }]; - let shape_boxes = vec![(1usize, blocker)]; - let chosen = choose_label_sides(&labels, &shape_boxes, 3); + let cost = |_: usize, r: &Rect, _: &dyn Fn(usize) -> Option| { + rect_overlap_area(r, &blocker) / rect_area(r) + }; + let chosen = choose_label_sides(&labels, cost, 3); assert_eq!(chosen.get(&0), Some(&LabelSide::Top)); } @@ -948,7 +1183,7 @@ mod tests { (LabelSide::Top, rect(0.0, -20.0, 10.0, -10.0)), ], }]; - let chosen = choose_label_sides(&labels, &[], 3); + let chosen = choose_label_sides(&labels, |_, _, _| 0.0, 3); assert_eq!(chosen.get(&0), Some(&LabelSide::Bottom)); } @@ -972,7 +1207,11 @@ mod tests { ], }, ]; - let chosen = choose_label_sides(&labels, &[], 3); + // Each label is charged its overlap with the other's current box. + let cost = |id: usize, r: &Rect, label_of: &dyn Fn(usize) -> Option| { + label_of(1 - id).map_or(0.0, |other| rect_overlap_area(r, &other)) + }; + let chosen = choose_label_sides(&labels, cost, 3); let s0 = chosen[&0]; let s1 = chosen[&1]; assert!( @@ -981,6 +1220,158 @@ mod tests { ); } + #[test] + fn test_label_side_moves_off_a_link_through_it() { + // A link runs straight through where aux #1's preferred Bottom label + // sits, on its way between two auxes far to either side. Nothing + // covers that label, so only a chooser that charges a line through a + // name -- as the metric does -- moves it off the link. + let target = aux_at(1, 200.0, 0.0, "a fairly long name"); + let bottom = current_label_box(&target, &HashMap::new()).expect("aux label"); + let y = (bottom.top + bottom.bottom) / 2.0; + let mut elements = vec![ + target, + aux_at(2, -200.0, y, "a"), + aux_at(3, 600.0, y, "b"), + ViewElement::Link(crate::datamodel::view_element::Link { + uid: 10, + from_uid: 2, + to_uid: 3, + shape: crate::datamodel::view_element::LinkShape::Straight, + polarity: None, + }), + ]; + + optimize_label_sides(&mut elements, &HashMap::new()); + + let ViewElement::Aux(a) = &elements[0] else { + unreachable!() + }; + assert_ne!( + a.label_side, + LabelSide::Bottom, + "the label must leave the side a link strikes through" + ); + } + + #[test] + fn test_label_side_moves_away_from_a_crowding_neighbor() { + // Aux #2 sits just below where aux #1's preferred Bottom label goes: + // nothing overlaps, but the name would jam against the neighbor's + // circle, which the metric charges as crowding. The chooser must + // charge it too and put the name elsewhere. + use crate::diagram::constants::AUX_RADIUS; + let target = aux_at(1, 0.0, 0.0, "name"); + let bottom = current_label_box(&target, &HashMap::new()).expect("aux label"); + let mut elements = vec![ + target, + aux_at(2, 0.0, bottom.bottom + 3.0 + AUX_RADIUS, "n"), + ]; + + optimize_label_sides(&mut elements, &HashMap::new()); + + let ViewElement::Aux(a) = &elements[0] else { + unreachable!() + }; + assert_ne!( + a.label_side, + LabelSide::Bottom, + "the label must leave the side where it crowds a neighbor" + ); + } + + #[test] + fn test_declutter_part_moves_only_the_elements_it_may_move() { + // An existing aux (#1) and a new one (#2) drawn on the same spot. Only + // the new one may move, so it alone steps off; the existing one keeps + // its position and label side exactly. + let mut elements = vec![ + aux_at(1, 100.0, 100.0, "existing name"), + aux_at(2, 100.0, 100.0, "new name"), + ]; + let before = elements[0].clone(); + + declutter_part(&mut elements, |uid| uid == 2, |uid| uid == 2); + + assert!( + elements[0] == before, + "the existing aux must keep its position and label side" + ); + let alias_names = HashMap::new(); + assert!( + !layout_has_overlap(&elements, &alias_names), + "the new aux must end clear of the existing one" + ); + } + + #[test] + fn test_declutter_part_resides_only_the_labels_it_may_reside() { + // A link strikes through the Bottom labels of two auxes: #1 existing, + // #4 new. Only the new label may change sides. + let existing = aux_at(1, 200.0, 0.0, "a fairly long name"); + let bottom = current_label_box(&existing, &HashMap::new()).expect("aux label"); + let y = (bottom.top + bottom.bottom) / 2.0; + let mut elements = vec![ + existing, + aux_at(2, -300.0, y, "a"), + aux_at(3, 900.0, y, "b"), + aux_at(4, 500.0, 0.0, "another long name"), + ViewElement::Link(crate::datamodel::view_element::Link { + uid: 10, + from_uid: 2, + to_uid: 3, + shape: crate::datamodel::view_element::LinkShape::Straight, + polarity: None, + }), + ]; + + declutter_part(&mut elements, |uid| uid == 4, |_| false); + + let side = |i: usize| match &elements[i] { + ViewElement::Aux(a) => a.label_side, + _ => unreachable!(), + }; + assert_eq!( + side(0), + LabelSide::Bottom, + "an existing label keeps its side" + ); + assert_ne!( + side(3), + LabelSide::Bottom, + "the new label leaves the struck side" + ); + } + + #[test] + fn test_declutter_leaves_nothing_the_metric_charges_as_crowding() { + // Four auxes jammed into a tight cluster. The declutter separates and + // then compacts them; the tightest arrangement it may stop at is the + // metric's comfortable clearance, never closer. + use crate::layout::config::LayoutConfig; + use crate::layout::metrics::compute_layout_metrics; + let mut elements = vec![ + aux_at(1, 100.0, 100.0, "first name"), + aux_at(2, 112.0, 104.0, "second name"), + aux_at(3, 96.0, 118.0, "third name"), + aux_at(4, 118.0, 122.0, "fourth name"), + ]; + declutter_view(&mut elements); + let view = crate::datamodel::StockFlow { + name: None, + elements, + view_box: crate::datamodel::Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + }; + let m = compute_layout_metrics(&view, &LayoutConfig::default()); + assert_eq!(m.node_overlap, 0.0); + assert_eq!(m.label_overlap, 0.0); + assert!(m.crowding < 1e-9, "crowding {}", m.crowding); + } + // ── flow labels as side-choice obstacles ── #[test] @@ -1282,9 +1673,9 @@ mod tests { let mut min_y = f64::INFINITY; let mut max_y = f64::NEG_INFINITY; for e in elements { - for r in [node_shape_box(e), current_label_box(e, &names)] + for r in shape_rects(e) .into_iter() - .flatten() + .chain(current_label_box(e, &names)) { min_x = min_x.min(r.left); max_x = max_x.max(r.right); diff --git a/src/simlin-engine/src/layout/eval_stats.rs b/src/simlin-engine/src/layout/eval_stats.rs index 28234589c..02e5c82a4 100644 --- a/src/simlin-engine/src/layout/eval_stats.rs +++ b/src/simlin-engine/src/layout/eval_stats.rs @@ -18,7 +18,7 @@ // The corpus sweep (Phase 3) is the imperative shell that fills these structs // from real layouts. -use crate::layout::metrics::LayoutMetrics; +use crate::layout::metrics::{LayoutMetrics, MetricWeights}; /// Geometric mean of strictly-positive values: `exp(mean(ln(x)))`. /// @@ -198,6 +198,112 @@ pub fn mann_whitney_u(a: &[f64], b: &[f64]) -> MannWhitney { MannWhitney { u, u1, u2, p_value } } +/// Largest number of nonzero pairs the signed-rank test evaluates exactly; above +/// it the normal approximation is used. The exact null distribution is a +/// dynamic program over the (doubled, integer) rank sums, `O(n^3)` work, which +/// is instant for any realistic corpus and still cheap at this bound. +const WILCOXON_EXACT_MAX_N: usize = 200; + +/// Two-sided p-value of the Wilcoxon signed-rank test over paired differences. +/// +/// This is the aggregate test for a corpus comparison: each matched model +/// contributes one paired difference (candidate vs baseline on the SAME model), +/// so a consistent improvement across models whose absolute costs differ by +/// orders of magnitude is detected. An unpaired rank test over the per-model +/// medians cannot see it -- the between-model spread swamps any within-model +/// change. +/// +/// Zero differences carry no directional signal and are dropped (Wilcoxon's +/// original treatment), so models a change does not touch never dilute the +/// verdict. Tied |differences| take their average rank. For up to +/// [`WILCOXON_EXACT_MAX_N`] nonzero pairs the p-value comes from the exact null +/// distribution (every sign assignment equally likely); beyond that, from the +/// normal approximation with tie correction. +/// +/// Returns `1.0` (non-significant) when no nonzero difference remains; never +/// NaN. +pub fn wilcoxon_signed_rank(diffs: &[f64]) -> f64 { + let mut nonzero: Vec = diffs + .iter() + .copied() + .filter(|d| d.is_finite() && *d != 0.0) + .collect(); + let n = nonzero.len(); + if n == 0 { + return 1.0; + } + nonzero.sort_by(|a, b| { + a.abs() + .partial_cmp(&b.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + // Doubled average ranks are integers: a tie group spanning 1-based ranks + // i+1..=j averages (i+1+j)/2, and doubling it clears the half. + let mut doubled_ranks: Vec = vec![0; n]; + let mut tie_term = 0.0; + let mut i = 0; + while i < n { + let mut j = i + 1; + while j < n && nonzero[j].abs() == nonzero[i].abs() { + j += 1; + } + for rank in &mut doubled_ranks[i..j] { + *rank = i + 1 + j; + } + let t = (j - i) as f64; + tie_term += t * t * t - t; + i = j; + } + let w_plus: usize = nonzero + .iter() + .zip(&doubled_ranks) + .filter(|(d, _)| **d > 0.0) + .map(|(_, r)| *r) + .sum(); + + if n > WILCOXON_EXACT_MAX_N { + return wilcoxon_normal_p(n, w_plus, tie_term); + } + wilcoxon_exact_p(&doubled_ranks, w_plus) +} + +/// Exact two-sided signed-rank p-value: the null distribution of the doubled +/// W+ statistic, where each pair independently contributes its doubled rank +/// with probability 1/2. +fn wilcoxon_exact_p(doubled_ranks: &[usize], w_plus: usize) -> f64 { + let max_sum: usize = doubled_ranks.iter().sum(); + let mut dist = vec![0.0_f64; max_sum + 1]; + dist[0] = 1.0; + let mut reach = 0; + for &r in doubled_ranks { + for s in (0..=reach).rev() { + let mass = dist[s] * 0.5; + dist[s] = mass; + dist[s + r] += mass; + } + reach += r; + } + let lower: f64 = dist[..=w_plus].iter().sum(); + let upper: f64 = dist[w_plus..].iter().sum(); + (2.0 * lower.min(upper)).clamp(0.0, 1.0) +} + +/// Normal-approximation two-sided signed-rank p-value for `n` nonzero pairs, +/// the doubled statistic `w_plus_doubled`, and the tie term `sum(t^3 - t)` over +/// tie groups; continuity-corrected. +fn wilcoxon_normal_p(n: usize, w_plus_doubled: usize, tie_term: f64) -> f64 { + let nf = n as f64; + let mean = nf * (nf + 1.0) / 4.0; + let variance = nf * (nf + 1.0) * (2.0 * nf + 1.0) / 24.0 - tie_term / 48.0; + if variance <= 0.0 { + return 1.0; + } + let w = w_plus_doubled as f64 / 2.0; + let z = ((w - mean).abs() - 0.5).max(0.0) / variance.sqrt(); + (2.0 * (1.0 - phi(z))).clamp(0.0, 1.0) +} + /// Error function via the Abramowitz & Stegun 7.1.26 rational approximation /// (max absolute error ~1.5e-7) -- ample accuracy for a significance verdict. /// @@ -392,6 +498,36 @@ impl CorpusReport { aggregate_cost, } } + + /// This report re-scored under `weights`: every sample's `weighted_cost` is + /// recomputed from its stored per-term metrics and every statistic is + /// re-derived (`production_seeds` as in [`ModelStats::from_samples`]). + /// + /// A report records costs under the weights in force when it was produced; + /// comparing it against a run under different weights would diff two + /// different objectives. Re-scoring both sides under one weight set makes a + /// weight change a pure re-weighting of the same layouts. A term the stored + /// report predates deserializes as `0` (its serde default), so a comparison + /// involving a newly added term is only meaningful once both sides carry it. + pub fn rescored(&self, weights: &MetricWeights, production_seeds: &[u64]) -> CorpusReport { + let per_model = self + .per_model + .iter() + .map(|stats| { + let samples = stats + .samples + .iter() + .map(|s| MetricSample { + seed: s.seed, + metrics: s.metrics, + weighted_cost: s.metrics.weighted_cost(weights), + }) + .collect(); + ModelStats::from_samples(stats.model.clone(), samples, production_seeds) + }) + .collect(); + CorpusReport::from_model_stats(per_model) + } } /// Per-model verdict from comparing a baseline against a candidate report. @@ -432,8 +568,8 @@ pub struct Comparison { /// the matched per-model medians, or `0.0` when the baseline aggregate is /// `0`. pub aggregate_delta_ratio: f64, - /// Two-sided Mann-Whitney U p-value over the matched per-model medians (see - /// [`compare`] for why Mann-Whitney rather than a paired test). + /// Two-sided Wilcoxon signed-rank p-value over the matched models' paired + /// shifted-log ratios (see [`compare`]). pub aggregate_p_value: f64, /// `aggregate_p_value < SIGNIFICANCE_ALPHA` AND `|aggregate_delta_ratio| >= /// MIN_PRACTICAL_DELTA_RATIO`. @@ -480,15 +616,11 @@ fn delta_ratio(baseline: f64, candidate: f64) -> f64 { /// Aggregate: `aggregate_delta_ratio` is the ratio of the candidate-side to /// baseline-side shifted geometric mean ([`geomean1p`]) of the matched /// per-model medians (so a `0` median is a neutral factor on either side, not a -/// floored outlier). `aggregate_p_value` is -/// `mann_whitney_u(baseline_medians, candidate_medians).p_value` over the -/// matched per-model medians. -/// -/// The aggregate significance test treats the two median vectors as -/// independent samples (Mann-Whitney U), per the design. A paired test such as -/// Wilcoxon signed-rank -- which would exploit the model-by-model pairing of -/// the matched medians -- is a documented future refinement, not implemented -/// here. +/// floored outlier). `aggregate_p_value` is the [`wilcoxon_signed_rank`] test +/// over the matched models' paired shifted-log ratios +/// `ln(1 + candidate) - ln(1 + baseline)`: the pairing is what lets a +/// consistent per-model change register when the models' absolute costs span +/// orders of magnitude. /// /// On empty or fully-disjoint reports there are no matched models: /// `per_model` is empty, `aggregate_delta_ratio == 0.0`, and the aggregate is @@ -540,7 +672,14 @@ pub fn compare(baseline: &CorpusReport, candidate: &CorpusReport) -> Comparison let aggregate_delta_ratio = delta_ratio(geomean1p(&baseline_medians), geomean1p(&candidate_medians)); - let aggregate_p_value = mann_whitney_u(&baseline_medians, &candidate_medians).p_value; + // Paired per-model differences on the same shifted-log scale the aggregate + // uses, so the test and the headline number agree about what "better" is. + let log_ratios: Vec = baseline_medians + .iter() + .zip(&candidate_medians) + .map(|(b, c)| c.ln_1p() - b.ln_1p()) + .collect(); + let aggregate_p_value = wilcoxon_signed_rank(&log_ratios); Comparison { per_model, @@ -867,16 +1006,7 @@ mod tests { fn metrics_with_cost(cost: f64) -> LayoutMetrics { LayoutMetrics { node_overlap: cost, - node_connector_overlap: 0.0, - label_overlap: 0.0, - crossings: 0.0, - sprawl: 0.0, - edge_length_cv: 0.0, - aspect_penalty: 0.0, - chain_straightness: 0.0, - loop_compactness: 0.0, - flow_bends: 0.0, - loop_straightness: 0.0, + ..LayoutMetrics::default() } } @@ -1255,6 +1385,148 @@ mod tests { assert!(!cmp.aggregate_significant); } + // --- Wilcoxon signed-rank (the paired aggregate test) --- + + #[test] + fn test_wilcoxon_all_positive_matches_exact_tail() { + // Five all-positive differences: of the 2^5 equally likely sign + // assignments only the all-positive one reaches W+ = 15, so the + // one-sided tail is 1/32 and the two-sided p-value is 2/32. + let p = wilcoxon_signed_rank(&[0.1, 0.2, 0.3, 0.4, 0.5]); + assert!(close(p, 2.0 / 32.0), "{p}"); + // Six: 2/64, below the 5% threshold. + let p = wilcoxon_signed_rank(&[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); + assert!(close(p, 2.0 / 64.0), "{p}"); + assert!(p < SIGNIFICANCE_ALPHA); + } + + #[test] + fn test_wilcoxon_symmetric_differences_are_nonsignificant() { + // Mirror-image differences put W+ at its null mean: p == 1. + let p = wilcoxon_signed_rank(&[0.1, -0.1, 0.2, -0.2, 0.3, -0.3]); + assert!(p > 0.9, "{p}"); + } + + #[test] + fn test_wilcoxon_drops_zero_differences() { + // Unchanged models carry no signal about direction: zeros are dropped, + // so padding with zeros leaves the verdict exactly unchanged. + let base = wilcoxon_signed_rank(&[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); + let padded = wilcoxon_signed_rank(&[0.0, 0.1, 0.0, 0.2, 0.3, 0.4, 0.0, 0.5, 0.6]); + assert!(close(base, padded), "{base} vs {padded}"); + } + + #[test] + fn test_wilcoxon_degenerate_input_is_nonsignificant() { + assert_eq!(wilcoxon_signed_rank(&[]), 1.0); + assert_eq!(wilcoxon_signed_rank(&[0.0, 0.0]), 1.0); + // One nonzero difference can never be significant (p = 2 * 1/2). + assert!(close(wilcoxon_signed_rank(&[3.0]), 1.0)); + } + + #[test] + fn test_wilcoxon_ties_use_average_ranks() { + // |d| all tied: every rank is the average, so the statistic depends only + // on how many are positive. Four positive of four: only one of 16 + // assignments is as extreme -> two-sided 2/16. + let p = wilcoxon_signed_rank(&[0.5, 0.5, 0.5, 0.5]); + assert!(close(p, 2.0 / 16.0), "{p}"); + } + + #[test] + fn test_wilcoxon_normal_approximation_tracks_exact_distribution() { + // The large-n branch must agree with the exact distribution where both + // are computable: 30 untied pairs, doubled ranks 2, 4, ..., 60, at a + // spread of statistics from the center to the tail. + let doubled: Vec = (1..=30).map(|r| 2 * r).collect(); + for w_plus in [465, 600, 700, 800, 880] { + let exact = wilcoxon_exact_p(&doubled, w_plus); + let approx = wilcoxon_normal_p(30, w_plus, 0.0); + assert!( + (exact - approx).abs() < 0.01, + "W+={} exact {exact} vs normal {approx}", + w_plus / 2 + ); + } + } + + #[test] + fn test_compare_aggregate_is_paired_across_heterogeneous_models() { + // Six models whose costs span two orders of magnitude, each improved by + // ~20%. An unpaired test over the medians sees two overlapping clouds + // and cannot separate them; the paired signed-rank test sees six + // consistent improvements and must flag the aggregate. + let bases = [0.3, 1.0, 2.5, 8.0, 12.0, 30.0]; + let baseline = CorpusReport::from_model_stats( + bases + .iter() + .enumerate() + .map(|(i, &c)| model_stats_from_costs(&format!("m{i}"), &[(1, c)])) + .collect(), + ); + let candidate = CorpusReport::from_model_stats( + bases + .iter() + .enumerate() + .map(|(i, &c)| model_stats_from_costs(&format!("m{i}"), &[(1, c * 0.8)])) + .collect(), + ); + let cmp = compare(&baseline, &candidate); + assert!(cmp.aggregate_delta_ratio < 0.0); + assert!( + cmp.aggregate_significant, + "six consistent 20% improvements must be a significant aggregate; p={}", + cmp.aggregate_p_value + ); + } + + #[test] + fn test_rescored_recomputes_costs_under_new_weights() { + // A baseline seeded under one weight set must be comparable after the + // weights change: rescoring recomputes each sample's cost from its + // stored per-term metrics and re-derives every statistic. + let mut m = metrics_with_cost(2.0); // node_overlap = 2 + m.crossings = 1.0; + let samples = vec![ + MetricSample { + seed: 1, + metrics: m, + weighted_cost: 3.0, + }, + MetricSample { + seed: 2, + metrics: metrics_with_cost(4.0), + weighted_cost: 4.0, + }, + ]; + let report = CorpusReport::from_model_stats(vec![ModelStats::from_samples( + "m".into(), + samples, + &[1], + )]); + let weights = MetricWeights { + node_overlap: 1.0, + crossings: 10.0, + ..MetricWeights::zero() + }; + let rescored = report.rescored(&weights, &[1]); + let stats = &rescored.per_model[0]; + assert!(close(stats.samples[0].weighted_cost, 12.0)); + assert!(close(stats.samples[1].weighted_cost, 4.0)); + assert_eq!( + stats.best_seed, 2, + "the cheaper sample under the new weights" + ); + assert!( + close(stats.best_of_k_cost, 12.0), + "only seed 1 is a production seed" + ); + assert!(close( + rescored.aggregate_cost, + geomean1p(&[stats.median_cost]) + )); + } + #[test] fn test_compare_microscopic_delta_is_not_significant() { // Statistical significance is not practical significance: when every @@ -1295,8 +1567,8 @@ mod tests { assert!(!cmp.aggregate_significant); // A REAL improvement on the same samples is still flagged per-model. - // (The AGGREGATE verdict runs Mann-Whitney over per-model medians -- - // one sample per side here -- which can never separate, so only the + // (The AGGREGATE verdict is a signed-rank test over the matched models + // -- one pair here -- which can never reach significance, so only the // per-model verdict is meaningful for a single-model comparison.) let improved = CorpusReport::from_model_stats(vec![model_stats_from_costs( "m", diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs new file mode 100644 index 000000000..8b6c370a6 --- /dev/null +++ b/src/simlin-engine/src/layout/incremental.rs @@ -0,0 +1,2282 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Incremental layout: bring an existing diagram in line with a patched model +//! while leaving every element the patch did not touch exactly where it is -- +//! place and settle the new elements, rebuild the flows whose stock faces +//! changed, and diff the connectors and clouds. + +use super::*; +use crate::diagram::common::{Rect as Bounds, merge_bounds}; + +/// Compute initial positions for newly-added elements based on their +/// dependency connections to existing elements. +/// +/// Three placement strategies: +/// - Connected aux/module: centroid of connected existing elements with +/// ring spreading when multiple new elements share the same connections +/// - Connected chain element: near connected existing elements with offset +/// - Disconnected element: at the diagram periphery beyond existing bounds +pub fn compute_new_element_positions( + state: &LayoutState, + metadata: &ComputedMetadata, + new_elements: &NewElements, +) -> HashMap { + let mut result: HashMap = HashMap::new(); + + let new_set: HashSet<&str> = new_elements + .new_stocks + .iter() + .chain(&new_elements.new_flows) + .chain(&new_elements.new_auxes) + .chain(&new_elements.new_modules) + .map(|s| s.as_str()) + .collect(); + + // Compute bounding box of all existing positioned elements for periphery placement + let (bbox_min, bbox_max) = existing_bounding_box(state); + + // Place new auxes and modules near connected existing elements + place_new_point_elements( + state, + metadata, + &new_elements.new_auxes, + &new_set, + &bbox_min, + &bbox_max, + &mut result, + ); + place_new_point_elements( + state, + metadata, + &new_elements.new_modules, + &new_set, + &bbox_min, + &bbox_max, + &mut result, + ); + + // Place new stocks and flows (chain elements) + place_new_chain_elements( + state, + metadata, + new_elements, + &new_set, + &bbox_max, + &mut result, + ); + + result +} + +/// Bounding box of variable elements (stocks, flows, auxes, modules) only. +/// Excludes aliases, groups, and clouds so that outlier non-variable elements +/// don't push new variable placement far from the actual model graph. +/// Returns ((min_x, min_y), (max_x, max_y)). +/// When no variable elements exist, returns a default origin area. +pub(super) fn existing_bounding_box(state: &LayoutState) -> (Position, Position) { + let variable_uids: HashSet = state + .elements + .iter() + .filter(|e| { + matches!( + e, + ViewElement::Stock(_) + | ViewElement::Flow(_) + | ViewElement::Aux(_) + | ViewElement::Module(_) + ) + }) + .map(|e| e.get_uid()) + .collect(); + + let mut min_x = f64::MAX; + let mut min_y = f64::MAX; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + let mut found = false; + for (&uid, pos) in &state.positions { + if !variable_uids.contains(&uid) { + continue; + } + found = true; + min_x = min_x.min(pos.x); + min_y = min_y.min(pos.y); + max_x = max_x.max(pos.x); + max_y = max_y.max(pos.y); + } + if !found { + return ( + Position::new(DIAGRAM_ORIGIN_MARGIN, DIAGRAM_ORIGIN_MARGIN), + Position::new(DIAGRAM_ORIGIN_MARGIN, DIAGRAM_ORIGIN_MARGIN), + ); + } + (Position::new(min_x, min_y), Position::new(max_x, max_y)) +} + +/// Collect (uid, position) pairs for existing elements connected to a given +/// ident via dep_graph (things `ident` depends on) and reverse_dep_graph +/// (things that depend on `ident`), excluding other new elements. +/// +/// Returning UIDs alongside positions lets callers build grouping keys +/// directly from stable identifiers rather than doing a position-based +/// reverse lookup. +fn connected_existing_positions( + state: &LayoutState, + metadata: &ComputedMetadata, + ident: &str, + new_set: &HashSet<&str>, +) -> Vec<(i32, Position)> { + let mut pairs = Vec::new(); + let mut seen = HashSet::new(); + + // Forward: things this element depends on + if let Some(deps) = metadata.dep_graph.get(ident) { + for dep in deps { + if new_set.contains(dep.as_str()) || !seen.insert(dep.as_str()) { + continue; + } + if let Some(uid) = state.uid_manager.get_uid(dep) + && let Some(&pos) = state.positions.get(&uid) + { + pairs.push((uid, pos)); + } + } + } + + // Reverse: things that depend on this element + if let Some(dependents) = metadata.reverse_dep_graph.get(ident) { + for dep in dependents { + if new_set.contains(dep.as_str()) || !seen.insert(dep.as_str()) { + continue; + } + if let Some(uid) = state.uid_manager.get_uid(dep) + && let Some(&pos) = state.positions.get(&uid) + { + pairs.push((uid, pos)); + } + } + } + + pairs +} + +/// Centroid of a non-empty set of positions. +fn centroid(positions: &[Position]) -> Position { + let n = positions.len() as f64; + let sum_x: f64 = positions.iter().map(|p| p.x).sum(); + let sum_y: f64 = positions.iter().map(|p| p.y).sum(); + Position::new(sum_x / n, sum_y / n) +} + +/// Place new aux or module elements near their connected existing elements, +/// spreading multiple elements that share the same connections into a ring. +fn place_new_point_elements( + state: &LayoutState, + metadata: &ComputedMetadata, + new_idents: &[String], + new_set: &HashSet<&str>, + bbox_min: &Position, + bbox_max: &Position, + result: &mut HashMap, +) { + if new_idents.is_empty() { + return; + } + + // Group new elements by their set of connected existing element UIDs + // so we can spread apart those that share the same connection set. + let mut connection_groups: HashMap, Vec> = HashMap::new(); + let mut ident_centroids: HashMap = HashMap::new(); + let mut disconnected_index: usize = 0; + + for ident in new_idents { + let connected = connected_existing_positions(state, metadata, ident, new_set); + if connected.is_empty() { + // No connections to existing elements: place at periphery, + // staggering vertically so multiple disconnected inserts don't overlap. + let periphery_x = bbox_max.x + 150.0; + let center_y = (bbox_min.y + bbox_max.y) / 2.0; + let offset_y = disconnected_index as f64 * 80.0; + disconnected_index += 1; + result.insert( + ident.clone(), + Position::new(periphery_x, center_y + offset_y), + ); + continue; + } + + let positions: Vec = connected.iter().map(|(_, p)| *p).collect(); + let center = centroid(&positions); + ident_centroids.insert(ident.clone(), center); + + // Build a sorted UID key for grouping elements that share the same + // connection set, so they can be spread into a ring rather than stacked. + let mut uid_key: Vec = connected.iter().map(|(uid, _)| *uid).collect(); + uid_key.sort(); + uid_key.dedup(); + + connection_groups + .entry(uid_key) + .or_default() + .push(ident.clone()); + } + + // Place each group, spreading elements in a ring when multiple share + // the same connection set (AC4.4). + for group in connection_groups.values() { + let group_count = group.len(); + for (i, ident) in group.iter().enumerate() { + let base = ident_centroids + .get(ident) + .copied() + .unwrap_or(Position::new(bbox_max.x + 150.0, bbox_min.y)); + + if group_count == 1 { + // Offset slightly from the centroid so SFDP has non-zero + // initial displacement. Without this, a new element seeded + // exactly on its only neighbor gets zero force and stays stacked. + result.insert(ident.clone(), Position::new(base.x + 50.0, base.y + 30.0)); + } else { + let angle = i as f64 * 2.0 * PI / group_count.max(8) as f64; + let radius = 50.0; + result.insert( + ident.clone(), + Position::new(base.x + radius * angle.cos(), base.y + radius * angle.sin()), + ); + } + } + } +} + +/// Place new stock and flow elements. When connected to existing +/// structure, place near the connected elements; when disconnected, +/// place at the diagram periphery. +fn place_new_chain_elements( + state: &LayoutState, + metadata: &ComputedMetadata, + new_elements: &NewElements, + new_set: &HashSet<&str>, + bbox_max: &Position, + result: &mut HashMap, +) { + let offset_x = 100.0; + let offset_y = 50.0; + + for stock_ident in &new_elements.new_stocks { + let connected = connected_existing_positions(state, metadata, stock_ident, new_set); + if connected.is_empty() { + // Periphery placement + let pos = Position::new(bbox_max.x + 150.0, bbox_max.y + offset_y); + result.insert(stock_ident.clone(), pos); + } else { + let positions: Vec = connected.iter().map(|(_, p)| *p).collect(); + let center = centroid(&positions); + result.insert( + stock_ident.clone(), + Position::new(center.x + offset_x, center.y + offset_y), + ); + } + } + + for flow_ident in &new_elements.new_flows { + // A flow between two EXISTING stocks belongs at their midpoint (the valve + // sits on the pipe between them), not offset to the side -- and it is + // pinned there during settle (see `settle_new_elements`), because as a + // free SFDP node with rest length k it would be pushed far from the + // midpoint whenever the two stocks are closer together than k. + if let Some(mid) = stock_to_stock_flow_midpoint(state, metadata, flow_ident, new_set) { + result.insert(flow_ident.clone(), mid); + continue; + } + let connected = connected_existing_positions(state, metadata, flow_ident, new_set); + if connected.is_empty() { + let pos = Position::new(bbox_max.x + 200.0, bbox_max.y + offset_y); + result.insert(flow_ident.clone(), pos); + } else { + let positions: Vec = connected.iter().map(|(_, p)| *p).collect(); + let center = centroid(&positions); + result.insert( + flow_ident.clone(), + Position::new(center.x + offset_x, center.y), + ); + } + } +} + +/// The midpoint of a flow's two stocks when BOTH already exist (are not new), or +/// `None` otherwise (a cloud flow, or a flow into/out of a new stock, which the +/// chain/rigid-group machinery positions instead). This is the canonical valve +/// position for an incrementally-added stock-to-stock flow. +fn stock_to_stock_flow_midpoint( + state: &LayoutState, + metadata: &ComputedMetadata, + flow_ident: &str, + new_set: &HashSet<&str>, +) -> Option { + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + let from_stock = from_stock?; + let to_stock = to_stock?; + if new_set.contains(from_stock) || new_set.contains(to_stock) { + return None; + } + let a = state + .uid_manager + .get_uid(from_stock) + .and_then(|uid| state.positions.get(&uid).copied())?; + let b = state + .uid_manager + .get_uid(to_stock) + .and_then(|uid| state.positions.get(&uid).copied())?; + Some(chain::stock_pair_valve_position(a, b, 0, 1)) +} + +/// Run SFDP + annealing with existing elements pinned and only new +/// elements free to move. This settles new elements into positions +/// that respect the force-directed layout while preserving all +/// existing element positions exactly. +pub fn settle_new_elements( + state: &mut LayoutState, + config: &LayoutConfig, + model: &datamodel::Model, + metadata: &ComputedMetadata, + new_elements: &NewElements, + chains_data: &[(Vec, Vec, Vec)], +) -> Result<(), String> { + if new_elements.is_empty() { + return Ok(()); + } + + let new_ident_set: HashSet<&str> = new_elements + .new_stocks + .iter() + .chain(&new_elements.new_flows) + .chain(&new_elements.new_auxes) + .chain(&new_elements.new_modules) + .map(|s| s.as_str()) + .collect(); + + // Isolated variables are excluded from the force graph (see + // `build_full_graph`), which on this incremental path means they simply + // stay where `compute_new_element_positions` placed them -- no parking + // pass, since incremental layout's contract is minimal disturbance. + let FullGraph { + graph: full_graph, + var_to_node, + isolated_vars: _, + } = build_full_graph(state, model, metadata)?; + + // Build constrained graph: pin existing elements, make new chains rigid groups + let mut constrained_builder = ConstrainedGraphBuilder::new(full_graph); + + // Pin all existing (non-new) nodes, plus any NEW flow that connects two + // existing stocks: its valve is fixed at the stock midpoint + // (`stock_to_stock_flow_midpoint`), so letting it float as an SFDP node + // (rest length k) would push it far off whenever the stocks are closer than + // k. The rest of a genuinely new chain still settles normally. + let mut pinned_node_ids: Vec = var_to_node + .iter() + .filter(|(ident, _)| !new_ident_set.contains(ident.as_str())) + .map(|(_, node_id)| node_id.clone()) + .collect(); + for flow_ident in &new_elements.new_flows { + if stock_to_stock_flow_midpoint(state, metadata, flow_ident, &new_ident_set).is_some() + && let Some(node_id) = var_to_node.get(flow_ident) + { + pinned_node_ids.push(node_id.clone()); + } + } + constrained_builder.pin(&pinned_node_ids); + + // Add rigid groups for new chain elements (same pattern as run_sfdp_with_rigid_chains) + for (_stocks, _flows, all_vars) in chains_data { + let mut group_members: Vec = Vec::new(); + let mut added: HashSet = HashSet::new(); + + for var_ident in all_vars { + if !new_ident_set.contains(var_ident.as_str()) { + continue; + } + if let Some(node_id) = var_to_node.get(var_ident) + && added.insert(node_id.clone()) + { + group_members.push(node_id.clone()); + + let canonical = canonicalize(var_ident); + if let Some(cloud_idents) = state.flow_ident_to_clouds.get(canonical.as_ref()) { + for cloud_ident in cloud_idents { + if let Some(cloud_node) = var_to_node.get(cloud_ident) + && added.insert(cloud_node.clone()) + { + group_members.push(cloud_node.clone()); + } + } + } + } + } + + if group_members.len() > 1 { + constrained_builder.add_rigid_group(group_members); + } + } + + let constrained_graph = constrained_builder.build(); + + // Seed initial positions: existing elements from state.positions, + // new elements from state.positions (which were set by compute_new_element_positions) + let mut initial_layout: Layout = BTreeMap::new(); + for (var_ident, node_id) in &var_to_node { + if let Some(uid) = state.uid_manager.get_uid(var_ident) + && let Some(&pos) = state.positions.get(&uid) + { + initial_layout.insert(node_id.clone(), pos); + continue; + } + if let Some(&cloud_uid) = state.cloud_ident_to_uid.get(var_ident) + && let Some(&pos) = state.positions.get(&cloud_uid) + { + initial_layout.insert(node_id.clone(), pos); + } + } + + let sfdp_config = SfdpConfig::for_aux_placement(); + + let node_to_ident: HashMap = var_to_node + .iter() + .map(|(ident, node_id)| (node_id.clone(), ident.clone())) + .collect(); + let stock_inflows: HashMap> = metadata + .stock_to_inflows + .iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) + .collect(); + let stock_outflows: HashMap> = metadata + .stock_to_outflows + .iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) + .collect(); + + let new_node_ids: HashSet = var_to_node + .iter() + .filter(|(ident, _)| new_ident_set.contains(ident.as_str())) + .map(|(_, node_id)| node_id.clone()) + .collect(); + + let build_segments = |candidate_layout: &Layout| -> Vec { + let mut segments = Vec::new(); + + for edge in constrained_graph.edges() { + let (Some(&from_pos), Some(&to_pos)) = ( + candidate_layout.get(&edge.from), + candidate_layout.get(&edge.to), + ) else { + continue; + }; + + if let (Some(from_ident), Some(to_ident)) = + (node_to_ident.get(&edge.from), node_to_ident.get(&edge.to)) + && is_structural_stock_flow(from_ident, to_ident, &stock_inflows, &stock_outflows) + { + continue; + } + + segments.push(LineSegment { + start: from_pos, + end: to_pos, + from_node: edge.from.clone(), + to_node: edge.to.clone(), + }); + } + + for (flow_ident, tmpl) in &state.flow_templates { + if tmpl.offsets.len() < 2 { + continue; + } + let Some(node_id) = var_to_node.get(flow_ident) else { + continue; + }; + let Some(¢er) = candidate_layout.get(node_id) else { + continue; + }; + + let points: Vec = tmpl + .offsets + .iter() + .map(|offset| Position::new(center.x + offset.x, center.y + offset.y)) + .collect(); + + for i in 0..points.len() - 1 { + segments.push(LineSegment { + start: points[i], + end: points[i + 1], + from_node: format!("{}#{}", flow_ident, i), + to_node: format!("{}#{}", flow_ident, i + 1), + }); + } + } + + segments + }; + + let mut adjacency: annealing::AdjacencyMap = HashMap::new(); + for edge in constrained_graph.edges() { + adjacency + .entry(edge.from.clone()) + .or_default() + .push((edge.to.clone(), edge.weight)); + adjacency + .entry(edge.to.clone()) + .or_default() + .push((edge.from.clone(), edge.weight)); + } + + let max_delta_aux = config.annealing_max_delta_aux; + let annealing_config = config.clone(); + let annealing_seed = config.annealing_random_seed; + + let mut annealing_round: usize = 0; + let mut last_annealing_iter: usize = 0; + let mut best_cost: f64 = f64::INFINITY; + let mut best_layout: Option> = None; + + let final_layout = compute_layout_from_initial_with_callback( + &constrained_graph, + &sfdp_config, + &initial_layout, + annealing_seed, + &mut |iter, layout| { + if !should_trigger_annealing( + iter, + annealing_config.annealing_interval, + last_annealing_iter, + annealing_round, + annealing_config.annealing_max_rounds, + ) { + return None; + } + + let result = run_annealing_with_filter( + layout, + build_segments, + // Incremental settling perturbs only the new elements around + // pinned existing ones; a new element must still not land on + // top of another node. + |layout: &Layout| point_node_pileup_count(layout, &new_node_ids) as f64, + &annealing_config, + annealing_seed.wrapping_add(annealing_round as u64), + |node_id: &String| new_node_ids.contains(node_id), + |node_id: &String| { + if new_node_ids.contains(node_id) { + max_delta_aux + } else { + 0.0 + } + }, + &adjacency, + ); + + last_annealing_iter = iter; + annealing_round += 1; + + if result.cost < best_cost { + best_cost = result.cost; + best_layout = Some(result.layout.clone()); + Some(result.layout) + } else { + None + } + }, + ); + + let settled_layout = if let Some(saved) = best_layout { + let final_crossings = annealing::count_crossings(&build_segments(&final_layout)); + if final_crossings as f64 > best_cost { + saved + } else { + final_layout + } + } else { + final_layout + }; + + // Only update positions for new elements; existing elements stay unchanged + for (var_ident, node_id) in &var_to_node { + if !new_ident_set.contains(var_ident.as_str()) { + continue; + } + if let Some(&pos) = settled_layout.get(node_id) + && let Some(uid) = state.uid_manager.get_uid(var_ident) + { + state.positions.insert(uid, pos); + } + } + + // Also update positions for clouds of new flows. SFDP moves cloud nodes in a rigid + // group together with their parent flow, but the loop above skips cloud idents since + // they are not model variables and therefore not in new_ident_set. Without recording + // the settled cloud positions here, the coordinate update loop in incremental_layout + // cannot apply the flow's displacement to the cloud element, leaving the cloud stranded + // at its creation position while the flow endpoint shifts. + for var_ident in var_to_node.keys() { + if !new_ident_set.contains(var_ident.as_str()) { + continue; + } + let canonical = canonicalize(var_ident); + if let Some(cloud_idents) = state.flow_ident_to_clouds.get(canonical.as_ref()) { + for cloud_ident in cloud_idents { + if let Some(&cloud_uid) = state.cloud_ident_to_uid.get(cloud_ident) + && let Some(cloud_node) = var_to_node.get(cloud_ident) + && let Some(&pos) = settled_layout.get(cloud_node) + { + state.positions.insert(cloud_uid, pos); + } + } + } + } + + Ok(()) +} + +/// Re-snap stock-attached flow endpoints to stock edges after SFDP settlement. +/// +/// SFDP may move flow valves while stocks stay pinned, causing the +/// proportional point translation to detach endpoints from their stocks. +/// This function restores each attached endpoint to the correct stock +/// edge, using the flow valve position to determine which face of the +/// stock rectangle the flow approaches from. +pub fn resnap_flow_endpoints(state: &mut LayoutState, config: &LayoutConfig) { + let stock_positions: HashMap = state + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, Position::new(s.x, s.y))), + _ => None, + }) + .collect(); + + let half_w = config.stock_width / 2.0; + let half_h = config.stock_height / 2.0; + + for elem in &mut state.elements { + if let ViewElement::Flow(f) = elem { + let valve = Position::new(f.x, f.y); + for pt in &mut f.points { + if let Some(attached_uid) = pt.attached_to_uid + && let Some(stock_pos) = stock_positions.get(&attached_uid) + { + let dx = valve.x - stock_pos.x; + let dy = valve.y - stock_pos.y; + + // Determine which face the flow approaches from using + // aspect-ratio-normalized comparison of dx vs dy. + if half_h * dx.abs() >= half_w * dy.abs() { + // Horizontal approach: snap to left or right edge. + // Preserve the y position (may be off-center for + // multi-flow sides), clamped to stock bounds. + pt.x = stock_pos.x + dx.signum() * half_w; + pt.y = pt.y.clamp(stock_pos.y - half_h, stock_pos.y + half_h); + } else { + // Vertical approach: snap to top or bottom edge. + // Preserve the x position (may be off-center for + // multi-flow sides), clamped to stock bounds. + pt.x = pt.x.clamp(stock_pos.x - half_w, stock_pos.x + half_w); + pt.y = stock_pos.y + dy.signum() * half_h; + } + } + } + } + } +} + +/// Perform three-way connector diff: compare old links in LayoutState +/// against edges derived from the current dep_graph, then preserve +/// unchanged links, remove stale ones, and create new links with +/// default shapes. +pub fn diff_connectors(state: &mut LayoutState, metadata: &ComputedMetadata) { + // Build HashMap<(from_uid, to_uid), ViewElement> for existing links + let mut old_links: HashMap<(i32, i32), ViewElement> = HashMap::new(); + for elem in &state.elements { + if let ViewElement::Link(l) = elem { + old_links.insert((l.from_uid, l.to_uid), elem.clone()); + } + } + + // Compute new dependency edges from dep_graph, skipping structural flow-stock edges + let stock_inflows: HashMap> = metadata + .stock_to_inflows + .iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) + .collect(); + let stock_outflows: HashMap> = metadata + .stock_to_outflows + .iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) + .collect(); + + let mut new_edges: HashSet<(i32, i32)> = HashSet::new(); + let mut new_edge_idents: HashMap<(i32, i32), (String, String)> = HashMap::new(); + + for (var, deps) in &metadata.dep_graph { + for dep in deps { + let from_ident = dep.as_str(); + let to_ident = var.as_str(); + + if is_structural_flow_stock(from_ident, to_ident, &stock_inflows, &stock_outflows) { + continue; + } + + let from_uid = match state.uid_manager.get_uid(from_ident) { + Some(uid) => uid, + None => continue, + }; + let to_uid = match state.uid_manager.get_uid(to_ident) { + Some(uid) => uid, + None => continue, + }; + + if from_uid != 0 && to_uid != 0 { + new_edges.insert((from_uid, to_uid)); + new_edge_idents.insert( + (from_uid, to_uid), + (from_ident.to_string(), to_ident.to_string()), + ); + } + } + } + + // Build alias UID -> primary variable UID mapping so that old links + // targeting aliases are recognized as semantically equivalent to the + // primary variable link. Without this, imported views with causal links + // terminating on aliases would lose those links after an incremental edit. + let alias_to_primary: HashMap = state + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Alias(a) => Some((a.uid, a.alias_of_uid)), + _ => None, + }) + .collect(); + + // Remove all old links from elements + state + .elements + .retain(|elem| !matches!(elem, ViewElement::Link(_))); + + // Track which old links have been consumed so each is used at most once. + let mut consumed_old_links: HashSet<(i32, i32)> = HashSet::new(); + + // Iterate edges in a deterministic order. `new_edges` is a HashSet, so its + // iteration order is per-process random; since each newly-created link both + // allocates a sequential `uid` and is appended to `state.elements` in this + // loop, hash order would otherwise assign different uids / element ordering + // to the same logical link run-to-run (the incremental analogue of #633). + let mut sorted_new_edges: Vec<(i32, i32)> = new_edges.iter().copied().collect(); + sorted_new_edges.sort_unstable(); + + // Add back preserved links (unchanged) and create new links + for (from_uid, to_uid) in sorted_new_edges { + if let Some(old_link) = old_links.get(&(from_uid, to_uid)) { + // Preserved: keep the old link exactly as-is + state.elements.push(old_link.clone()); + consumed_old_links.insert((from_uid, to_uid)); + } else if let Some(key) = old_links + .keys() + .copied() + .filter(|&(of, ot)| { + if consumed_old_links.contains(&(of, ot)) { + return false; + } + let rf = alias_to_primary.get(&of).copied().unwrap_or(of); + let rt = alias_to_primary.get(&ot).copied().unwrap_or(ot); + rf == from_uid && rt == to_uid + }) + // Pick the lowest matching key so the alias-match selection is + // deterministic; HashMap iteration order would otherwise vary. + .min() + { + // Preserved via alias: the old link targets an alias whose primary + // variable matches this dependency edge. Keep the alias link as-is. + state.elements.push(old_links[&key].clone()); + consumed_old_links.insert(key); + } else if let Some((from_ident, to_ident)) = new_edge_idents.get(&(from_uid, to_uid)) { + // Added: create new link with default shape + let link_uid = state.uid_manager.alloc(""); + let shape = if is_structural_stock_flow( + from_ident, + to_ident, + &stock_inflows, + &stock_outflows, + ) { + let arc_angle = if let (Some(&s_pos), Some(&f_pos)) = + (state.positions.get(&from_uid), state.positions.get(&to_uid)) + { + calc_stock_flow_arc_angle(s_pos, f_pos) + } else { + -45.0 + }; + LinkShape::Arc(arc_angle) + } else if metadata + .dep_graph + .get(from_ident) + .is_some_and(|deps| deps.contains(to_ident)) + { + let arc_angle = if let (Some(&from_pos), Some(&to_pos)) = + (state.positions.get(&from_uid), state.positions.get(&to_uid)) + { + calc_reciprocal_arc_angle(from_pos, to_pos) + } else { + -45.0 + }; + LinkShape::Arc(arc_angle) + } else { + LinkShape::Straight + }; + + state.elements.push(ViewElement::Link(view_element::Link { + uid: link_uid, + from_uid, + to_uid, + shape, + polarity: None, + })); + } + } + + // Preserve remaining alias-backed links whose alias-resolved endpoints + // match a valid dependency. Imported views may have multiple rendered + // connectors for the same dependency (e.g., links to two different + // aliases of the same variable). + // Iterate in a deterministic order for the same reason as the new-edge loop: + // the preserved links are appended to `state.elements`, so HashMap iteration + // order would otherwise perturb element ordering run-to-run. + let mut sorted_old_links: Vec<&(i32, i32)> = old_links.keys().collect(); + sorted_old_links.sort_unstable(); + for &(of, ot) in sorted_old_links { + if consumed_old_links.contains(&(of, ot)) { + continue; + } + let rf = alias_to_primary.get(&of).copied().unwrap_or(of); + let rt = alias_to_primary.get(&ot).copied().unwrap_or(ot); + if new_edges.contains(&(rf, rt)) { + state.elements.push(old_links[&(of, ot)].clone()); + } + } +} + +/// Diff clouds for all flows: preserve existing clouds that are still +/// needed, remove clouds whose flow endpoint is now connected to a +/// stock, and create new clouds for newly-unconnected flow endpoints. +pub fn diff_clouds(state: &mut LayoutState, metadata: &ComputedMetadata) { + // Index existing clouds by (flow_uid, is_source). + // A source cloud is at the first flow point, a sink at the last. + // We distinguish them by checking their position against the flow + // element's points when possible, but we can also use a simpler + // heuristic: group all clouds by flow_uid. + let mut old_clouds_by_flow: HashMap> = HashMap::new(); + for elem in &state.elements { + if let ViewElement::Cloud(c) = elem { + old_clouds_by_flow + .entry(c.flow_uid) + .or_default() + .push(elem.clone()); + } + } + + // Determine which clouds should exist for each flow + let mut needed_flow_uids: HashSet = HashSet::new(); + // Track which flows need source/sink clouds + let mut need_source: HashSet = HashSet::new(); + let mut need_sink: HashSet = HashSet::new(); + + for (flow_ident, (from_stock, to_stock)) in &metadata.flow_to_stocks { + let flow_uid = match state.uid_manager.get_uid(flow_ident) { + Some(uid) => uid, + None => continue, + }; + needed_flow_uids.insert(flow_uid); + if from_stock.is_none() { + need_source.insert(flow_uid); + } + if to_stock.is_none() { + need_sink.insert(flow_uid); + } + } + + // Snapshot flow endpoint positions before mutating state.elements + let flow_endpoints: HashMap = state + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Flow(f) if !f.points.is_empty() => { + let first = Position::new(f.points[0].x, f.points[0].y); + let last_idx = f.points.len() - 1; + let last = Position::new(f.points[last_idx].x, f.points[last_idx].y); + Some((f.uid, (first, last))) + } + _ => None, + }) + .collect(); + + // Remove all old clouds from elements + state + .elements + .retain(|elem| !matches!(elem, ViewElement::Cloud(_))); + + // For each flow, determine what to keep vs create + let all_flow_uids: HashSet = needed_flow_uids + .iter() + .chain(old_clouds_by_flow.keys()) + .copied() + .collect(); + + for flow_uid in all_flow_uids { + let old_clouds = old_clouds_by_flow + .get(&flow_uid) + .cloned() + .unwrap_or_default(); + let wants_source = need_source.contains(&flow_uid); + let wants_sink = need_sink.contains(&flow_uid); + + let needed_count = wants_source as usize + wants_sink as usize; + + if needed_count == 0 { + for c in &old_clouds { + if let ViewElement::Cloud(cloud) = c { + state.positions.remove(&cloud.uid); + } + } + continue; + } + + // Preserve existing clouds by matching to needed roles (source/sink) + // based on proximity to flow endpoints, rather than iteration order. + let endpoints = flow_endpoints.get(&flow_uid); + let mut preserved_source = false; + let mut preserved_sink = false; + let mut used_uids: HashSet = HashSet::new(); + + let find_nearest = + |clouds: &[ViewElement], target: &Position, exclude: &HashSet| -> Option { + clouds + .iter() + .filter_map(|c| match c { + ViewElement::Cloud(cloud) if !exclude.contains(&cloud.uid) => { + let d = (cloud.x - target.x).powi(2) + (cloud.y - target.y).powi(2); + Some((cloud.uid, d)) + } + _ => None, + }) + .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(uid, _)| uid) + }; + + if let Some((src_pos, snk_pos)) = endpoints { + if wants_source && let Some(uid) = find_nearest(&old_clouds, src_pos, &used_uids) { + used_uids.insert(uid); + preserved_source = true; + } + if wants_sink && let Some(uid) = find_nearest(&old_clouds, snk_pos, &used_uids) { + used_uids.insert(uid); + preserved_sink = true; + } + } else { + // No endpoint info: preserve in order as a fallback + for cloud in &old_clouds { + if let ViewElement::Cloud(c) = cloud { + if wants_source && !preserved_source { + used_uids.insert(c.uid); + preserved_source = true; + } else if wants_sink && !preserved_sink { + used_uids.insert(c.uid); + preserved_sink = true; + } + } + } + } + + // Push preserved clouds and remove positions of discarded ones + for cloud in &old_clouds { + if let ViewElement::Cloud(c) = cloud { + if used_uids.contains(&c.uid) { + state.elements.push(cloud.clone()); + } else { + state.positions.remove(&c.uid); + } + } + } + + // Create new clouds for roles that couldn't be filled from old clouds + if wants_source && !preserved_source { + let pos = endpoints.map(|(src, _)| *src); + let (cx, cy) = pos.map_or((0.0, 0.0), |p| (p.x, p.y)); + let cloud_uid = state.uid_manager.alloc(""); + state.elements.push(ViewElement::Cloud(view_element::Cloud { + uid: cloud_uid, + flow_uid, + x: cx, + y: cy, + compat: None, + })); + state.positions.insert(cloud_uid, Position::new(cx, cy)); + } + if wants_sink && !preserved_sink { + let pos = endpoints.map(|(_, sink)| *sink); + let (cx, cy) = pos.map_or((0.0, 0.0), |p| (p.x, p.y)); + let cloud_uid = state.uid_manager.alloc(""); + state.elements.push(ViewElement::Cloud(view_element::Cloud { + uid: cloud_uid, + flow_uid, + x: cx, + y: cy, + compat: None, + })); + state.positions.insert(cloud_uid, Position::new(cx, cy)); + } + } + + // Repair pass: for XMILE-imported views a cloud element may exist but the + // corresponding flow point's attached_to_uid may be None. Wire up any + // unattached flow endpoints to their matching cloud. + // + // Build a map from flow_uid to the clouds that now exist for it. + let mut clouds_by_flow: HashMap> = HashMap::new(); + for elem in &state.elements { + if let ViewElement::Cloud(c) = elem { + clouds_by_flow + .entry(c.flow_uid) + .or_default() + .push((c.uid, c.x, c.y)); + } + } + + for elem in &mut state.elements { + let flow = match elem { + ViewElement::Flow(f) => f, + _ => continue, + }; + let Some(clouds) = clouds_by_flow.get(&flow.uid) else { + continue; + }; + if flow.points.len() < 2 { + continue; + } + + // For each flow endpoint (source=0, sink=last) that is unattached, + // assign the nearest cloud. We use a simple squared-distance heuristic + // which is correct for both single-cloud and two-cloud cases. + let last = flow.points.len() - 1; + for pt_idx in [0, last] { + if flow.points[pt_idx].attached_to_uid.is_some() { + continue; + } + let px = flow.points[pt_idx].x; + let py = flow.points[pt_idx].y; + let nearest = clouds.iter().min_by(|(_, ax, ay), (_, bx, by)| { + let da = (ax - px).powi(2) + (ay - py).powi(2); + let db = (bx - px).powi(2) + (by - py).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }); + if let Some(&(cloud_uid, _, _)) = nearest { + flow.points[pt_idx].attached_to_uid = Some(cloud_uid); + } + } + } +} + +/// The face of `stock` each drawn side flow (one attached to the stock at a +/// single end) currently sits on: the face its stock-attached endpoint lies +/// on, by the aspect-normalized rule `resnap_flow_endpoints` uses. +fn existing_side_flow_faces( + state: &LayoutState, + config: &LayoutConfig, + metadata: &ComputedMetadata, + stock_ident: &str, +) -> HashMap { + let mut faces = HashMap::new(); + let Some(stock_uid) = state.uid_manager.get_uid(stock_ident) else { + return faces; + }; + let Some(&stock_pos) = state.positions.get(&stock_uid) else { + return faces; + }; + let side_flows = metadata + .stock_to_outflows + .get(stock_ident) + .into_iter() + .chain(metadata.stock_to_inflows.get(stock_ident)) + .flatten() + .filter(|flow| { + let (from, to) = metadata.connected_stocks(flow); + from.is_none() || to.is_none() + }); + for flow_ident in side_flows { + let Some(uid) = state.uid_manager.get_uid(flow_ident) else { + continue; + }; + let attached = state.elements.iter().find_map(|e| match e { + ViewElement::Flow(f) if f.uid == uid => f + .points + .iter() + .find(|pt| pt.attached_to_uid == Some(stock_uid)) + .map(|pt| (pt.x, pt.y)), + _ => None, + }); + let Some((x, y)) = attached else { continue }; + let (dx, dy) = (x - stock_pos.x, y - stock_pos.y); + let half_w = config.stock_width / 2.0; + let half_h = config.stock_height / 2.0; + let side = if half_h * dx.abs() >= half_w * dy.abs() { + if dx >= 0.0 { + StockAttachSide::Right + } else { + StockAttachSide::Left + } + } else if dy >= 0.0 { + StockAttachSide::Bottom + } else { + StockAttachSide::Top + }; + faces.insert(flow_ident.clone(), side); + } + faces +} + +/// Re-sort flows on each affected stock's sides by their existing +/// attachment position rather than alphabetical ident. This preserves +/// the visual left-to-right (or top-to-bottom) ordering of imported or +/// manually-edited flows when a sibling is added or removed. +/// +/// Only affects flows that already have view elements in `state`; +/// new flows without positions are placed last (sorted by ident among +/// themselves). +fn reorder_attachments_by_position( + attachments: &mut HashMap, + state: &LayoutState, + affected_stocks: &HashSet, + metadata: &ComputedMetadata, +) { + for stock_ident in affected_stocks { + let stock_uid = match state.uid_manager.get_uid(stock_ident) { + Some(uid) => uid, + None => continue, + }; + + // Group flows on this stock by side, recording each flow's + // existing attachment position (x for Top/Bottom, y for Left/Right). + let mut by_side: HashMap> = HashMap::new(); + + for (flow_ident, att) in attachments.iter() { + let (from, to) = metadata.connected_stocks(flow_ident); + // Skip stock-to-stock flows: their attachment side depends on + // which stock classified them last, so including them would + // count them on the wrong side of one stock. + if from.is_some() && to.is_some() { + continue; + } + let connected = + from.is_some_and(|s| s == stock_ident) || to.is_some_and(|s| s == stock_ident); + if !connected { + continue; + } + + let pos_key = state + .uid_manager + .get_uid(flow_ident) + .and_then(|uid| { + state.elements.iter().find_map(|e| match e { + ViewElement::Flow(f) if f.uid == uid => f + .points + .iter() + .find(|pt| pt.attached_to_uid == Some(stock_uid)) + .map(|pt| match att.side { + StockAttachSide::Bottom | StockAttachSide::Top => pt.x, + StockAttachSide::Left | StockAttachSide::Right => pt.y, + }), + _ => None, + }) + }) + .unwrap_or(f64::MAX); // new flows sort last + + by_side + .entry(att.side) + .or_default() + .push((flow_ident.clone(), pos_key)); + } + + // Re-sort each side group by position and reassign offsets + for flows in by_side.values_mut() { + if flows.len() <= 1 { + continue; + } + flows.sort_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + let n = flows.len(); + for (i, (flow_ident, _)) in flows.iter().enumerate() { + let offset = if n == 1 { + 0.5 + } else { + (i as f64 + 1.0) / (n as f64 + 1.0) + }; + if let Some(att) = attachments.get_mut(flow_ident) { + att.offset = offset; + } + } + } + } +} + +/// Compute the valve position for a flow based on its attachment info and +/// connected stock position. Returns `None` if the flow has no attachment +/// or the stock position is unknown, in which case the caller should fall +/// back to `initial_positions`. +fn attachment_based_flow_position( + state: &LayoutState, + config: &LayoutConfig, + metadata: &ComputedMetadata, + flow_ident: &str, + flow_attachments: &HashMap, +) -> Option { + let attachment = flow_attachments.get(flow_ident)?; + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + let stock_name = from_stock.or(to_stock)?; + let stock_uid = state.uid_manager.get_uid(stock_name)?; + let stock_pos = state.positions.get(&stock_uid)?; + Some(side_flow_valve_position(*stock_pos, *attachment, config)) +} + +/// Write `sides` back onto the named elements that carry those UIDs. Used by +/// incremental layout after a flow is rebuilt with unchanged orientation +/// (`create_flow_view_element` picks a default side) to reinstate the side +/// the element had before the rebuild. +fn restore_label_sides(state: &mut LayoutState, sides: &HashMap) { + for elem in &mut state.elements { + let Some(&side) = sides.get(&elem.get_uid()) else { + continue; + }; + match elem { + ViewElement::Stock(s) => s.label_side = side, + ViewElement::Flow(f) => f.label_side = side, + ViewElement::Aux(a) => a.label_side = side, + ViewElement::Module(m) => m.label_side = side, + _ => {} + } + } +} + +/// Assemble a [`datamodel::StockFlow`] from finalized layout state, copying +/// metadata (name, view box, zoom, font, sketch_compat) from `template`. +/// +/// The view box is copied verbatim, never recomputed from the elements: the +/// editor stores its viewport there (the pan offset and the canvas size it +/// was last shown at, `Canvas.tsx` `getCanvasOffset`), and an incremental +/// pass runs after every kernel/MCP edit of a diagram someone is looking +/// at -- re-boxing it to the content bounds snaps their pan back and, when +/// the size no longer matches the canvas, triggers the editor's +/// proportional refit, so the diagram jumps on every "Updated from Python". +/// Content that ends up outside the viewport is the editor's business (it +/// re-centres an offscreen diagram on mount). Only a from-scratch layout +/// synthesises a box. +pub(super) fn build_stock_flow_from_state( + state: LayoutState, + template: &datamodel::StockFlow, +) -> datamodel::StockFlow { + datamodel::StockFlow { + name: template.name.clone(), + elements: state.elements, + view_box: template.view_box.clone(), + zoom: if template.zoom > 0.0 { + template.zoom + } else { + 1.0 + }, + use_lettered_polarity: template.use_lettered_polarity, + font: template.font.clone(), + sketch_compat: template.sketch_compat.clone(), + } +} + +/// Room between a chain set down beside the diagram and the diagram itself: +/// enough for a row of parameters between them. +const NEW_CHAIN_GAP: f64 = 75.0; + +/// Lay out each chain the patch added whole -- every one of its stocks new -- +/// the way a fresh layout lays out a chain, and set it down in free space +/// beside the existing diagram. Returns the idents of the stocks and flows it +/// placed: the generic placement must not create them again, and settling must +/// hold them still, since a chain whose parameters have not arrived yet only +/// repels what is already drawn and the force pass would push it anywhere. +/// +/// A chain goes below the diagram or to its right. When it reads from or feeds +/// variables already drawn, it takes whichever side is nearer them; otherwise +/// whichever keeps the whole diagram nearer square, preferring below -- a new +/// row, the way modelers stack sectors. +fn place_new_chains( + state: &mut LayoutState, + config: &LayoutConfig, + metadata: &ComputedMetadata, + new_elements: &NewElements, +) -> Result, String> { + let new_stocks: HashSet<&str> = new_elements.new_stocks.iter().map(String::as_str).collect(); + let new_vars: HashSet<&str> = new_elements + .new_stocks + .iter() + .chain(&new_elements.new_flows) + .chain(&new_elements.new_auxes) + .chain(&new_elements.new_modules) + .map(String::as_str) + .collect(); + let mut placed: HashSet = HashSet::new(); + for chain in &metadata.chains { + let whole = !chain.stocks.is_empty() + && chain.stocks.iter().all(|s| new_stocks.contains(s.as_str())); + if !whole { + continue; + } + let Some(diagram) = footprint_bounds(&state.elements) else { + continue; + }; + let flows: Vec = chain + .flows + .iter() + .filter(|f| new_vars.contains(f.as_str())) + .cloned() + .collect(); + let first_created = state.elements.len(); + layout_chain( + state, + config, + metadata, + &chain.stocks, + &flows, + Position::new(0.0, 0.0), + )?; + let Some(chain_box) = footprint_bounds(&state.elements[first_created..]) else { + continue; + }; + + let below = ( + diagram.left - chain_box.left, + diagram.bottom + NEW_CHAIN_GAP - chain_box.top, + ); + let right = ( + diagram.right + NEW_CHAIN_GAP - chain_box.left, + diagram.top - chain_box.top, + ); + let chain_vars: HashSet<&str> = chain.all_vars.iter().map(String::as_str).collect(); + let neighbors: Vec = chain + .all_vars + .iter() + .flat_map(|var| { + metadata + .dep_graph + .get(var) + .into_iter() + .chain(metadata.reverse_dep_graph.get(var)) + .flatten() + }) + .filter(|other| { + !chain_vars.contains(other.as_str()) && !new_vars.contains(other.as_str()) + }) + .filter_map(|other| { + let uid = state.uid_manager.get_uid(other)?; + state.positions.get(&uid).copied() + }) + .collect(); + let (dx, dy) = if neighbors.is_empty() { + let aspect = |(dx, dy): (f64, f64)| { + let union = merge_bounds(diagram, translated(&chain_box, dx, dy)); + let (w, h) = (union.right - union.left, union.bottom - union.top); + w.max(h) / w.min(h).max(1.0) + }; + if aspect(right) < aspect(below) - 1e-9 { + right + } else { + below + } + } else { + let n = neighbors.len() as f64; + let cx = neighbors.iter().map(|p| p.x).sum::() / n; + let cy = neighbors.iter().map(|p| p.y).sum::() / n; + let distance = |(dx, dy): (f64, f64)| { + let center_x = (chain_box.left + chain_box.right) / 2.0 + dx; + let center_y = (chain_box.top + chain_box.bottom) / 2.0 + dy; + (center_x - cx).hypot(center_y - cy) + }; + if distance(right) < distance(below) - 1e-9 { + right + } else { + below + } + }; + + for elem in &mut state.elements[first_created..] { + translate_view_element(elem, dx, dy); + if let Some(pos) = state.positions.get_mut(&elem.get_uid()) { + *pos = Position::new(pos.x + dx, pos.y + dy); + } + } + placed.extend(chain.stocks.iter().cloned()); + placed.extend(flows); + } + Ok(placed) +} + +/// Set each new stock that hangs off a drawn chain -- joined by a flow to a +/// stock already drawn -- one chain step past that neighbor, in its row: right +/// of the stock it drains, left of the stock it feeds, fanning vertically only +/// past a stock or parameter already drawn there +/// (`chain::find_free_stock_position`). A new stock reached only through other +/// new stocks is placed from them in turn. Returns the idents of the stocks +/// placed; a flow between two stocks that are now drawn takes the stock-pair +/// valve position and is held there while the rest settles. +fn place_chain_extensions( + state: &mut LayoutState, + config: &LayoutConfig, + metadata: &ComputedMetadata, + new_stocks: &[String], +) -> HashSet { + let mut pending: HashSet<&str> = new_stocks.iter().map(String::as_str).collect(); + let mut placed: HashSet = HashSet::new(); + let drawn_position = |state: &LayoutState, ident: &str| { + let uid = state.uid_manager.get_uid(ident)?; + state.positions.get(&uid).copied() + }; + for chain in &metadata.chains { + loop { + let mut progress = false; + for flow in &chain.flows { + let (Some(from), Some(to)) = metadata.connected_stocks(flow) else { + continue; + }; + let step = config.stock_width + config.horizontal_spacing; + let (anchor, target, dx) = + match (drawn_position(state, from), drawn_position(state, to)) { + (Some(anchor), None) if pending.contains(to) => (anchor, to, step), + (None, Some(anchor)) if pending.contains(from) => (anchor, from, -step), + _ => continue, + }; + let occupied: Vec = state + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some(Position::new(s.x, s.y)), + ViewElement::Aux(a) => Some(Position::new(a.x, a.y)), + ViewElement::Module(m) => Some(Position::new(m.x, m.y)), + _ => None, + }) + .collect(); + let pos = chain::find_free_stock_position( + Position::new(anchor.x + dx, anchor.y), + &occupied, + config, + ); + let uid = state.get_or_alloc_uid(target); + let name = format_label_with_line_breaks(&state.display_name(target)); + state.elements.push(ViewElement::Stock(view_element::Stock { + name, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + compat: None, + })); + state.positions.insert(uid, pos); + pending.remove(target); + placed.insert(target.to_string()); + progress = true; + } + if !progress { + break; + } + } + } + placed +} + +/// `idents` less the ones in `placed`. +fn without(idents: Vec, placed: &HashSet) -> Vec { + idents.into_iter().filter(|i| !placed.contains(i)).collect() +} + +/// The union of what `elements` draw -- shapes, pipes, and labels at their +/// current sides -- or `None` when they draw nothing. +fn footprint_bounds(elements: &[ViewElement]) -> Option { + use crate::diagram::label::label_bounds; + use crate::layout::metrics::{element_label_props_for, node_shape_box, pipe_rects}; + let mut rects: Vec = Vec::new(); + for elem in elements { + rects.extend(node_shape_box(elem)); + if let ViewElement::Flow(f) = elem { + rects.extend(pipe_rects(f)); + } + let side = match elem { + ViewElement::Aux(a) => Some(a.label_side), + ViewElement::Stock(s) => Some(s.label_side), + ViewElement::Flow(f) => Some(f.label_side), + ViewElement::Module(m) => Some(m.label_side), + _ => None, + }; + if let Some(props) = side.and_then(|side| element_label_props_for(elem, side)) { + rects.push(label_bounds(&props)); + } + } + rects.into_iter().reduce(merge_bounds) +} + +fn translated(r: &Bounds, dx: f64, dy: f64) -> Bounds { + Bounds { + left: r.left + dx, + top: r.top + dy, + right: r.right + dx, + bottom: r.bottom + dy, + } +} + +/// Move a drawn element by `(dx, dy)`, a flow's pipe with it. +fn translate_view_element(elem: &mut ViewElement, dx: f64, dy: f64) { + match elem { + ViewElement::Stock(s) => { + s.x += dx; + s.y += dy; + } + ViewElement::Flow(f) => { + f.x += dx; + f.y += dy; + for pt in &mut f.points { + pt.x += dx; + pt.y += dy; + } + } + ViewElement::Aux(a) => { + a.x += dx; + a.y += dy; + } + ViewElement::Module(m) => { + m.x += dx; + m.y += dy; + } + ViewElement::Cloud(c) => { + c.x += dx; + c.y += dy; + } + ViewElement::Alias(a) => { + a.x += dx; + a.y += dy; + } + ViewElement::Link(_) | ViewElement::Group(_) => {} + } +} + +/// Apply a model patch incrementally to an existing diagram view, +/// preserving existing element positions and only placing new or +/// modified elements. +/// +/// The `project` must already reflect the post-patch model state +/// (i.e., `apply_patch` has been called). The `patch` is taken by +/// reference so callers can inspect the operations. +/// +/// Contract for elements the patch did not touch: position AND +/// `label_side` are returned byte-for-byte. A label side is chosen only +/// for elements created in this pass -- new variables, kind-changed or +/// endpoint-changed rebuilds, and flows whose pipe orientation flipped. +/// A flow rebuilt merely to slide along the same stock face keeps its +/// side. The optimizer never revisits an existing side, even when a +/// connector added by this patch now crosses the label: hand placement +/// wins, and the human (or a full relayout) can move it. +/// +/// Composition: +/// 1. Compute metadata for the post-patch model +/// 2. Seed LayoutState from old view +/// 3. Process deletions and renames from the patch +/// 4. Identify new elements, compute initial positions +/// 5. Create view elements and settle via pinned SFDP +/// 6. Diff connectors/clouds, place labels for this pass's elements, +/// apply loop curvature +/// 7. Build StockFlow from final state +pub fn incremental_layout( + old_view: &datamodel::StockFlow, + project: &datamodel::Project, + model_name: &str, + patch: &crate::patch::ModelPatch, + db_state: Option<(&crate::db::SimlinDb, crate::db::SourceProject)>, +) -> Result { + if old_view.elements.is_empty() { + return generate_best_layout(project, model_name, db_state); + } + + // View-only patches (UpsertView/DeleteView) don't affect model variables, + // so the diagram should be returned unchanged. Without this guard, the + // diff_connectors and optimize_labels passes would rewrite connectors and + // labels even though nothing structurally changed. + let has_variable_ops = patch.ops.iter().any(|op| { + !matches!( + op, + crate::patch::ModelOperation::UpsertView { .. } + | crate::patch::ModelOperation::DeleteView { .. } + ) + }); + if !has_variable_ops { + return Ok(old_view.clone()); + } + + let config = LayoutConfig::default(); + + let not_found = || format!("model '{}' not found in project", model_name); + let model = project.get_model(model_name).ok_or_else(not_found)?; + let metadata = compute_metadata(project, model_name, db_state).ok_or_else(not_found)?; + + // Step 2: Seed state from old view + let mut state = LayoutState::from_existing_view(old_view, model); + + // Step 3: Process deletions and renames + for op in &patch.ops { + match op { + crate::patch::ModelOperation::DeleteVariable { ident } => { + state.apply_deletion(ident); + } + crate::patch::ModelOperation::RenameVariable { from, to } => { + let new_display = state + .display_names + .get(&canonicalize(to).into_owned()) + .cloned() + .unwrap_or_else(|| to.clone()); + state.apply_rename(from, to, &new_display); + } + _ => {} + } + } + + // Between steps 3 and 4a: detect variables whose type changed (e.g., Aux -> Stock). + // When a caller issues UpsertStock for a variable that was previously an Aux, there + // is no DeleteVariable in the patch and the old Aux element is still in state. + // identify_new_elements only checks for UID presence, not element type, so the + // stale element would survive. We detect type mismatches here and remove the + // old element so it is rebuilt with the correct type. + { + let kind_changed: Vec = model + .variables + .iter() + .filter_map(|var| { + let canonical = canonicalize(var.get_ident()).into_owned(); + let uid = state.uid_manager.get_uid(&canonical)?; + // Find the view element for this UID + let elem = state.elements.iter().find(|e| e.get_uid() == uid)?; + // Check for a type mismatch + let mismatch = !matches!( + (var, elem), + (datamodel::Variable::Stock(_), ViewElement::Stock(_)) + | (datamodel::Variable::Flow(_), ViewElement::Flow(_)) + | (datamodel::Variable::Aux(_), ViewElement::Aux(_)) + | (datamodel::Variable::Module(_), ViewElement::Module(_)) + ); + if mismatch { Some(canonical) } else { None } + }) + .collect(); + for ident in kind_changed { + // Save the display name before apply_deletion removes it from display_names, + // so the rebuilt element can recover the original casing (e.g. "Growth Rate" + // instead of "growth_rate"). + let saved_display = state.display_names.get(&ident).cloned(); + state.apply_deletion(&ident); + // Restore: use the saved original display name when available, otherwise + // fall back to the canonical ident so the entry is always present. + let display = saved_display.unwrap_or_else(|| ident.clone()); + state.display_names.insert(ident, display); + } + } + + // Between steps 3 and 4: detect flows whose stock connections changed. + // A flow element keeps its old attached_to_uid values when preserved in state, + // so a flow that moved from one stock to another would keep stale endpoints. + // Remove such flows (and their clouds) so identify_new_elements picks them + // up as new and they get rebuilt with correct endpoints. + // + // This also handles transitions between stock and cloud endpoints: if the + // model now expects a cloud source (from_stock == None) but the preserved + // flow's source point is still attached to a stock UID, the flow is stale. + { + let uid_to_ident: HashMap = model + .variables + .iter() + .filter_map(|var| { + let ident = canonicalize(var.get_ident()).into_owned(); + state.uid_manager.get_uid(&ident).map(|uid| (uid, ident)) + }) + .collect(); + + // Build the set of cloud UIDs so we can validate cloud-endpoint assignments. + // When a cloud is expected (expected_from/to == None), the flow endpoint must + // be either unattached or attached to a cloud. Checking against cloud_uids + // (rather than just "not in stock_uids") catches the case where a stock was + // kind-changed to an aux: the old UID is reused by the new non-stock element, + // so the flow must be rebuilt with a proper cloud endpoint. + let cloud_uids: HashSet = state + .elements + .iter() + .filter_map(|elem| match elem { + ViewElement::Cloud(c) => Some(c.uid), + _ => None, + }) + .collect(); + + let flows_to_reset: Vec = state + .elements + .iter() + .filter_map(|elem| { + let flow = match elem { + ViewElement::Flow(f) => f, + _ => return None, + }; + if flow.points.len() < 2 { + return None; + } + let flow_ident = uid_to_ident.get(&flow.uid)?; + let (expected_from, expected_to) = metadata.flow_to_stocks.get(flow_ident)?; + + let expected_from_uid = expected_from + .as_deref() + .and_then(|s| state.uid_manager.get_uid(s)); + let expected_to_uid = expected_to + .as_deref() + .and_then(|s| state.uid_manager.get_uid(s)); + + // Check the source endpoint (points[0]): + // - None expected (cloud): endpoint must be unattached or attached to a cloud + // - Some(uid) expected: the source must be attached to exactly that stock + let source_uid = flow.points[0].attached_to_uid; + let from_matches = match expected_from_uid { + None => { + source_uid.is_none() || source_uid.is_some_and(|u| cloud_uids.contains(&u)) + } + Some(uid) => source_uid == Some(uid), + }; + + // Check the sink endpoint (points[last]): + // - None expected (cloud): endpoint must be unattached or attached to a cloud + // - Some(uid) expected: the sink must be attached to exactly that stock + let last = flow.points.len() - 1; + let sink_uid = flow.points[last].attached_to_uid; + let to_matches = match expected_to_uid { + None => sink_uid.is_none() || sink_uid.is_some_and(|u| cloud_uids.contains(&u)), + Some(uid) => sink_uid == Some(uid), + }; + + if from_matches && to_matches { + None + } else { + Some(flow_ident.clone()) + } + }) + .collect(); + + for flow_ident in flows_to_reset { + // apply_deletion removes the element from state.elements but leaves + // the UID in uid_manager. identify_new_elements will see a UID with + // no corresponding element and classify the flow as new, causing + // create_flow_view_element to rebuild it with correct endpoints. + let canonical = canonicalize(&flow_ident).into_owned(); + // Save the display name before apply_deletion removes it so the + // rebuilt element recovers the original casing. + let saved_display = state.display_names.get(&canonical).cloned(); + state.apply_deletion(&flow_ident); + let display = saved_display.unwrap_or_else(|| flow_ident.clone()); + state.display_names.insert(canonical, display); + } + } + + // Step 4: Identify new elements and compute initial positions + let new_elements = state.identify_new_elements(model); + + // Compute flow attachments for flows on stocks that are affected by + // flow additions, deletions, or connection changes. This ensures + // preserved flows get reclassified when a sibling chain flow is + // added or removed. + let mut incr_flow_attachments: HashMap = HashMap::new(); + let mut affected_stocks: HashSet = HashSet::new(); + + for flow_ident in &new_elements.new_flows { + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + if let Some(stock) = from_stock { + affected_stocks.insert(stock.to_string()); + } + if let Some(stock) = to_stock { + affected_stocks.insert(stock.to_string()); + } + } + + // Also mark stocks whose flow connections changed via the patch + // (e.g. when a chain flow is deleted, the stock loses a flow and + // remaining cloud flows may need reclassification from Bottom/Top + // back to Right/Left). + for op in &patch.ops { + if let crate::patch::ModelOperation::UpdateStockFlows { ident, .. } = op { + let canonical = canonicalize(ident).into_owned(); + affected_stocks.insert(canonical); + } + } + + // For deleted flows, find which stocks they were connected to in the + // old view. This handles patches that only emit DeleteVariable without + // UpdateStockFlows -- the remaining sibling flows still need to be + // reclassified. + // Build UID-to-ident map from the model's stock variables rather than + // from view element labels, since labels go through + // format_label_with_line_breaks and may not round-trip through + // canonicalize for quoted names like "a.b". + let stock_uid_to_ident: HashMap = model + .variables + .iter() + .filter_map(|v| { + if !matches!(v, datamodel::Variable::Stock(_)) { + return None; + } + let canonical = canonicalize(v.get_ident()).into_owned(); + state + .uid_manager + .get_uid(&canonical) + .map(|uid| (uid, canonical)) + }) + .collect(); + for op in &patch.ops { + if let crate::patch::ModelOperation::DeleteVariable { ident } = op { + let canonical = canonicalize(ident).into_owned(); + // Match by UID rather than display name: labels go through + // format_label_with_line_breaks which strips quoting, so + // canonicalizing the label back can produce a different ident + // for names like "a.b". + let deleted_uid = match state.uid_manager.get_uid(&canonical) { + Some(uid) => uid, + None => continue, + }; + for elem in &old_view.elements { + if let ViewElement::Flow(f) = elem + && f.uid == deleted_uid + { + for pt in &f.points { + if let Some(uid) = pt.attached_to_uid + && let Some(stock_ident) = stock_uid_to_ident.get(&uid) + { + affected_stocks.insert(stock_ident.clone()); + } + } + } + } + } + } + + // The faces the affected stocks' side flows are drawn on before the patch. + let mut existing_faces: HashMap = HashMap::new(); + for stock in &affected_stocks { + let existing = existing_side_flow_faces(&state, &config, &metadata, stock); + let sides = classify_flow_sides(stock, &metadata, &existing); + incr_flow_attachments.extend(sides); + existing_faces.extend(existing); + } + + // Re-sort flows within each side group by existing position rather + // than alphabetical ident, so imported or manually-edited ordering + // is preserved when a sibling is added or removed. + reorder_attachments_by_position( + &mut incr_flow_attachments, + &state, + &affected_stocks, + &metadata, + ); + + // Check if any existing (preserved) flows need to change sides. + // If classify_flow_sides assigns Bottom/Top to a flow that is + // currently horizontal (or Right/Left to one that is vertical), + // delete and rebuild it so its geometry matches. + let mut flows_to_rebuild: Vec = Vec::new(); + // Label sides of flows rebuilt only to move along the same stock face: + // their pipe keeps its orientation, so the existing (possibly hand-placed) + // side stays valid and is restored after the rebuild. Flows whose + // orientation flips are rebuilt with a freshly chosen side instead. + let mut offset_rebuilt_label_sides: HashMap = HashMap::new(); + for (flow_ident, attachment) in &incr_flow_attachments { + // Skip flows that are new (they'll be created below) + if new_elements.new_flows.contains(flow_ident) { + continue; + } + // Skip stock-to-stock (chain) flows entirely: their pipe geometry + // is determined by both stock positions and ignores the attachment + // offset. Rebuilding them via attachment_based_flow_position (which + // only knows one stock) would place the valve beside one stock + // instead of between the pair. + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + if from_stock.is_some() && to_stock.is_some() { + continue; + } + // Check if this flow exists and has mismatched orientation or offset + if let Some(uid) = state.uid_manager.get_uid(flow_ident) { + let existing = state.elements.iter().find(|e| { + if let ViewElement::Flow(f) = e { + f.uid == uid + } else { + false + } + }); + if let Some(ViewElement::Flow(f)) = existing { + let orientation = compute_flow_orientation(&f.points); + let needs_vertical = matches!( + attachment.side, + StockAttachSide::Bottom | StockAttachSide::Top + ); + let is_vertical = matches!(orientation, FlowOrientation::Vertical); + if needs_vertical != is_vertical { + flows_to_rebuild.push(flow_ident.clone()); + } else if existing_faces + .get(flow_ident) + .is_some_and(|&side| side != attachment.side) + { + // Moved to the opposite face (top <-> bottom, left <-> + // right): the pipe keeps its orientation, so the label + // side stays valid. + flows_to_rebuild.push(flow_ident.clone()); + offset_rebuilt_label_sides.insert(flow_ident.clone(), f.label_side); + } else { + // Orientation matches but the offset may have changed + // (e.g. a sibling was added/removed on the same face). + let stock_name = from_stock.or(to_stock); + if let Some(sn) = stock_name + && let Some(stock_uid) = state.uid_manager.get_uid(sn) + && let Some(&stock_pos) = state.positions.get(&stock_uid) + { + let (expected, current) = if needs_vertical { + let exp = stock_pos.x - config.stock_width / 2.0 + + config.stock_width * attachment.offset; + let cur = f + .points + .iter() + .find(|pt| pt.attached_to_uid == Some(stock_uid)) + .map(|pt| pt.x); + (exp, cur) + } else { + let exp = stock_pos.y - config.stock_height / 2.0 + + config.stock_height * attachment.offset; + let cur = f + .points + .iter() + .find(|pt| pt.attached_to_uid == Some(stock_uid)) + .map(|pt| pt.y); + (exp, cur) + }; + if let Some(c) = current + && (c - expected).abs() > 0.5 + { + flows_to_rebuild.push(flow_ident.clone()); + offset_rebuilt_label_sides.insert(flow_ident.clone(), f.label_side); + } + } + } + } + } + } + + // Save old positions before deletion so we have a fallback if + // attachment_based_flow_position can't resolve the stock UID + // (e.g. imported views with quoted identifiers). + let old_flow_positions: HashMap = flows_to_rebuild + .iter() + .filter_map(|ident| { + let uid = state.uid_manager.get_uid(ident)?; + state.positions.get(&uid).map(|&pos| (ident.clone(), pos)) + }) + .collect(); + + // Delete and rebuild flows that need to change orientation or offset + for flow_ident in &flows_to_rebuild { + let saved_display = state + .display_names + .get(&canonicalize(flow_ident).into_owned()) + .cloned(); + state.apply_deletion(flow_ident); + if let Some(display) = saved_display { + state + .display_names + .insert(canonicalize(flow_ident).into_owned(), display); + } + } + + // Every element still standing at this point survived the patch untouched + // (or was merely renamed): it keeps its position, and a named one its label + // side, for the rest of the pass. Whatever gets created from here on -- new + // variables, kind-changed or endpoint-changed rebuilds, orientation-flipped + // flows -- is absent from this snapshot, so `declutter_part` below chooses + // its side and may move it. Offset-only rebuilt flows are added back to the + // pinned sides explicitly because they were just deleted but keep their + // orientation. + let standing_uids: HashSet = state.elements.iter().map(ViewElement::get_uid).collect(); + let mut pinned_label_sides: HashMap = state + .elements + .iter() + .filter_map(|elem| match elem { + ViewElement::Stock(s) => Some((s.uid, s.label_side)), + ViewElement::Flow(f) => Some((f.uid, f.label_side)), + ViewElement::Aux(a) => Some((a.uid, a.label_side)), + ViewElement::Module(m) => Some((m.uid, m.label_side)), + _ => None, + }) + .collect(); + for (flow_ident, side) in &offset_rebuilt_label_sides { + if let Some(uid) = state.uid_manager.get_uid(flow_ident) { + pinned_label_sides.insert(uid, *side); + } + } + + // Compute positions for rebuilt flows based on their attachment info, + // falling back to the old position if the stock UID lookup fails. + for flow_ident in &flows_to_rebuild { + let pos = attachment_based_flow_position( + &state, + &config, + &metadata, + flow_ident, + &incr_flow_attachments, + ) + .or_else(|| old_flow_positions.get(flow_ident).copied()); + if let Some(pos) = pos { + let uid = state.get_or_alloc_uid(flow_ident); + create_flow_view_element( + &mut state, + &config, + &metadata, + flow_ident, + uid, + pos, + &incr_flow_attachments, + )?; + } + } + + restore_label_sides(&mut state, &pinned_label_sides); + let needs_label_placement = |uid: i32| !pinned_label_sides.contains_key(&uid); + + if new_elements.is_empty() { + // No new elements and no settlement step, so rebuilt flows + // already have correct geometry from create_flow_view_element. + // Skip resnap entirely to avoid rewriting unrelated manual or + // imported flow endpoints elsewhere in the diagram. + diff_connectors(&mut state, &metadata); + diff_clouds(&mut state, &metadata); + declutter::declutter_part(&mut state.elements, needs_label_placement, |_| false); + apply_loop_curvature(&mut state, &config, model, &metadata); + validate_view_completeness(&state, model)?; + return Ok(build_stock_flow_from_state(state, old_view)); + } + + // Step 4b: chains the patch added whole are laid out as chains and set + // down beside the diagram; the rest of what is new is placed generically. + let mut placed_chain_vars = place_new_chains(&mut state, &config, &metadata, &new_elements)?; + placed_chain_vars.extend(place_chain_extensions( + &mut state, + &config, + &metadata, + &without(new_elements.new_stocks.clone(), &placed_chain_vars), + )); + let new_elements = NewElements { + new_stocks: without(new_elements.new_stocks, &placed_chain_vars), + new_flows: without(new_elements.new_flows, &placed_chain_vars), + new_auxes: new_elements.new_auxes, + new_modules: new_elements.new_modules, + }; + + let initial_positions = compute_new_element_positions(&state, &metadata, &new_elements); + + // Step 5: Create view elements for new variables and insert their + // initial positions into state so settlement can find them. + for stock_ident in &new_elements.new_stocks { + if let Some(&pos) = initial_positions.get(stock_ident) { + let uid = state.get_or_alloc_uid(stock_ident); + let name = state.display_name(stock_ident); + let formatted = format_label_with_line_breaks(&name); + state.elements.push(ViewElement::Stock(view_element::Stock { + name: formatted, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + compat: None, + })); + state.positions.insert(uid, pos); + } + } + + for flow_ident in &new_elements.new_flows { + // For stock-to-stock (chain) flows, use the generic seed position + // which places the valve between the two stocks. For cloud flows + // (one unattached end), use attachment-based position so top/bottom + // flows get their valve on the correct vertical pipe. + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + let is_stock_to_stock = from_stock.is_some() && to_stock.is_some(); + let pos = if is_stock_to_stock { + initial_positions.get(flow_ident).copied() + } else { + attachment_based_flow_position( + &state, + &config, + &metadata, + flow_ident, + &incr_flow_attachments, + ) + .or_else(|| initial_positions.get(flow_ident).copied()) + }; + if let Some(pos) = pos { + let uid = state.get_or_alloc_uid(flow_ident); + create_flow_view_element( + &mut state, + &config, + &metadata, + flow_ident, + uid, + pos, + &incr_flow_attachments, + )?; + } + } + + // create_flow_view_element calls build_clouds_for_flow which pushes Cloud elements into + // state.elements but does not add their positions to state.positions. Record those + // positions now so that settle_new_elements can seed proper initial positions for cloud + // nodes in SFDP and later update them after settling. + for elem in &state.elements { + if let ViewElement::Cloud(c) = elem { + state + .positions + .entry(c.uid) + .or_insert_with(|| Position::new(c.x, c.y)); + } + } + + for aux_ident in &new_elements.new_auxes { + if let Some(&pos) = initial_positions.get(aux_ident) { + let uid = state.get_or_alloc_uid(aux_ident); + let name = state.display_name(aux_ident); + let formatted = format_label_with_line_breaks(&name); + state.elements.push(ViewElement::Aux(view_element::Aux { + name: formatted, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + compat: None, + })); + state.positions.insert(uid, pos); + } + } + + for module_ident in &new_elements.new_modules { + if let Some(&pos) = initial_positions.get(module_ident) { + let uid = state.get_or_alloc_uid(module_ident); + let name = state.display_name(module_ident); + let formatted = format_label_with_line_breaks(&name); + state + .elements + .push(ViewElement::Module(view_element::Module { + name: formatted, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + })); + state.positions.insert(uid, pos); + } + } + + // Step 6: Settle new elements with existing elements pinned + let chains_data: Vec<_> = metadata + .chains + .iter() + .map(|c| (c.stocks.clone(), c.flows.clone(), c.all_vars.clone())) + .collect(); + settle_new_elements( + &mut state, + &config, + model, + &metadata, + &new_elements, + &chains_data, + )?; + + // Update view element coordinates from settled positions + for elem in &mut state.elements { + let uid = elem.get_uid(); + if let Some(&pos) = state.positions.get(&uid) { + match elem { + ViewElement::Stock(s) => { + s.x = pos.x; + s.y = pos.y; + } + ViewElement::Flow(f) => { + let dx = pos.x - f.x; + let dy = pos.y - f.y; + f.x = pos.x; + f.y = pos.y; + for pt in &mut f.points { + pt.x += dx; + pt.y += dy; + } + } + ViewElement::Aux(a) => { + a.x = pos.x; + a.y = pos.y; + } + ViewElement::Module(m) => { + m.x = pos.x; + m.y = pos.y; + } + ViewElement::Cloud(c) => { + c.x = pos.x; + c.y = pos.y; + } + _ => {} + } + } + } + + resnap_flow_endpoints(&mut state, &config); + + // Step 7: Diff connectors and clouds + diff_connectors(&mut state, &metadata); + diff_clouds(&mut state, &metadata); + + // Step 8: Polish. The new free-floating elements step off crossings and + // off whatever they landed on, and elements created in this pass get their + // label sides chosen by what the metric charges. Pinned elements keep their positions and + // sides even if a new connector now runs through a label (hand placement + // wins; the human can move it). + polish::polish_crossings_for(&mut state.elements, |uid| !standing_uids.contains(&uid)); + declutter::declutter_part(&mut state.elements, needs_label_placement, |uid| { + !standing_uids.contains(&uid) + }); + // The decluttered free-floating elements' positions, for the loop arcs. + for elem in &state.elements { + let (uid, x, y) = match elem { + ViewElement::Aux(a) => (a.uid, a.x, a.y), + ViewElement::Module(m) => (m.uid, m.x, m.y), + ViewElement::Alias(a) => (a.uid, a.x, a.y), + _ => continue, + }; + if let Some(pos) = state.positions.get_mut(&uid) { + *pos = Position::new(x, y); + } + } + apply_loop_curvature(&mut state, &config, model, &metadata); + // Guarantee flows stay orthogonal after re-snapping endpoints to moved + // stocks (only rewrites pipes that actually went diagonal; hand-routed + // orthogonal flows are left untouched). + orthogonal::orthogonalize_flow_pipes(&mut state.elements); + + validate_view_completeness(&state, model)?; + + // Step 9: Build StockFlow + Ok(build_stock_flow_from_state(state, old_view)) +} + +#[cfg(test)] +#[path = "incremental_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/layout/incremental_tests.rs b/src/simlin-engine/src/layout/incremental_tests.rs new file mode 100644 index 000000000..4f76d8c09 --- /dev/null +++ b/src/simlin-engine/src/layout/incremental_tests.rs @@ -0,0 +1,254 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +use super::*; +use crate::layout::metrics::compute_layout_metrics; +use crate::patch::{ModelOperation, ModelPatch}; + +const TEST_MODEL: &str = "main"; + +fn project_with(variables: Vec) -> datamodel::Project { + datamodel::Project { + name: "test".to_string(), + sim_specs: datamodel::SimSpecs::default(), + dimensions: Vec::new(), + units: Vec::new(), + models: vec![datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables, + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + }], + source: None, + ai_information: None, + } +} + +fn stock(ident: &str, inflows: &[&str], outflows: &[&str]) -> datamodel::Stock { + datamodel::Stock { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar("100".to_string()), + documentation: String::new(), + units: None, + inflows: inflows.iter().map(|s| s.to_string()).collect(), + outflows: outflows.iter().map(|s| s.to_string()).collect(), + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + } +} + +fn flow(ident: &str, equation: &str) -> datamodel::Flow { + datamodel::Flow { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + } +} + +fn aux(ident: &str, equation: &str) -> datamodel::Aux { + datamodel::Aux { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + } +} + +/// Apply `ops` to `project` holding `old_view`, and sync the view through +/// `incremental_layout`, the way MCP `edit_model` (`sync_diagram`) and +/// libsimlin's patch sync do: the patch is applied to a project whose model +/// already carries the view, so the uids it mints for new variables are past +/// every uid the view uses. +fn sync( + project: &datamodel::Project, + old_view: &datamodel::StockFlow, + ops: Vec, +) -> (datamodel::Project, datamodel::StockFlow) { + let patch = ModelPatch { + name: TEST_MODEL.to_string(), + ops, + }; + let mut patched = project.clone(); + patched.get_model_mut(TEST_MODEL).expect("model").views = + vec![datamodel::View::StockFlow(old_view.clone())]; + crate::patch::apply_patch( + &mut patched, + crate::patch::ProjectPatch { + project_ops: vec![], + models: vec![patch.clone()], + }, + ) + .expect("patch applies"); + let view = incremental_layout(old_view, &patched, TEST_MODEL, &patch, None) + .expect("incremental layout"); + (patched, view) +} + +/// `(x, y, label side)` of every named element. +fn geometry(view: &datamodel::StockFlow) -> HashMap { + view.elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y, s.label_side))), + ViewElement::Flow(f) => Some((f.uid, (f.x, f.y, f.label_side))), + ViewElement::Aux(a) => Some((a.uid, (a.x, a.y, a.label_side))), + ViewElement::Module(m) => Some((m.uid, (m.x, m.y, m.label_side))), + _ => None, + }) + .collect() +} + +#[test] +fn new_parameters_are_decluttered_around_the_fixed_diagram() { + // Six new parameters that all feed one existing flow are seeded in a tight + // ring beside it. Their names must not land on each other or on anything + // already drawn, while every element that was already drawn keeps its + // position and label side. + let project = project_with(vec![ + datamodel::Variable::Stock(stock("population", &[], &["deaths"])), + datamodel::Variable::Flow(flow("deaths", "population * 0.1")), + ]); + let base = generate_layout(&project, TEST_MODEL, None).expect("base layout"); + let params: Vec = (1..=6) + .map(|i| format!("mortality adjustment parameter {i}")) + .collect(); + let mut ops: Vec = params + .iter() + .map(|p| ModelOperation::UpsertAux(aux(p, "0.1"))) + .collect(); + let sum = params + .iter() + .map(|p| canonicalize(p).into_owned()) + .collect::>() + .join(" + "); + ops.push(ModelOperation::UpsertFlow(flow( + "deaths", + &format!("population * ({sum}) / 6"), + ))); + let (_, view) = sync(&project, &base, ops); + + let m = compute_layout_metrics(&view, &LayoutConfig::default()); + assert_eq!( + m.node_overlap, 0.0, + "new parameters must not cover any shape" + ); + assert_eq!(m.label_overlap, 0.0, "new names must not cover anything"); + + let before = geometry(&base); + let after = geometry(&view); + for (uid, g) in &before { + assert_eq!(after.get(uid), Some(g), "element {uid} must stay put"); + } +} + +#[test] +fn chains_added_whole_are_laid_out_as_chains_beside_the_diagram() { + // An agent adds two whole stock-flow chains to a diagram in one edit: a + // two-stock capital chain and a one-stock pollution chain. Each must be + // drawn as a fresh layout draws a chain -- stocks in a row, pipes straight + // -- in free space, not piled onto the existing chain or each other. + let project = project_with(vec![ + datamodel::Variable::Stock(stock("population", &["births"], &["deaths"])), + datamodel::Variable::Flow(flow("births", "population * 0.03")), + datamodel::Variable::Flow(flow("deaths", "population * 0.02")), + ]); + let base = generate_layout(&project, TEST_MODEL, None).expect("base layout"); + let ops = vec![ + ModelOperation::UpsertStock(stock("capital", &["investment"], &["retirement"])), + ModelOperation::UpsertStock(stock("retired capital", &["retirement"], &["scrapping"])), + ModelOperation::UpsertFlow(flow("investment", "10")), + ModelOperation::UpsertFlow(flow("retirement", "capital / 20")), + ModelOperation::UpsertFlow(flow("scrapping", "retired_capital / 5")), + ModelOperation::UpsertStock(stock("pollution", &["emissions"], &["absorption"])), + ModelOperation::UpsertFlow(flow("emissions", "capital * 0.1")), + ModelOperation::UpsertFlow(flow("absorption", "pollution / 10")), + ]; + let (_, view) = sync(&project, &base, ops); + + let m = compute_layout_metrics(&view, &LayoutConfig::default()); + assert_eq!(m.node_overlap, 0.0, "no shape may cover another"); + assert_eq!(m.label_overlap, 0.0, "no name may be covered"); + assert_eq!(m.flow_bends, 0.0, "every pipe of a chain is straight"); + + let stock_y = |name: &str| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Stock(s) if canonicalize(&s.name) == name => Some(s.y), + _ => None, + }) + .unwrap_or_else(|| panic!("{name} drawn")) + }; + assert_eq!( + stock_y("capital"), + stock_y("retired_capital"), + "a chain's stocks share a row" + ); + + let before = geometry(&base); + let after = geometry(&view); + for (uid, g) in &before { + assert_eq!(after.get(uid), Some(g), "element {uid} must stay put"); + } +} + +#[test] +fn a_stock_added_to_a_drawn_chain_continues_its_row() { + // An agent extends a drawn chain: infected now drains into a new recovered + // stock. The new stock belongs one chain step past infected, in the same + // row, with a straight pipe between them -- not parked off to the side + // with a bent pipe reaching for it. + let project = project_with(vec![ + datamodel::Variable::Stock(stock("susceptible", &[], &["infection"])), + datamodel::Variable::Stock(stock("infected", &["infection"], &[])), + datamodel::Variable::Flow(flow("infection", "susceptible * infected / 1000")), + ]); + let base = generate_layout(&project, TEST_MODEL, None).expect("base layout"); + let ops = vec![ + ModelOperation::UpsertStock(stock("infected", &["infection"], &["recovery"])), + ModelOperation::UpsertStock(stock("recovered", &["recovery"], &[])), + ModelOperation::UpsertFlow(flow("recovery", "infected / 10")), + ]; + let (_, view) = sync(&project, &base, ops); + + let m = compute_layout_metrics(&view, &LayoutConfig::default()); + assert_eq!(m.node_overlap, 0.0, "no shape may cover another"); + assert_eq!(m.flow_bends, 0.0, "the new pipe runs straight"); + + let stock_at = |name: &str| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Stock(s) if canonicalize(&s.name) == name => Some((s.x, s.y)), + _ => None, + }) + .unwrap_or_else(|| panic!("{name} drawn")) + }; + let (infected_x, infected_y) = stock_at("infected"); + let (recovered_x, recovered_y) = stock_at("recovered"); + assert_eq!(recovered_y, infected_y, "the chain's row continues"); + assert!( + recovered_x > infected_x, + "the downstream stock is to the right" + ); + + let before = geometry(&base); + let after = geometry(&view); + for (uid, g) in &before { + assert_eq!(after.get(uid), Some(g), "element {uid} must stay put"); + } +} diff --git a/src/simlin-engine/src/layout/layout_flow_side_tests.rs b/src/simlin-engine/src/layout/layout_flow_side_tests.rs new file mode 100644 index 000000000..ea1fa3301 --- /dev/null +++ b/src/simlin-engine/src/layout/layout_flow_side_tests.rs @@ -0,0 +1,658 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Which stock face each flow attaches to (`classify_flow_sides`) and the +//! geometry fresh layouts draw from it. + +use super::*; +use crate::datamodel; + +// --------------------------------------------------------------------------- +// classify_flow_sides tests +// --------------------------------------------------------------------------- + +/// Helper: build a ComputedMetadata with the given stock/flow topology. +fn metadata_with_flows( + stock_outflows: &[(&str, &[&str])], + stock_inflows: &[(&str, &[&str])], + flow_to_stocks: &[(&str, Option<&str>, Option<&str>)], +) -> ComputedMetadata { + let mut meta = ComputedMetadata::new_empty(); + for &(stock, outflows) in stock_outflows { + meta.stock_to_outflows.insert( + stock.to_string(), + outflows.iter().map(|s| s.to_string()).collect(), + ); + } + for &(stock, inflows) in stock_inflows { + meta.stock_to_inflows.insert( + stock.to_string(), + inflows.iter().map(|s| s.to_string()).collect(), + ); + } + for &(flow, from, to) in flow_to_stocks { + meta.flow_to_stocks.insert( + flow.to_string(), + (from.map(|s| s.to_string()), to.map(|s| s.to_string())), + ); + } + meta +} + +#[test] +fn test_classify_sides_single_outflow_no_chain() { + // Stock with one outflow to cloud (no chain flow) -> stays Right + let meta = metadata_with_flows(&[("a", &["f1"])], &[], &[("f1", Some("a"), None)]); + let sides = classify_flow_sides("a", &meta, &HashMap::new()); + let att = sides.get("f1").expect("f1 should have attachment"); + assert_eq!(att.side, StockAttachSide::Right); + assert!((att.offset - 0.5).abs() < f64::EPSILON); +} + +#[test] +fn test_classify_sides_chain_plus_side_outflow() { + // Stock with chain outflow (a->b) + side outflow (a->cloud) + let meta = metadata_with_flows( + &[("a", &["chain_flow", "waste_flow"])], + &[], + &[ + ("chain_flow", Some("a"), Some("b")), + ("waste_flow", Some("a"), None), + ], + ); + let sides = classify_flow_sides("a", &meta, &HashMap::new()); + + let chain = sides.get("chain_flow").expect("chain_flow attachment"); + assert_eq!(chain.side, StockAttachSide::Right); + assert!((chain.offset - 0.5).abs() < f64::EPSILON); + + let waste = sides.get("waste_flow").expect("waste_flow attachment"); + assert_eq!(waste.side, StockAttachSide::Bottom); + assert!((waste.offset - 0.5).abs() < f64::EPSILON); +} + +#[test] +fn test_classify_sides_chain_plus_two_side_outflows() { + // Chain + two side outflows: the chain holds the right face, so the side + // outflows each take a face of their own -- below, then above -- rather + // than stacking their valves on one face. + let meta = metadata_with_flows( + &[("a", &["chain_flow", "waste_a", "waste_b"])], + &[], + &[ + ("chain_flow", Some("a"), Some("b")), + ("waste_a", Some("a"), None), + ("waste_b", Some("a"), None), + ], + ); + let sides = classify_flow_sides("a", &meta, &HashMap::new()); + + let chain = sides.get("chain_flow").expect("chain_flow"); + assert_eq!(chain.side, StockAttachSide::Right); + + // waste_a and waste_b sorted alphabetically -> waste_a first + let wa = sides.get("waste_a").expect("waste_a"); + let wb = sides.get("waste_b").expect("waste_b"); + assert_eq!(wa.side, StockAttachSide::Bottom); + assert_eq!(wb.side, StockAttachSide::Top); + assert!((wa.offset - 0.5).abs() < 1e-10); + assert!((wb.offset - 0.5).abs() < 1e-10); +} + +#[test] +fn test_classify_sides_chain_inflow_plus_side_inflow() { + // Stock with chain inflow (from stock) + side inflow (from cloud) + let meta = metadata_with_flows( + &[], + &[("b", &["chain_in", "side_in"])], + &[ + ("chain_in", Some("a"), Some("b")), + ("side_in", None, Some("b")), + ], + ); + let sides = classify_flow_sides("b", &meta, &HashMap::new()); + + let chain = sides.get("chain_in").expect("chain_in"); + assert_eq!(chain.side, StockAttachSide::Left); + assert!((chain.offset - 0.5).abs() < f64::EPSILON); + + let side = sides.get("side_in").expect("side_in"); + assert_eq!(side.side, StockAttachSide::Top); + assert!((side.offset - 0.5).abs() < f64::EPSILON); +} + +#[test] +fn test_classify_sides_only_nonchain_outflows() { + // Three side outflows spread over the three outflow faces in preference + // order (sorted by ident): right, bottom, top. + let meta = metadata_with_flows( + &[("a", &["f1", "f2", "f3"])], + &[], + &[ + ("f1", Some("a"), None), + ("f2", Some("a"), None), + ("f3", Some("a"), None), + ], + ); + let sides = classify_flow_sides("a", &meta, &HashMap::new()); + + assert_eq!(sides["f1"].side, StockAttachSide::Right); + assert_eq!(sides["f2"].side, StockAttachSide::Bottom); + assert_eq!(sides["f3"].side, StockAttachSide::Top); + for name in ["f1", "f2", "f3"] { + assert!((sides[name].offset - 0.5).abs() < 1e-10, "{name}"); + } +} + +#[test] +fn test_classify_sides_multiple_chain_outflows() { + // Stock with two chain outflows (feeds two stocks) -> both Right, 1/3 and 2/3 + let meta = metadata_with_flows( + &[("a", &["f1", "f2"])], + &[], + &[("f1", Some("a"), Some("b")), ("f2", Some("a"), Some("c"))], + ); + let sides = classify_flow_sides("a", &meta, &HashMap::new()); + + let a1 = sides.get("f1").expect("f1"); + let a2 = sides.get("f2").expect("f2"); + assert_eq!(a1.side, StockAttachSide::Right); + assert_eq!(a2.side, StockAttachSide::Right); + assert!((a1.offset - 1.0 / 3.0).abs() < 1e-10); + assert!((a2.offset - 2.0 / 3.0).abs() < 1e-10); +} + +#[test] +fn test_classify_sides_existing_faces_are_kept() { + // One row per arm of how an already-drawn side flow is seated. Stock `a`; + // `chain` (when present) runs a -> b and holds the right face. + type Row = ( + &'static str, + bool, + &'static [&'static str], + &'static [(&'static str, StockAttachSide)], + &'static [(&'static str, StockAttachSide)], + ); + let rows: &[Row] = &[ + ( + "a flow drawn on a face a chain now holds is re-placed like a new one", + true, + &["w"], + &[("w", StockAttachSide::Right)], + &[("w", StockAttachSide::Bottom)], + ), + ( + "flows on their faces stay; a new flow takes a free face, even when it sorts first", + false, + &["w0", "w1", "w2"], + &[ + ("w1", StockAttachSide::Right), + ("w2", StockAttachSide::Bottom), + ], + &[ + ("w0", StockAttachSide::Top), + ("w1", StockAttachSide::Right), + ("w2", StockAttachSide::Bottom), + ], + ), + ( + "a flow off its preferred face returns once that face comes free", + false, + &["w"], + &[("w", StockAttachSide::Bottom)], + &[("w", StockAttachSide::Right)], + ), + ( + "a flow off its preferred face, with that face held, stays even on a shared face", + true, + &["w1", "w2"], + &[ + ("w1", StockAttachSide::Bottom), + ("w2", StockAttachSide::Bottom), + ], + &[ + ("w1", StockAttachSide::Bottom), + ("w2", StockAttachSide::Bottom), + ], + ), + ( + "a hand-placed flow on a face outside its direction's preferences stays", + true, + &["w"], + &[("w", StockAttachSide::Left)], + &[("w", StockAttachSide::Left)], + ), + ]; + for &(label, with_chain, side_outflows, existing, expected) in rows { + let mut outflows: Vec<&str> = side_outflows.to_vec(); + let mut flow_stocks: Vec<(&str, Option<&str>, Option<&str>)> = side_outflows + .iter() + .map(|f| (*f, Some("a"), None)) + .collect(); + if with_chain { + outflows.push("chain"); + flow_stocks.push(("chain", Some("a"), Some("b"))); + } + let meta = metadata_with_flows(&[("a", &outflows)], &[], &flow_stocks); + let existing: HashMap = existing + .iter() + .map(|(f, side)| (f.to_string(), *side)) + .collect(); + let sides = classify_flow_sides("a", &meta, &existing); + for &(flow, side) in expected { + assert_eq!(sides[flow].side, side, "{label}: {flow}"); + } + } +} + +// --------------------------------------------------------------------------- +// Layout behavior tests for perpendicular side flows +// --------------------------------------------------------------------------- + +/// Build a model with stock A -> chain_flow -> stock B, plus stock A -> waste_flow -> cloud. +fn chain_with_waste_model() -> datamodel::Model { + datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables: vec![ + datamodel::Variable::Stock(datamodel::Stock { + ident: "a".to_string(), + equation: datamodel::Equation::Scalar("100".to_string()), + documentation: String::new(), + units: None, + inflows: vec![], + outflows: vec!["chain_flow".to_string(), "waste_flow".to_string()], + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Stock(datamodel::Stock { + ident: "b".to_string(), + equation: datamodel::Equation::Scalar("0".to_string()), + documentation: String::new(), + units: None, + inflows: vec!["chain_flow".to_string()], + outflows: vec![], + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "chain_flow".to_string(), + equation: datamodel::Equation::Scalar("10".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "waste_flow".to_string(), + equation: datamodel::Equation::Scalar("5".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + ], + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + } +} + +#[test] +fn test_layout_side_flow_below_stock() { + let project = test_project(chain_with_waste_model()); + let result = generate_layout(&project, TEST_MODEL, None).unwrap(); + + let stock_a = find_stock(&result, "a").expect("stock a should exist"); + let chain_flow = find_flow(&result, "chain_flow").expect("chain_flow should exist"); + let waste_flow = find_flow(&result, "waste_flow").expect("waste_flow should exist"); + + // Chain flow should be horizontal (same y as stock) + assert!( + (chain_flow.y - stock_a.y).abs() < 1.0, + "chain_flow y ({}) should be near stock a y ({})", + chain_flow.y, + stock_a.y, + ); + + // Waste flow should be below stock + assert!( + waste_flow.y > stock_a.y + 10.0, + "waste_flow y ({}) should be well below stock a y ({})", + waste_flow.y, + stock_a.y, + ); + + // Waste flow should have vertical flow points (same x, different y) + assert!( + waste_flow.points.len() >= 2, + "waste_flow should have at least 2 points" + ); + let first = &waste_flow.points[0]; + let last = &waste_flow.points[waste_flow.points.len() - 1]; + assert!( + (first.x - last.x).abs() < 1.0, + "waste_flow points should be vertically aligned: first.x={}, last.x={}", + first.x, + last.x, + ); + assert!( + (first.y - last.y).abs() > 10.0, + "waste_flow points should have vertical separation: first.y={}, last.y={}", + first.y, + last.y, + ); +} + +#[test] +fn test_layout_side_flows_no_overlap() { + let project = test_project(chain_with_waste_model()); + let result = generate_layout(&project, TEST_MODEL, None).unwrap(); + + let chain_flow = find_flow(&result, "chain_flow").expect("chain_flow"); + let waste_flow = find_flow(&result, "waste_flow").expect("waste_flow"); + + // Flows must not overlap: either x or y must differ significantly + let dist = + ((chain_flow.x - waste_flow.x).powi(2) + (chain_flow.y - waste_flow.y).powi(2)).sqrt(); + assert!( + dist > 5.0, + "chain_flow ({}, {}) and waste_flow ({}, {}) should not overlap (dist={})", + chain_flow.x, + chain_flow.y, + waste_flow.x, + waste_flow.y, + dist, + ); +} + +/// Model with chain + 2 waste flows to test spacing. +fn chain_with_two_waste_model() -> datamodel::Model { + datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables: vec![ + datamodel::Variable::Stock(datamodel::Stock { + ident: "a".to_string(), + equation: datamodel::Equation::Scalar("100".to_string()), + documentation: String::new(), + units: None, + inflows: vec![], + outflows: vec![ + "chain_flow".to_string(), + "waste_a".to_string(), + "waste_b".to_string(), + ], + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Stock(datamodel::Stock { + ident: "b".to_string(), + equation: datamodel::Equation::Scalar("0".to_string()), + documentation: String::new(), + units: None, + inflows: vec!["chain_flow".to_string()], + outflows: vec![], + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "chain_flow".to_string(), + equation: datamodel::Equation::Scalar("10".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "waste_a".to_string(), + equation: datamodel::Equation::Scalar("3".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "waste_b".to_string(), + equation: datamodel::Equation::Scalar("2".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + ], + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + } +} + +#[test] +fn test_layout_multiple_side_flows_spaced() { + let project = test_project(chain_with_two_waste_model()); + let result = generate_layout(&project, TEST_MODEL, None).unwrap(); + + let waste_a = find_flow(&result, "waste_a").expect("waste_a"); + let waste_b = find_flow(&result, "waste_b").expect("waste_b"); + let stock_a = find_stock(&result, "a").expect("stock a"); + + // The chain holds the right face, so one waste flow drops out of the + // bottom face and the other out of the top face. + assert!(waste_a.y > stock_a.y, "waste_a should be below stock a"); + assert!(waste_b.y < stock_a.y, "waste_b should be above stock a"); + + let valve_gap = ((waste_a.x - waste_b.x).powi(2) + (waste_a.y - waste_b.y).powi(2)).sqrt(); + assert!( + valve_gap > 2.0 * crate::diagram::constants::AUX_RADIUS, + "waste_a ({}, {}) and waste_b ({}, {}) valves should not overlap", + waste_a.x, + waste_a.y, + waste_b.x, + waste_b.y, + ); +} + +#[test] +fn test_layout_side_flows_take_separate_faces() { + // Every combination of a chain outflow, a chain inflow, and up to three + // side outflows and two side inflows on stock `a`. Whenever there are + // enough free faces for each side flow to have its own -- outflows use + // right/bottom/top, inflows left/top/bottom, a chain's face is never + // shared -- the side flows' valves must not overlap. And no side flow + // ever attaches to a face a chain flow holds. + let config = LayoutConfig::default(); + for with_chain_out in [false, true] { + for with_chain_in in [false, true] { + for n_out in 0..=3 { + for n_in in 0..=2 { + let side_out: Vec = (0..n_out).map(|i| format!("out_{i}")).collect(); + let side_in: Vec = (0..n_in).map(|i| format!("in_{i}")).collect(); + let mut a_out: Vec<&str> = side_out.iter().map(String::as_str).collect(); + let mut a_in: Vec<&str> = side_in.iter().map(String::as_str).collect(); + let mut vars = Vec::new(); + if with_chain_out { + a_out.push("chain_out"); + vars.push(stock_var("b", &["chain_out"], &[])); + vars.push(flow_var("chain_out")); + } + if with_chain_in { + a_in.push("chain_in"); + vars.push(stock_var("c", &[], &["chain_in"])); + vars.push(flow_var("chain_in")); + } + vars.push(stock_var("a", &a_in, &a_out)); + for f in side_out.iter().chain(&side_in) { + vars.push(flow_var(f)); + } + let model = datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables: vars, + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + }; + let row = format!( + "chain_out={with_chain_out} chain_in={with_chain_in} \ + side_out={n_out} side_in={n_in}" + ); + let view = generate_layout(&test_project(model), TEST_MODEL, None) + .unwrap_or_else(|e| panic!("{row}: {e:?}")); + let stock = find_stock(&view, "a").expect("stock a"); + + let face_of = |flow: &view_element::Flow| { + let pt = flow + .points + .iter() + .find(|pt| pt.attached_to_uid == Some(stock.uid)) + .expect("side flow attached to a"); + let (dx, dy) = (pt.x - stock.x, pt.y - stock.y); + if (dx - config.stock_width / 2.0).abs() < 0.5 { + StockAttachSide::Right + } else if (dx + config.stock_width / 2.0).abs() < 0.5 { + StockAttachSide::Left + } else if dy > 0.0 { + StockAttachSide::Bottom + } else { + StockAttachSide::Top + } + }; + let side_flows: Vec<&view_element::Flow> = side_out + .iter() + .chain(&side_in) + .map(|f| find_flow(&view, f).expect("side flow")) + .collect(); + for flow in &side_flows { + let face = face_of(flow); + assert!( + !(with_chain_out && face == StockAttachSide::Right) + && !(with_chain_in && face == StockAttachSide::Left), + "{row}: {} sits on a chain face ({face:?})", + flow.name + ); + } + + let free_faces = 4 - usize::from(with_chain_out) - usize::from(with_chain_in); + let out_faces = 3 - usize::from(with_chain_out); + let in_faces = 3 - usize::from(with_chain_in); + if n_out > out_faces || n_in > in_faces || n_out + n_in > free_faces { + continue; + } + for (i, f1) in side_flows.iter().enumerate() { + for f2 in &side_flows[i + 1..] { + let gap = ((f1.x - f2.x).powi(2) + (f1.y - f2.y).powi(2)).sqrt(); + assert!( + gap > 2.0 * crate::diagram::constants::AUX_RADIUS, + "{row}: valves of {} and {} overlap (gap {gap})", + f1.name, + f2.name + ); + } + } + } + } + } + } +} + +fn stock_var(ident: &str, inflows: &[&str], outflows: &[&str]) -> datamodel::Variable { + datamodel::Variable::Stock(datamodel::Stock { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar("100".to_string()), + documentation: String::new(), + units: None, + inflows: inflows.iter().map(|s| s.to_string()).collect(), + outflows: outflows.iter().map(|s| s.to_string()).collect(), + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }) +} + +fn flow_var(ident: &str) -> datamodel::Variable { + datamodel::Variable::Flow(datamodel::Flow { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar("1".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }) +} + +#[test] +fn test_layout_single_outflow_still_horizontal() { + // Stock with only one outflow to cloud (no chain) -> should go right + let model = datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables: vec![ + datamodel::Variable::Stock(datamodel::Stock { + ident: "a".to_string(), + equation: datamodel::Equation::Scalar("100".to_string()), + documentation: String::new(), + units: None, + inflows: vec![], + outflows: vec!["f1".to_string()], + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "f1".to_string(), + equation: datamodel::Equation::Scalar("10".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), + ], + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + }; + let project = test_project(model); + let result = generate_layout(&project, TEST_MODEL, None).unwrap(); + + let stock_a = find_stock(&result, "a").expect("stock a"); + let flow = find_flow(&result, "f1").expect("flow f1"); + + // Flow should be at same y as stock (horizontal) + assert!( + (flow.y - stock_a.y).abs() < 1.0, + "single outflow should be horizontal: flow.y={}, stock.y={}", + flow.y, + stock_a.y, + ); + + // Flow should be to the right of the stock + assert!( + flow.x > stock_a.x, + "single outflow should be to the right: flow.x={}, stock.x={}", + flow.x, + stock_a.x, + ); +} diff --git a/src/simlin-engine/src/layout/layout_label_tests.rs b/src/simlin-engine/src/layout/layout_label_tests.rs index eaa451c66..e679ffd0c 100644 --- a/src/simlin-engine/src/layout/layout_label_tests.rs +++ b/src/simlin-engine/src/layout/layout_label_tests.rs @@ -438,37 +438,40 @@ fn incremental_layout_preserves_label_side_across_rename() { } } -/// Fixture: stock_a -> chain_flow -> stock_b plus stock_a -> waste_a -> cloud. -/// Adding waste_b moves waste_a along the bottom face (offset 0.5 -> 1/3), -/// which rebuilds waste_a's geometry without changing its orientation. +/// Fixture: stock_a -> chain_flow -> stock_b plus side outflows waste_a (the +/// bottom face) and waste_b (the top face). Adding waste_c, with no free face +/// left, puts it beside waste_a on the bottom face and moves waste_a along +/// that face (offset 0.5 -> 1/3), which rebuilds waste_a's geometry without +/// changing its orientation. fn side_flow_project() -> datamodel::Project { test_project(model_with(vec![ - scalar_stock("stock_a", &[], &["chain_flow", "waste_a"]), + scalar_stock("stock_a", &[], &["chain_flow", "waste_a", "waste_b"]), scalar_stock("stock_b", &["chain_flow"], &[]), scalar_flow("chain_flow", "10"), scalar_flow("waste_a", "3"), + scalar_flow("waste_b", "2"), ])) } -fn add_waste_b(project: &datamodel::Project) -> (datamodel::Project, crate::patch::ModelPatch) { +fn add_waste_c(project: &datamodel::Project) -> (datamodel::Project, crate::patch::ModelPatch) { let mut patched = project.clone(); let model = patched.get_model_mut(TEST_MODEL).unwrap(); for var in &mut model.variables { if let datamodel::Variable::Stock(s) = var && s.ident == "stock_a" { - s.outflows.push("waste_b".to_string()); + s.outflows.push("waste_c".to_string()); } } - let waste_b = scalar_flow("waste_b", "2"); - model.variables.push(waste_b.clone()); - let datamodel::Variable::Flow(waste_b) = waste_b else { + let waste_c = scalar_flow("waste_c", "1"); + model.variables.push(waste_c.clone()); + let datamodel::Variable::Flow(waste_c) = waste_c else { unreachable!() }; let patch = crate::patch::ModelPatch { name: TEST_MODEL.to_string(), ops: vec![ - crate::patch::ModelOperation::UpsertFlow(waste_b), + crate::patch::ModelOperation::UpsertFlow(waste_c), crate::patch::ModelOperation::UpdateStockFlows { ident: "stock_a".to_string(), inflows: vec![], @@ -476,6 +479,7 @@ fn add_waste_b(project: &datamodel::Project) -> (datamodel::Project, crate::patc "chain_flow".to_string(), "waste_a".to_string(), "waste_b".to_string(), + "waste_c".to_string(), ], }, ], @@ -487,7 +491,7 @@ fn add_waste_b(project: &datamodel::Project) -> (datamodel::Project, crate::patc fn rebuilt_flow_with_unchanged_orientation_keeps_label_side() { let project = side_flow_project(); let base_view = generate_layout(&project, TEST_MODEL, None).expect("initial layout"); - let (patched, patch) = add_waste_b(&project); + let (patched, patch) = add_waste_c(&project); let old_waste_a = find_flow(&base_view, "waste_a"); assert!( diff --git a/src/simlin-engine/src/layout/layout_review_tests.rs b/src/simlin-engine/src/layout/layout_review_tests.rs index 74ee0626a..d0ffd4d14 100644 --- a/src/simlin-engine/src/layout/layout_review_tests.rs +++ b/src/simlin-engine/src/layout/layout_review_tests.rs @@ -1930,10 +1930,11 @@ fn test_incremental_new_side_flow_valve_on_pipe() { // --------------------------------------------------------------------------- #[test] -fn test_incremental_add_second_side_flow_redistributes_offsets() { +fn test_incremental_add_second_side_flow_takes_its_own_face() { // Start with: stock_a -> chain_flow -> stock_b, stock_a -> waste_a -> cloud - // Then add waste_b. Both waste_a and waste_b should have distinct offsets - // on the bottom face (1/3 and 2/3, not both at 0.5). + // Then add waste_b. waste_a keeps its bottom face untouched; waste_b takes + // the free top face instead of squeezing onto the bottom face beside it, + // where the two valves would overlap. let initial_model = datamodel::Model { name: TEST_MODEL.to_string(), sim_specs: None, @@ -2093,25 +2094,26 @@ fn test_incremental_add_second_side_flow_redistributes_offsets() { }) .expect("waste_b in new view"); - // Both flows should have distinct x-positions (different offsets on bottom face) - let attach_a_x = new_waste_a.points[0].x; - let attach_b_x = new_waste_b.points[0].x; - assert!( - (attach_a_x - attach_b_x).abs() > 1.0, - "waste_a attach x ({}) and waste_b attach x ({}) should differ after redistribution", - attach_a_x, - attach_b_x, + // waste_a is untouched: same pipe, same valve. + assert_eq!( + new_waste_a.points, old_waste_a.points, + "waste_a must keep its pipe when a sibling is added" ); + assert!((new_waste_a.x - old_waste_a.x).abs() < 1e-9); + assert!((new_waste_a.y - old_waste_a.y).abs() < 1e-9); - // waste_a (alphabetically first) should be at offset 1/3, not 0.5 anymore. - // With stock_width=45, the expected attach for 1/3 is stock_x - 22.5 + 45 * 1/3 = stock_x - 7.5 - let config = LayoutConfig::default(); - let expected_a_x = new_stock_a.x - config.stock_width / 2.0 + config.stock_width * (1.0 / 3.0); + // waste_b leaves from the top face, on the opposite side of the stock. assert!( - (attach_a_x - expected_a_x).abs() < 1.0, - "waste_a should be at 1/3 offset ({}) but attach x is ({})", - expected_a_x, - attach_a_x, + new_waste_b.y < new_stock_a.y - 5.0, + "waste_b valve y ({}) should be above stock_a y ({})", + new_waste_b.y, + new_stock_a.y, + ); + let valve_gap = + ((new_waste_a.x - new_waste_b.x).powi(2) + (new_waste_a.y - new_waste_b.y).powi(2)).sqrt(); + assert!( + valve_gap > 2.0 * crate::diagram::constants::AUX_RADIUS, + "the two valves must not overlap: {valve_gap}" ); } @@ -2550,11 +2552,12 @@ fn test_incremental_chain_flow_seeded_between_stocks() { #[test] fn test_incremental_redistribute_preserves_visual_order() { - // Construct a view where waste_b is visually LEFT of waste_a on the - // bottom face (non-alphabetical order). Adding waste_c should not - // swap waste_a and waste_b. + // Three side outflows beside a chain flow: waste_a and waste_c share the + // bottom face (waste_b holds the top). Construct a view where waste_c is + // visually LEFT of waste_a (non-alphabetical order). Adding waste_d, which + // reclassifies every flow on the stock, must not swap waste_a and waste_c. - // First, build an initial model with chain + waste_a + waste_b + // First, build an initial model with chain + waste_a + waste_b + waste_c let initial_model = datamodel::Model { name: TEST_MODEL.to_string(), sim_specs: None, @@ -2569,6 +2572,7 @@ fn test_incremental_redistribute_preserves_visual_order() { "chain_flow".to_string(), "waste_a".to_string(), "waste_b".to_string(), + "waste_c".to_string(), ], compat: datamodel::Compat::default(), ai_state: None, @@ -2615,6 +2619,16 @@ fn test_incremental_redistribute_preserves_visual_order() { ai_state: None, uid: None, }), + datamodel::Variable::Flow(datamodel::Flow { + ident: "waste_c".to_string(), + equation: datamodel::Equation::Scalar("1".to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }), ], views: Vec::new(), loop_metadata: Vec::new(), @@ -2624,8 +2638,8 @@ fn test_incremental_redistribute_preserves_visual_order() { let initial_project = test_project(initial_model); let base_view = generate_layout(&initial_project, TEST_MODEL, None).expect("base layout"); - // Manually swap waste_a and waste_b attachment points to create - // non-alphabetical positional ordering (waste_b to the left). + // Manually swap waste_a and waste_c attachment points to create + // non-alphabetical positional ordering (waste_c to the left). let mut swapped_view = base_view.clone(); let wa_attach_x = swapped_view .elements @@ -2637,30 +2651,30 @@ fn test_incremental_redistribute_preserves_visual_order() { _ => None, }) .expect("waste_a attach x"); - let wb_attach_x = swapped_view + let wc_attach_x = swapped_view .elements .iter() .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_b" => { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_c" => { f.points.first().map(|p| p.x) } _ => None, }) - .expect("waste_b attach x"); + .expect("waste_c attach x"); // Swap the x positions of waste_a and waste_b (valve and pipe points) for elem in &mut swapped_view.elements { match elem { ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_a" => { - f.x += wb_attach_x - wa_attach_x; + f.x += wc_attach_x - wa_attach_x; for pt in &mut f.points { - pt.x += wb_attach_x - wa_attach_x; + pt.x += wc_attach_x - wa_attach_x; } } - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_b" => { - f.x += wa_attach_x - wb_attach_x; + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_c" => { + f.x += wa_attach_x - wc_attach_x; for pt in &mut f.points { - pt.x += wa_attach_x - wb_attach_x; + pt.x += wa_attach_x - wc_attach_x; } } _ => {} @@ -2678,37 +2692,37 @@ fn test_incremental_redistribute_preserves_visual_order() { _ => None, }) .unwrap(); - let swapped_wb_x = swapped_view + let swapped_wc_x = swapped_view .elements .iter() .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_b" => { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_c" => { f.points.first().map(|p| p.x) } _ => None, }) .unwrap(); assert!( - swapped_wb_x < swapped_wa_x, - "precondition: waste_b ({}) should be left of waste_a ({})", - swapped_wb_x, + swapped_wc_x < swapped_wa_x, + "precondition: waste_c ({}) should be left of waste_a ({})", + swapped_wc_x, swapped_wa_x, ); - // Now add waste_c + // Now add waste_d let mut patched_project = initial_project.clone(); let model = patched_project.get_model_mut(TEST_MODEL).unwrap(); for var in &mut model.variables { if let datamodel::Variable::Stock(s) = var && s.ident == "stock_a" { - s.outflows.push("waste_c".to_string()); + s.outflows.push("waste_d".to_string()); } } model .variables .push(datamodel::Variable::Flow(datamodel::Flow { - ident: "waste_c".to_string(), + ident: "waste_d".to_string(), equation: datamodel::Equation::Scalar("1".to_string()), documentation: String::new(), units: None, @@ -2722,7 +2736,7 @@ fn test_incremental_redistribute_preserves_visual_order() { name: TEST_MODEL.to_string(), ops: vec![ crate::patch::ModelOperation::UpsertFlow(datamodel::Flow { - ident: "waste_c".to_string(), + ident: "waste_d".to_string(), equation: datamodel::Equation::Scalar("1".to_string()), documentation: String::new(), units: None, @@ -2739,6 +2753,7 @@ fn test_incremental_redistribute_preserves_visual_order() { "waste_a".to_string(), "waste_b".to_string(), "waste_c".to_string(), + "waste_d".to_string(), ], }, ], @@ -2747,7 +2762,7 @@ fn test_incremental_redistribute_preserves_visual_order() { let new_view = incremental_layout(&swapped_view, &patched_project, TEST_MODEL, &patch, None) .expect("incremental layout"); - // After redistribution, waste_b should still be to the left of waste_a + // After redistribution, waste_c should still be to the left of waste_a let new_wa_x = new_view .elements .iter() @@ -2758,21 +2773,21 @@ fn test_incremental_redistribute_preserves_visual_order() { _ => None, }) .expect("waste_a in new view"); - let new_wb_x = new_view + let new_wc_x = new_view .elements .iter() .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_b" => { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_c" => { f.points.first().map(|p| p.x) } _ => None, }) - .expect("waste_b in new view"); + .expect("waste_c in new view"); assert!( - new_wb_x < new_wa_x, - "waste_b ({}) should remain left of waste_a ({}) after adding waste_c", - new_wb_x, + new_wc_x < new_wa_x, + "waste_c ({}) should remain left of waste_a ({}) after adding waste_d", + new_wc_x, new_wa_x, ); } @@ -2904,8 +2919,9 @@ fn test_incremental_delete_flow_without_update_stock_flows() { #[test] fn test_layout_two_horizontal_cloud_outflows_spaced() { - // Stock with 2 cloud outflows and no chain flow -- both go Right - // but should be at different y positions. + // Stock with 2 cloud outflows and no chain flow: the first leaves the + // right face, the second drops out of the bottom face, so the valves + // do not collide. let model = datamodel::Model { name: TEST_MODEL.to_string(), sim_specs: None, @@ -2953,15 +2969,19 @@ fn test_layout_two_horizontal_cloud_outflows_spaced() { let f1 = find_flow(&result, "f1").expect("f1"); let f2 = find_flow(&result, "f2").expect("f2"); - // Both should be to the right of the stock (horizontal) let stock = find_stock(&result, "a").expect("stock a"); - assert!(f1.x > stock.x, "f1 should be right of stock"); - assert!(f2.x > stock.x, "f2 should be right of stock"); + assert!( + f1.x > stock.x + 5.0 && (f1.y - stock.y).abs() < 1.0, + "f1 should leave the right face" + ); + assert!( + f2.y > stock.y + 5.0 && (f2.x - stock.x).abs() < 1.0, + "f2 should leave the bottom face" + ); - // Their y-positions should differ (distributed along right edge) let dist = ((f1.x - f2.x).powi(2) + (f1.y - f2.y).powi(2)).sqrt(); assert!( - dist > 1.0, + dist > 2.0 * crate::diagram::constants::AUX_RADIUS, "f1 ({}, {}) and f2 ({}, {}) should not overlap (dist={})", f1.x, f1.y, diff --git a/src/simlin-engine/src/layout/layout_selection_tests.rs b/src/simlin-engine/src/layout/layout_selection_tests.rs index 85af86424..e27aaf92d 100644 --- a/src/simlin-engine/src/layout/layout_selection_tests.rs +++ b/src/simlin-engine/src/layout/layout_selection_tests.rs @@ -303,15 +303,14 @@ fn test_select_best_layout_all_nan_keeps_earliest() { // Lowering a ceiling that no longer matches reality is fine; raising one to // paper over a real regression is not. // -// Observed at seed 42 (2026-05-31), after the quiescence work and with the -// sprawl compactness counterweight (0.1) in MetricWeights::default(): -// pop = 0.2023, chain = 0.4959, two_stock = 0.1373. (Each cost now includes -// ~0.1-0.15 of sprawl-times-weight; the readability terms themselves are near -// zero on these tiny models.) The regeneration procedure printed these via the -// GUARD_REGEN lines this test emits. -const GUARD_POP_COST_CEILING: f64 = 0.32; -const GUARD_CHAIN_COST_CEILING: f64 = 0.17; -const GUARD_TWO_STOCK_COST_CEILING: f64 = 0.25; +// Observed at seed 42 with the rate-based metric and its calibrated weights: +// pop = 0.5880, chain = 0.4498, two_stock = 0.4636, printed by the GUARD_REGEN +// lines this test emits. Most of each cost is the gentle spacing and alignment +// terms (sprawl, crowding, misalignment); the illegibility terms are near zero +// on these tiny models. +const GUARD_POP_COST_CEILING: f64 = 0.68; +const GUARD_CHAIN_COST_CEILING: f64 = 0.52; +const GUARD_TWO_STOCK_COST_CEILING: f64 = 0.54; /// Lay `project`'s `main` model out at the fixed seed 42 and return its /// calibrated `weighted_cost`. Seeding explicitly (rather than relying on the diff --git a/src/simlin-engine/src/layout/layout_tests.rs b/src/simlin-engine/src/layout/layout_tests.rs index 7e537cba4..780256161 100644 --- a/src/simlin-engine/src/layout/layout_tests.rs +++ b/src/simlin-engine/src/layout/layout_tests.rs @@ -298,6 +298,167 @@ fn test_compute_metadata_dep_graph() { assert!(births_deps.contains("birth_rate")); } +#[test] +fn test_compute_metadata_reads_through_builtin_calls_and_tables() { + // A variable's reads reach rendered variables by more routes than its + // equation's direct heads, and a diagram must draw every one: the parse + // turns a builtin module call (SMTH1, DELAY3, TREND, ...) into a + // synthesized instance whose inputs are the call's arguments, hoists a + // non-identifier argument into a helper, captures an expression under + // PREVIOUS, and records a LOOKUP table as a referenced table rather than a + // read. One row per route. + let table = datamodel::Variable::Aux(datamodel::Aux { + ident: "table".to_string(), + equation: datamodel::Equation::Scalar(String::new()), + documentation: String::new(), + units: None, + gf: Some(datamodel::GraphicalFunction { + kind: datamodel::GraphicalFunctionKind::Continuous, + x_points: Some(vec![0.0, 1.0, 2.0]), + y_points: vec![4.0, 2.0, 0.0], + x_scale: datamodel::GraphicalFunctionScale { min: 0.0, max: 2.0 }, + y_scale: datamodel::GraphicalFunctionScale { min: 0.0, max: 4.0 }, + }), + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + }); + let aux = |ident: &str, equation: &str| { + datamodel::Variable::Aux(datamodel::Aux { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + }) + }; + let rows: &[(&str, &str, &[&str])] = &[ + ("direct reads", "a + b", &["a", "b"]), + ("builtin module call", "SMTH1(a, b)", &["a", "b"]), + ( + "hoisted module-call argument", + "SMTH1(a * 2, b)", + &["a", "b"], + ), + ("captured PREVIOUS argument", "PREVIOUS(a + b)", &["a", "b"]), + ("lookup table", "LOOKUP(table, a)", &["a", "table"]), + ( + "lookup table inside a module call", + "SMTH1(LOOKUP(table, a), b)", + &["a", "b", "table"], + ), + ]; + for &(route, equation, expected) in rows { + let model = datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables: vec![ + aux("a", "1"), + aux("b", "2"), + table.clone(), + aux("out", equation), + ], + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + }; + let metadata = compute_metadata(&test_project(model), TEST_MODEL, None).unwrap(); + let deps: Vec<&str> = metadata.dep_graph["out"] + .iter() + .map(String::as_str) + .collect(); + assert_eq!(deps, expected, "{route}: `out = {equation}`"); + for dep in expected { + assert!( + metadata.reverse_dep_graph[*dep].contains("out"), + "{route}: {dep} must record out as a dependent" + ); + } + assert!(!metadata.is_constant("out"), "{route}: out reads variables"); + } +} + +#[test] +fn test_generate_layout_links_builtin_call_inputs_and_tables() { + // The drawn counterpart of the metadata rows above: each name typed into + // a builtin call or a LOOKUP gets its arrow, so none of them is parked as + // an isolated variable. + let table = datamodel::Variable::Aux(datamodel::Aux { + ident: "effect_table".to_string(), + equation: datamodel::Equation::Scalar(String::new()), + documentation: String::new(), + units: None, + gf: Some(datamodel::GraphicalFunction { + kind: datamodel::GraphicalFunctionKind::Continuous, + x_points: Some(vec![0.0, 1.0, 2.0]), + y_points: vec![4.0, 2.0, 0.0], + x_scale: datamodel::GraphicalFunctionScale { min: 0.0, max: 2.0 }, + y_scale: datamodel::GraphicalFunctionScale { min: 0.0, max: 4.0 }, + }), + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + }); + let aux = |ident: &str, equation: &str| { + datamodel::Variable::Aux(datamodel::Aux { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + }) + }; + let model = datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables: vec![ + aux("ratio", "1"), + aux("smoothing_time", "3"), + table, + aux("effect", "LOOKUP(effect_table, ratio)"), + aux("perceived_effect", "SMTH1(effect, smoothing_time)"), + ], + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + }; + let view = generate_layout(&test_project(model), TEST_MODEL, None).unwrap(); + let uid_of = |name: &str| { + view.elements + .iter() + .find(|e| e.get_name().is_some_and(|n| canonicalize(n) == name)) + .map(ViewElement::get_uid) + .unwrap_or_else(|| panic!("{name} is drawn")) + }; + let links: BTreeSet<(i32, i32)> = view + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Link(l) => Some((l.from_uid, l.to_uid)), + _ => None, + }) + .collect(); + for (from, to) in [ + ("effect_table", "effect"), + ("ratio", "effect"), + ("effect", "perceived_effect"), + ("smoothing_time", "perceived_effect"), + ] { + assert!( + links.contains(&(uid_of(from), uid_of(to))), + "missing link {from} -> {to}" + ); + } +} + #[test] fn test_compute_metadata_constants() { let project = test_project(simple_model()); @@ -5437,223 +5598,6 @@ fn test_module_output_dep_preserved_with_db_state() { ); } -// --------------------------------------------------------------------------- -// classify_flow_sides tests -// --------------------------------------------------------------------------- - -/// Helper: build a ComputedMetadata with the given stock/flow topology. -fn metadata_with_flows( - stock_outflows: &[(&str, &[&str])], - stock_inflows: &[(&str, &[&str])], - flow_to_stocks: &[(&str, Option<&str>, Option<&str>)], -) -> ComputedMetadata { - let mut meta = ComputedMetadata::new_empty(); - for &(stock, outflows) in stock_outflows { - meta.stock_to_outflows.insert( - stock.to_string(), - outflows.iter().map(|s| s.to_string()).collect(), - ); - } - for &(stock, inflows) in stock_inflows { - meta.stock_to_inflows.insert( - stock.to_string(), - inflows.iter().map(|s| s.to_string()).collect(), - ); - } - for &(flow, from, to) in flow_to_stocks { - meta.flow_to_stocks.insert( - flow.to_string(), - (from.map(|s| s.to_string()), to.map(|s| s.to_string())), - ); - } - meta -} - -#[test] -fn test_classify_sides_single_outflow_no_chain() { - // Stock with one outflow to cloud (no chain flow) -> stays Right - let meta = metadata_with_flows(&[("a", &["f1"])], &[], &[("f1", Some("a"), None)]); - let sides = classify_flow_sides("a", &meta); - let att = sides.get("f1").expect("f1 should have attachment"); - assert_eq!(att.side, StockAttachSide::Right); - assert!((att.offset - 0.5).abs() < f64::EPSILON); -} - -#[test] -fn test_classify_sides_chain_plus_side_outflow() { - // Stock with chain outflow (a->b) + side outflow (a->cloud) - let meta = metadata_with_flows( - &[("a", &["chain_flow", "waste_flow"])], - &[], - &[ - ("chain_flow", Some("a"), Some("b")), - ("waste_flow", Some("a"), None), - ], - ); - let sides = classify_flow_sides("a", &meta); - - let chain = sides.get("chain_flow").expect("chain_flow attachment"); - assert_eq!(chain.side, StockAttachSide::Right); - assert!((chain.offset - 0.5).abs() < f64::EPSILON); - - let waste = sides.get("waste_flow").expect("waste_flow attachment"); - assert_eq!(waste.side, StockAttachSide::Bottom); - assert!((waste.offset - 0.5).abs() < f64::EPSILON); -} - -#[test] -fn test_classify_sides_chain_plus_two_side_outflows() { - // Chain + two side outflows -> side outflows on Bottom at 1/3, 2/3 - let meta = metadata_with_flows( - &[("a", &["chain_flow", "waste_a", "waste_b"])], - &[], - &[ - ("chain_flow", Some("a"), Some("b")), - ("waste_a", Some("a"), None), - ("waste_b", Some("a"), None), - ], - ); - let sides = classify_flow_sides("a", &meta); - - let chain = sides.get("chain_flow").expect("chain_flow"); - assert_eq!(chain.side, StockAttachSide::Right); - - // waste_a and waste_b sorted alphabetically -> waste_a first - let wa = sides.get("waste_a").expect("waste_a"); - let wb = sides.get("waste_b").expect("waste_b"); - assert_eq!(wa.side, StockAttachSide::Bottom); - assert_eq!(wb.side, StockAttachSide::Bottom); - assert!((wa.offset - 1.0 / 3.0).abs() < 1e-10); - assert!((wb.offset - 2.0 / 3.0).abs() < 1e-10); -} - -#[test] -fn test_classify_sides_chain_inflow_plus_side_inflow() { - // Stock with chain inflow (from stock) + side inflow (from cloud) - let meta = metadata_with_flows( - &[], - &[("b", &["chain_in", "side_in"])], - &[ - ("chain_in", Some("a"), Some("b")), - ("side_in", None, Some("b")), - ], - ); - let sides = classify_flow_sides("b", &meta); - - let chain = sides.get("chain_in").expect("chain_in"); - assert_eq!(chain.side, StockAttachSide::Left); - assert!((chain.offset - 0.5).abs() < f64::EPSILON); - - let side = sides.get("side_in").expect("side_in"); - assert_eq!(side.side, StockAttachSide::Top); - assert!((side.offset - 0.5).abs() < f64::EPSILON); -} - -#[test] -fn test_classify_sides_only_nonchain_outflows() { - // Stock with only non-chain outflows -> all Right with (i+1)/(n+1) - let meta = metadata_with_flows( - &[("a", &["f1", "f2", "f3"])], - &[], - &[ - ("f1", Some("a"), None), - ("f2", Some("a"), None), - ("f3", Some("a"), None), - ], - ); - let sides = classify_flow_sides("a", &meta); - - // All on Right, sorted alphabetically: f1, f2, f3 - for name in &["f1", "f2", "f3"] { - assert_eq!( - sides.get(*name).unwrap().side, - StockAttachSide::Right, - "{name} should be Right" - ); - } - assert!((sides["f1"].offset - 1.0 / 4.0).abs() < 1e-10); - assert!((sides["f2"].offset - 2.0 / 4.0).abs() < 1e-10); - assert!((sides["f3"].offset - 3.0 / 4.0).abs() < 1e-10); -} - -#[test] -fn test_classify_sides_multiple_chain_outflows() { - // Stock with two chain outflows (feeds two stocks) -> both Right, 1/3 and 2/3 - let meta = metadata_with_flows( - &[("a", &["f1", "f2"])], - &[], - &[("f1", Some("a"), Some("b")), ("f2", Some("a"), Some("c"))], - ); - let sides = classify_flow_sides("a", &meta); - - let a1 = sides.get("f1").expect("f1"); - let a2 = sides.get("f2").expect("f2"); - assert_eq!(a1.side, StockAttachSide::Right); - assert_eq!(a2.side, StockAttachSide::Right); - assert!((a1.offset - 1.0 / 3.0).abs() < 1e-10); - assert!((a2.offset - 2.0 / 3.0).abs() < 1e-10); -} - -// --------------------------------------------------------------------------- -// Layout behavior tests for perpendicular side flows -// --------------------------------------------------------------------------- - -/// Build a model with stock A -> chain_flow -> stock B, plus stock A -> waste_flow -> cloud. -fn chain_with_waste_model() -> datamodel::Model { - datamodel::Model { - name: TEST_MODEL.to_string(), - sim_specs: None, - variables: vec![ - datamodel::Variable::Stock(datamodel::Stock { - ident: "a".to_string(), - equation: datamodel::Equation::Scalar("100".to_string()), - documentation: String::new(), - units: None, - inflows: vec![], - outflows: vec!["chain_flow".to_string(), "waste_flow".to_string()], - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Stock(datamodel::Stock { - ident: "b".to_string(), - equation: datamodel::Equation::Scalar("0".to_string()), - documentation: String::new(), - units: None, - inflows: vec!["chain_flow".to_string()], - outflows: vec![], - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Flow(datamodel::Flow { - ident: "chain_flow".to_string(), - equation: datamodel::Equation::Scalar("10".to_string()), - documentation: String::new(), - units: None, - gf: None, - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Flow(datamodel::Flow { - ident: "waste_flow".to_string(), - equation: datamodel::Equation::Scalar("5".to_string()), - documentation: String::new(), - units: None, - gf: None, - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - ], - views: Vec::new(), - loop_metadata: Vec::new(), - groups: Vec::new(), - macro_spec: None, - } -} - /// Find a stock view element by canonical name. fn find_stock<'a>(view: &'a datamodel::StockFlow, name: &str) -> Option<&'a view_element::Stock> { view.elements.iter().find_map(|e| { @@ -5680,225 +5624,11 @@ fn find_flow<'a>(view: &'a datamodel::StockFlow, name: &str) -> Option<&'a view_ }) } -#[test] -fn test_layout_side_flow_below_stock() { - let project = test_project(chain_with_waste_model()); - let result = generate_layout(&project, TEST_MODEL, None).unwrap(); - - let stock_a = find_stock(&result, "a").expect("stock a should exist"); - let chain_flow = find_flow(&result, "chain_flow").expect("chain_flow should exist"); - let waste_flow = find_flow(&result, "waste_flow").expect("waste_flow should exist"); - - // Chain flow should be horizontal (same y as stock) - assert!( - (chain_flow.y - stock_a.y).abs() < 1.0, - "chain_flow y ({}) should be near stock a y ({})", - chain_flow.y, - stock_a.y, - ); - - // Waste flow should be below stock - assert!( - waste_flow.y > stock_a.y + 10.0, - "waste_flow y ({}) should be well below stock a y ({})", - waste_flow.y, - stock_a.y, - ); - - // Waste flow should have vertical flow points (same x, different y) - assert!( - waste_flow.points.len() >= 2, - "waste_flow should have at least 2 points" - ); - let first = &waste_flow.points[0]; - let last = &waste_flow.points[waste_flow.points.len() - 1]; - assert!( - (first.x - last.x).abs() < 1.0, - "waste_flow points should be vertically aligned: first.x={}, last.x={}", - first.x, - last.x, - ); - assert!( - (first.y - last.y).abs() > 10.0, - "waste_flow points should have vertical separation: first.y={}, last.y={}", - first.y, - last.y, - ); -} - -#[test] -fn test_layout_side_flows_no_overlap() { - let project = test_project(chain_with_waste_model()); - let result = generate_layout(&project, TEST_MODEL, None).unwrap(); - - let chain_flow = find_flow(&result, "chain_flow").expect("chain_flow"); - let waste_flow = find_flow(&result, "waste_flow").expect("waste_flow"); - - // Flows must not overlap: either x or y must differ significantly - let dist = - ((chain_flow.x - waste_flow.x).powi(2) + (chain_flow.y - waste_flow.y).powi(2)).sqrt(); - assert!( - dist > 5.0, - "chain_flow ({}, {}) and waste_flow ({}, {}) should not overlap (dist={})", - chain_flow.x, - chain_flow.y, - waste_flow.x, - waste_flow.y, - dist, - ); -} - -/// Model with chain + 2 waste flows to test spacing. -fn chain_with_two_waste_model() -> datamodel::Model { - datamodel::Model { - name: TEST_MODEL.to_string(), - sim_specs: None, - variables: vec![ - datamodel::Variable::Stock(datamodel::Stock { - ident: "a".to_string(), - equation: datamodel::Equation::Scalar("100".to_string()), - documentation: String::new(), - units: None, - inflows: vec![], - outflows: vec![ - "chain_flow".to_string(), - "waste_a".to_string(), - "waste_b".to_string(), - ], - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Stock(datamodel::Stock { - ident: "b".to_string(), - equation: datamodel::Equation::Scalar("0".to_string()), - documentation: String::new(), - units: None, - inflows: vec!["chain_flow".to_string()], - outflows: vec![], - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Flow(datamodel::Flow { - ident: "chain_flow".to_string(), - equation: datamodel::Equation::Scalar("10".to_string()), - documentation: String::new(), - units: None, - gf: None, - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Flow(datamodel::Flow { - ident: "waste_a".to_string(), - equation: datamodel::Equation::Scalar("3".to_string()), - documentation: String::new(), - units: None, - gf: None, - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Flow(datamodel::Flow { - ident: "waste_b".to_string(), - equation: datamodel::Equation::Scalar("2".to_string()), - documentation: String::new(), - units: None, - gf: None, - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - ], - views: Vec::new(), - loop_metadata: Vec::new(), - groups: Vec::new(), - macro_spec: None, - } -} - -#[test] -fn test_layout_multiple_side_flows_spaced() { - let project = test_project(chain_with_two_waste_model()); - let result = generate_layout(&project, TEST_MODEL, None).unwrap(); - - let waste_a = find_flow(&result, "waste_a").expect("waste_a"); - let waste_b = find_flow(&result, "waste_b").expect("waste_b"); - let stock_a = find_stock(&result, "a").expect("stock a"); - - // Both should be below the stock - assert!(waste_a.y > stock_a.y, "waste_a should be below stock a"); - assert!(waste_b.y > stock_a.y, "waste_b should be below stock a"); - - // Their x-coordinates should differ (spaced along bottom edge) - assert!( - (waste_a.x - waste_b.x).abs() > 1.0, - "waste_a.x ({}) and waste_b.x ({}) should differ for spacing", - waste_a.x, - waste_b.x, - ); -} - -#[test] -fn test_layout_single_outflow_still_horizontal() { - // Stock with only one outflow to cloud (no chain) -> should go right - let model = datamodel::Model { - name: TEST_MODEL.to_string(), - sim_specs: None, - variables: vec![ - datamodel::Variable::Stock(datamodel::Stock { - ident: "a".to_string(), - equation: datamodel::Equation::Scalar("100".to_string()), - documentation: String::new(), - units: None, - inflows: vec![], - outflows: vec!["f1".to_string()], - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - datamodel::Variable::Flow(datamodel::Flow { - ident: "f1".to_string(), - equation: datamodel::Equation::Scalar("10".to_string()), - documentation: String::new(), - units: None, - gf: None, - compat: datamodel::Compat::default(), - ai_state: None, - uid: None, - }), - ], - views: Vec::new(), - loop_metadata: Vec::new(), - groups: Vec::new(), - macro_spec: None, - }; - let project = test_project(model); - let result = generate_layout(&project, TEST_MODEL, None).unwrap(); - - let stock_a = find_stock(&result, "a").expect("stock a"); - let flow = find_flow(&result, "f1").expect("flow f1"); - - // Flow should be at same y as stock (horizontal) - assert!( - (flow.y - stock_a.y).abs() < 1.0, - "single outflow should be horizontal: flow.y={}, stock.y={}", - flow.y, - stock_a.y, - ); - - // Flow should be to the right of the stock - assert!( - flow.x > stock_a.x, - "single outflow should be to the right: flow.x={}, stock.x={}", - flow.x, - stock_a.x, - ); -} - #[path = "layout_review_tests.rs"] mod review_tests; #[path = "layout_label_tests.rs"] mod label_tests; + +#[path = "layout_flow_side_tests.rs"] +mod flow_side_tests; diff --git a/src/simlin-engine/src/layout/metrics.rs b/src/simlin-engine/src/layout/metrics.rs index ecd5d25e6..597404c8c 100644 --- a/src/simlin-engine/src/layout/metrics.rs +++ b/src/simlin-engine/src/layout/metrics.rs @@ -4,18 +4,26 @@ // pattern: Functional Core // -// The layout quality core. Every term here is computed purely from a -// `datamodel::StockFlow` (and the `LayoutConfig` parameter, kept for -// forward-compatibility with the design's optimizer signature). All geometry -// comes from the same `diagram` helpers the SVG renderer uses and from -// `layout::build_view_segments`, so a layout's quality score can never disagree -// with the geometry the renderer draws or with `count_view_crossings`. +// The layout quality core. Every term is computed purely from a +// `datamodel::StockFlow`, over the SCENE the renderer draws: node shapes at +// their drawn size (a flow valve is the 9px circle `render_flow` draws, not +// its smaller bounds box), flow pipes as 4px-thick segments, connectors as the +// exact polylines `diagram::connector` draws, and labels at the boxes +// `diagram::label` measures. A layout's score therefore can never disagree +// with what the picture shows. +// +// Every defect term is a RATE -- a mean over the elements, labels, or +// connectors it concerns -- so a model's cost does not grow with its size, 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. `analyze_layout` additionally reports each defect's location, so the +// eval harness can draw what the metric sees over the rendered diagram. // // There is NO I/O in this module: it takes data, computes scalars, returns -// them. That makes every term trivially testable with hand-computed expected -// values (see the inline tests below). +// them. That makes every term testable with hand-computed expected values (see +// `metrics_tests.rs`). -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::datamodel::{self, ViewElement}; use crate::diagram::common::{ @@ -24,109 +32,154 @@ use crate::diagram::common::{ }; use crate::diagram::connector::{ARC_POLYLINE_SAMPLES, connector_polyline, get_visual_center}; use crate::diagram::elements::{ - aux_bounds, aux_shape_bounds, cloud_bounds, module_bounds, stock_bounds, stock_shape_bounds, + aux_shape_bounds, cloud_bounds, module_shape_bounds, stock_shape_bounds, }; -use crate::diagram::flow::{flow_bounds, flow_shape_bounds}; use crate::diagram::label::{LabelProps, label_bounds}; -use super::annealing::count_crossings; +use super::annealing::segment_intersection; use super::build_view_segments; use super::config::LayoutConfig; /// Upper bound of the target aspect-ratio band. A view whose bounding-box /// aspect ratio (long side / short side, always >= 1) is at or below this value -/// is "well-proportioned" and incurs no `aspect_penalty`. 16:9 is a generous -/// band that comfortably contains the conventional 4:3 diagram proportions -/// while still penalizing pathologically thin (e.g. 1x10) layouts. +/// is "well-proportioned" and incurs no `aspect_penalty`. pub const TARGET_AR_MAX: f64 = 16.0 / 9.0; +/// Half the drawn width of a flow pipe: the renderer strokes the pipe's outer +/// path 4px wide. +pub(crate) const PIPE_HALF_WIDTH: f64 = 2.0; + +/// The gap between two element footprints (shape or label boxes) below which +/// they read as jammed together: enough air that two labels, or a label and a +/// neighbor's shape, read as separate marks. Hand-drawn diagrams routinely +/// leave less than a text line between neighbors, so the threshold sits well +/// under one line's height and the deficit is squared, charging marks that +/// nearly touch far more than ones that are merely snug. +pub(crate) const COMFORTABLE_CLEARANCE: f64 = 8.0; + +/// A link whose drawn length outside its two endpoint shapes is below this +/// cannot show its arrowhead's direction and reads as the nodes touching. +const MIN_VISIBLE_LINK: f64 = 20.0; + +/// A link longer than this many times the view's median link length reads as +/// a line across the diagram rather than a local connection. +const LONG_CONNECTOR_FACTOR: f64 = 3.0; + +/// How far inside a label box a connector must pass to be charged as crossing +/// the text: `label_bounds` pads the text horizontally, and a line grazing +/// that padding does not obscure anything. +pub(crate) const LABEL_INSET: f64 = 2.0; + +/// How much of a line through a name counts when the line is the name's own +/// node's link (see `label_connector_overlap`). +pub(crate) const OWN_LINK_STRIKE_FACTOR: f64 = 0.5; + +/// Two node centers within this distance on one axis share a row or column. +const ALIGN_TOLERANCE: f64 = 3.0; + +/// How far apart two nodes may be and still count as aligned with each other: +/// alignment is a local reading aid, not a property of distant nodes that +/// happen to share a coordinate. +const ALIGN_REACH: f64 = 300.0; + /// One quality cost per aesthetic concern, with `0.0` always meaning "ideal". /// -/// Most terms are scale-free by construction (ratios of like quantities), so -/// they are comparable across models of different absolute coordinate scale. -/// Three terms are *intentionally* sensitive to the absolute coordinate scale -/// relative to the universal fixed node-box size (`node_overlap`, -/// `label_overlap`, `sprawl`): a model whose nodes are packed tightly against -/// the fixed pixel size of a stock/aux box should score differently from one -/// spread far apart, and that sensitivity is what makes those terms meaningful -/// across models. See the AC1.8 scoping note in the Phase 1 plan. +/// The defect terms are rates (means over the things they concern), so they +/// are comparable across models of different size. Several terms are +/// intentionally sensitive to absolute coordinate scale relative to the fixed +/// pixel size of shapes and labels (`node_overlap`, `label_overlap`, +/// `crowding`, `sprawl`): packing nodes tightly against those fixed sizes is +/// exactly what they measure. /// /// `Serialize`/`Deserialize` let the layout-quality eval sweep -/// (`examples/layout_eval.rs`) emit the per-term breakdown into its -/// `metrics.json` artifact and round-trip the committed baseline report back -/// from JSON for the baseline diff; the struct is pure data (every field a -/// plain `f64`), so the derives carry no behavior. -#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +/// (`examples/layout_eval/`) emit the per-term breakdown into its artifacts +/// and read a stored report back for comparison. Terms added after a report +/// was written deserialize as `0.0`. +#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct LayoutMetrics { - /// Sum of pairwise node *shape*-box overlap area (label-free), normalized - /// by total shape-box area. Measures shapes overlapping shapes; label - /// collisions are charged by `label_overlap` instead. + /// Mean over nodes of the fraction of each node's drawn shape covered by + /// other nodes' shapes (capped at 1 per node). pub node_overlap: f64, - /// Fraction of total connector length that passes through non-incident - /// node *shape* boxes (label-free). A connector under a node shape reads as - /// a false causal connection; a connector under only a label is not - /// charged here. + /// Fraction of total connector length that passes under a non-incident + /// node shape or flow pipe: a connector under a shape reads as a false + /// causal connection. pub node_connector_overlap: f64, - /// Sum over labeled elements of each label's *obscured fraction*: the area - /// of the label box covered by any other label box or any other element's - /// bare shape box, capped at the label's own area and divided by it (so each - /// term is in [0,1]). 0 = no label obscured. Per-label so a small overlap - /// registers at its true obscuration fraction rather than being diluted by - /// the corpus's total label area. + /// Mean over labels of the fraction of each label box covered by other + /// labels and other nodes' shapes (capped at 1). pub label_overlap: f64, - /// Edge crossings normalized by connector count. + /// Mean over labels of how much connector -- link or flow pipe -- passes + /// through the label's text box, relative to the box's smaller side (capped + /// at 1): a line through a name strikes it out, however thin. The label's + /// own node's links count at `OWN_LINK_STRIKE_FACTOR`; a flow's own pipe + /// never strikes its name. + #[serde(default)] + pub label_connector_overlap: f64, + /// Edge crossings per connector. pub crossings: f64, + /// Mean over nodes of the clearance deficit to their neighbors -- for each + /// pair of nodes whose footprints (shape and label boxes) come closer than + /// `COMFORTABLE_CLEARANCE`, `(1 - gap/clearance)^2` -- plus the mean over + /// links of the same deficit for links whose visible length is below + /// `MIN_VISIBLE_LINK`. The counterweight to `sprawl`: without it, the + /// cheapest layout is the most crowded one that does not quite overlap. + #[serde(default)] + pub crowding: f64, /// Mean connector length relative to the characteristic node size. pub sprawl: f64, + /// Mean over links of how far each exceeds `LONG_CONNECTOR_FACTOR` times + /// the median link length, in multiples of that threshold: a parameter + /// parked across the diagram from its consumer. + #[serde(default)] + pub long_connectors: f64, /// Coefficient of variation (stddev/mean) of connector lengths. pub edge_length_cv: f64, /// How far the view bounding-box aspect ratio exceeds the target band. pub aspect_penalty: f64, - /// Reserved; computed in a future rung. Always 0.0, weight 0. - pub chain_straightness: f64, + /// Fraction of nodes that share neither a row nor a column (within + /// `ALIGN_TOLERANCE`) with any node within `ALIGN_REACH`. + #[serde(default)] + pub misalignment: f64, /// Mean isoperimetric penalty `1 - Q` over the view's feedback cycles /// (`Q = 4*PI*Area / Perimeter^2` of each loop's node-center polygon, /// clamped to [0,1]). 0.0 = clean, well-spread loops (circles); higher = /// collapsed/collinear loops. 0.0 when the view has no cycle of >= 3 nodes. - /// Computed and reported now; weight stays 0 until Phase 4 calibration. pub loop_compactness: f64, /// Mean number of right-angle bends per flow pipe (a straight pipe has 0, an /// `L` has 1, a `Z` has 2). Flows are orthogonalized before scoring, so this /// rewards placements where the two stocks a flow connects are naturally - /// aligned (a straight pipe, 0 bends) over diagonally-offset stocks that - /// require an `L`/`Z` detour. 0.0 when the view has no flows. + /// aligned over diagonally-offset stocks that require an `L`/`Z` detour. #[serde(default)] pub flow_bends: f64, /// Mean bow shortfall over the causal connectors that participate in a /// feedback loop: 0.0 = every loop connector is drawn with at least the /// target curvature (the loop reads as a visible circle), 1.0 = loop - /// connectors are straight (the loop collapses to a zig-zag). A Goodhart - /// guard complementing `loop_compactness` (which scores node arrangement, not - /// the drawn connector curvature). 0.0 when there is no loop connector. + /// connectors are straight (the loop collapses to a zig-zag). #[serde(default)] pub loop_straightness: f64, } /// Per-term weights for the scalar an optimizer minimizes. /// -/// `MetricWeights::default()` holds the calibrated production weights committed -/// in Phase 4 (see the failure-mode rationale on the `Default` impl below). -/// -/// `Serialize`/`Deserialize` let the layout-quality eval sweep -/// (`examples/layout_eval.rs`) record the weight set it used in its -/// `metrics.json` artifact and read it back when round-tripping the committed -/// baseline report; the struct is pure data (every field a plain `f64`), so the -/// derives carry no behavior. +/// `MetricWeights::default()` holds the calibrated production weights (see the +/// rationale on the `Default` impl). Weights a stored report predates +/// deserialize as `0.0`. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct MetricWeights { pub node_overlap: f64, pub node_connector_overlap: f64, pub label_overlap: f64, + #[serde(default)] + pub label_connector_overlap: f64, pub crossings: f64, + #[serde(default)] + pub crowding: f64, pub sprawl: f64, + #[serde(default)] + pub long_connectors: f64, pub edge_length_cv: f64, pub aspect_penalty: f64, - pub chain_straightness: f64, + #[serde(default)] + pub misalignment: f64, pub loop_compactness: f64, #[serde(default)] pub flow_bends: f64, @@ -135,58 +188,46 @@ pub struct MetricWeights { } impl Default for MetricWeights { - /// The calibrated production weights, from the Phase 3 contact-sheet - /// calibration with explicit user sign-off (2026-05-23). + /// The calibrated production weights. /// - /// Failure-mode rationale -- readability >> compactness: - /// * The dominant concerns all carry weight 1.0: node-shape overlap - /// (`node_overlap`), connectors passing under node shapes - /// (`node_connector_overlap`), obscured labels (`label_overlap`), and - /// edge `crossings`. These are the things that make a diagram unreadable - /// or assert false causal connections, so they dominate the cost. - /// * `sprawl` is a GENTLE compactness counterweight (0.1). The readability - /// terms above can be driven to zero just by spreading a diagram out - /// (labels are a fixed pixel size, so enough spacing always separates - /// them), and nothing else here resists that -- `crossings` and - /// `node_connector_overlap` are scale-invariant and `aspect_penalty` is - /// off. Without a counterweight, "lower cost" can mean "merely bigger", - /// and any optimizer driving this metric (best-of-k seed selection, a - /// declutter pass, metric-driven annealing) would prefer endlessly - /// inflated, unviewable layouts. A small `sprawl` weight makes spreading - /// past the point where labels separate strictly costly, so the cost has - /// a finite optimum at "spread just enough". It is kept far below the - /// readability terms (readability >> compactness still holds): at a - /// healthy spread `sprawl` is ~1, contributing ~0.1 -- enough to break - /// ties toward compactness and deter inflation, not enough to pull a - /// layout back into label collisions. - /// * `edge_length_cv` and `aspect_penalty` stay 0.0: even edge lengths are - /// not a goal, and aspect-ratio penalties actively punished good wide - /// layouts during calibration. - /// * `loop_compactness` is a low 0.25: it gently REWARDS drawing feedback - /// loops as visible circles (a readability aid), but must never dominate - /// the overlap/crossings family, so it stays well below 1.0. - /// * `flow_bends` is a low 0.15: flows are always orthogonalized, so this - /// never repairs a defect -- it only nudges the optimizer toward - /// placements where a flow's two stocks line up (a clean straight pipe) - /// instead of an `L`/`Z` detour. A convention aid like - /// `loop_compactness`, kept well below the readability family. - /// * `loop_straightness` is a low 0.1: a Goodhart guard that keeps feedback - /// loops drawn as visible curves. `apply_loop_curvature` curves loop - /// connectors deterministically so a healthy layout scores ~0 here; the - /// weight exists so the metric can never reward flattening a loop back - /// into a zig-zag. Below the readability family by construction. - /// * `chain_straightness` stays 0.0: it is reserved (not yet computed), so - /// it carries no weight. + /// Every defect term is a rate, so a weight is the cost of that defect + /// affecting EVERY element it concerns; a single defect in a model of `n` + /// elements costs `weight / n`. The weights encode how much one instance of + /// each defect hurts relative to the others: + /// * Illegibility dominates. A node covering a node, a label covered by + /// something, a connector under a shape (a false causal link), and a + /// line through a name each destroy information outright. + /// * A crossing costs less than an obscured label (the reader can follow + /// a line across another) but more than mild crowding. + /// * `crowding` and `sprawl` pull in opposite directions and together set + /// the finite optimum spacing: spread until neighbors have air, no + /// further. + /// * `long_connectors` charges the one parameter parked across the + /// diagram, which the mean-length `sprawl` barely registers. + /// * Loop and flow conventions (`loop_compactness`, `flow_bends`, + /// `loop_straightness`) and alignment (`misalignment`) are gentle + /// nudges toward how modelers draw. + /// * `edge_length_cv` and `aspect_penalty` are reported for diagnosis and + /// carry no weight. + /// + /// Calibrated against the eval harness's judged pairs (every taste-battery + /// degradation of the corpus references and production layouts, plus + /// visual reference-vs-production judgments): a log-space fit anchored at + /// `crossings = 1` moved these by under 15%, so the values are rounded + /// priors the data confirms rather than a fit to a handful of models. fn default() -> Self { MetricWeights { - node_overlap: 1.0, - node_connector_overlap: 1.0, - label_overlap: 1.0, + node_overlap: 3.5, + node_connector_overlap: 2.0, + label_overlap: 3.5, + label_connector_overlap: 1.5, crossings: 1.0, - sprawl: 0.2, + crowding: 1.0, + sprawl: 0.25, + long_connectors: 0.5, edge_length_cv: 0.0, aspect_penalty: 0.0, - chain_straightness: 0.0, + misalignment: 0.1, loop_compactness: 0.4, flow_bends: 0.15, loop_straightness: 0.1, @@ -194,130 +235,207 @@ impl Default for MetricWeights { } } +impl MetricWeights { + /// Every weight zero: the base for isolating one or a few terms + /// (`MetricWeights { crossings: 1.0, ..MetricWeights::zero() }`). + pub const fn zero() -> Self { + MetricWeights { + node_overlap: 0.0, + node_connector_overlap: 0.0, + label_overlap: 0.0, + label_connector_overlap: 0.0, + crossings: 0.0, + crowding: 0.0, + sprawl: 0.0, + long_connectors: 0.0, + edge_length_cv: 0.0, + aspect_penalty: 0.0, + misalignment: 0.0, + loop_compactness: 0.0, + flow_bends: 0.0, + loop_straightness: 0.0, + } + } +} + impl LayoutMetrics { /// Sigma w_i * term_i -- the scalar an optimizer minimizes. pub fn weighted_cost(&self, w: &MetricWeights) -> f64 { self.node_overlap * w.node_overlap + self.node_connector_overlap * w.node_connector_overlap + self.label_overlap * w.label_overlap + + self.label_connector_overlap * w.label_connector_overlap + self.crossings * w.crossings + + self.crowding * w.crowding + self.sprawl * w.sprawl + + self.long_connectors * w.long_connectors + self.edge_length_cv * w.edge_length_cv + self.aspect_penalty * w.aspect_penalty - + self.chain_straightness * w.chain_straightness + + self.misalignment * w.misalignment + self.loop_compactness * w.loop_compactness + self.flow_bends * w.flow_bends + self.loop_straightness * w.loop_straightness } -} -/// The drawn geometry of one connector (Link or Flow): its incident node uids -/// (so node-connector-overlap can skip them) and the polyline the renderer -/// draws. Built once and reused by every connector-derived term so they all see -/// the same geometry. -struct ConnectorGeometry { - /// Element uids the connector is attached to and must not be charged for - /// passing through (its own endpoints). - incident_uids: HashSet, - /// The drawn polyline. Always has at least two points (connectors that draw - /// nothing -- e.g. MultiPoint links -- are not collected at all). - polyline: Vec, - /// Total polyline length. - length: f64, + /// `(name, value)` for every term, in a stable display order. + pub fn terms(&self) -> [(&'static str, f64); 14] { + [ + ("node_overlap", self.node_overlap), + ("node_connector_overlap", self.node_connector_overlap), + ("label_overlap", self.label_overlap), + ("label_connector_overlap", self.label_connector_overlap), + ("crossings", self.crossings), + ("crowding", self.crowding), + ("sprawl", self.sprawl), + ("long_connectors", self.long_connectors), + ("edge_length_cv", self.edge_length_cv), + ("aspect_penalty", self.aspect_penalty), + ("misalignment", self.misalignment), + ("loop_compactness", self.loop_compactness), + ("flow_bends", self.flow_bends), + ("loop_straightness", self.loop_straightness), + ] + } } -/// Total length of the UNION of parameter intervals `[t0, t1]` (each `t` in -/// [0,1]), counting each covered sub-length once. Sorts by start then sweep- -/// merges, so overlapping/adjacent intervals collapse. The next interval merges -/// when its start is `<= ` the current end (no epsilon needed; equality is -/// tolerated as adjacency). Mutates `intervals` (sorts in place); empty input -/// yields 0.0. Order-independent in its result. PURE. -fn merged_interval_length(intervals: &mut [(f64, f64)]) -> f64 { - if intervals.is_empty() { - return 0.0; - } - intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); - let mut total = 0.0; - let mut cur = intervals[0]; - for &(t0, t1) in &intervals[1..] { - if t0 <= cur.1 { - // Overlapping or adjacent: extend the current run. - cur.1 = cur.1.max(t1); - } else { - total += cur.1 - cur.0; - cur = (t0, t1); - } +impl MetricWeights { + /// `(name, weight)` for every weight, in the same order as + /// [`LayoutMetrics::terms`]. + pub fn terms(&self) -> [(&'static str, f64); 14] { + [ + ("node_overlap", self.node_overlap), + ("node_connector_overlap", self.node_connector_overlap), + ("label_overlap", self.label_overlap), + ("label_connector_overlap", self.label_connector_overlap), + ("crossings", self.crossings), + ("crowding", self.crowding), + ("sprawl", self.sprawl), + ("long_connectors", self.long_connectors), + ("edge_length_cv", self.edge_length_cv), + ("aspect_penalty", self.aspect_penalty), + ("misalignment", self.misalignment), + ("loop_compactness", self.loop_compactness), + ("flow_bends", self.flow_bends), + ("loop_straightness", self.loop_straightness), + ] } - total += cur.1 - cur.0; - total } -/// Polyline length: sum of segment lengths. -fn polyline_length(points: &[Point]) -> f64 { - points - .windows(2) - .map(|w| { - let dx = w[1].x - w[0].x; - let dy = w[1].y - w[0].y; - (dx * dx + dy * dy).sqrt() - }) - .sum() +/// What kind of defect a [`Defect`] marks, one per defect term. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DefectKind { + NodeOverlap, + ConnectorThroughNode, + LabelObscured, + LabelCrossed, + Crossing, + Crowded, + LongConnector, } -/// Resolve the node box for an element that has one (everything except links, -/// groups, and aliases). An ALIAS's box needs its source element's name (the -/// label it renders), which a single-element function cannot resolve -- -/// `compute_layout_metrics` handles aliases at the view level via -/// `alias_source_names` + `alias_node_box`. -fn node_box(element: &ViewElement) -> Option { - match element { - ViewElement::Aux(a) => Some(aux_bounds(a)), - ViewElement::Stock(s) => Some(stock_bounds(s)), - ViewElement::Module(m) => Some(module_bounds(m)), - ViewElement::Cloud(c) => Some(cloud_bounds(c)), - ViewElement::Flow(f) => Some(flow_bounds(f)), - ViewElement::Link(_) | ViewElement::Alias(_) | ViewElement::Group(_) => None, +/// One defect the metric charged, located on the diagram: the region it +/// concerns (`[left, top, right, bottom]`; a point is a zero-size region) and +/// its severity in the term's own units (a covered fraction, a clearance +/// deficit, an excess ratio). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Defect { + pub kind: DefectKind, + pub region: [f64; 4], + pub severity: f64, +} + +/// The metrics of a view together with every defect behind them. +pub struct LayoutAnalysis { + pub metrics: LayoutMetrics, + pub defects: Vec, +} + +/// Where defects go while the terms are computed: nowhere (the hot path an +/// optimizer runs) or into a list (the eval harness's overlays). One code path +/// computes both, so the overlay can never disagree with the score. +struct DefectSink { + defects: Option>, +} + +impl DefectSink { + fn push(&mut self, kind: DefectKind, region: Rect, severity: f64) { + if let Some(d) = &mut self.defects { + d.push(Defect { + kind, + region: [region.left, region.top, region.right, region.bottom], + severity, + }); + } + } + + fn push_point(&mut self, kind: DefectKind, p: Point, severity: f64) { + self.push( + kind, + Rect { + left: p.x, + top: p.y, + right: p.x, + bottom: p.y, + }, + severity, + ); } } -/// The element's bare *shape* box, WITHOUT its own label, for the same set of -/// elements as `node_box`. `aux_bounds`/`stock_bounds`/`flow_bounds` merge each -/// element's own label into the returned box; the label-vs-node term of -/// `label_overlap` must use the label-free shape so a label-vs-label overlap is -/// not also charged via the other node's label-merged box (a double-count). -/// `module_bounds`/`cloud_bounds` already exclude the label (modules render a -/// label that their bounds omit; clouds render none), so they are their own -/// shape box. +// --- the drawn scene ----------------------------------------------------------- + +/// The element's primary drawn *shape* box, WITHOUT its label: the circle or +/// rectangle the renderer draws for it. A flow's shape is its valve circle +/// (`render_flow` draws radius `AUX_RADIUS`); its pipe is separate geometry +/// ([`pipe_rects`]). An alias draws an aux-sized circle. Links and groups have +/// no shape. pub(crate) fn node_shape_box(element: &ViewElement) -> Option { + use crate::diagram::constants::AUX_RADIUS; match element { ViewElement::Aux(a) => Some(aux_shape_bounds(a)), ViewElement::Stock(s) => Some(stock_shape_bounds(s)), - ViewElement::Module(m) => Some(module_bounds(m)), + ViewElement::Module(m) => Some(module_shape_bounds(m)), ViewElement::Cloud(c) => Some(cloud_bounds(c)), - ViewElement::Flow(f) => Some(flow_shape_bounds(f)), - // An alias renders an aux-sized circle (see `render_alias`); its shape - // box needs no name resolution. + ViewElement::Flow(f) => Some(circle_box(f.x, f.y, AUX_RADIUS)), ViewElement::Alias(a) => Some(alias_shape_box(a)), ViewElement::Link(_) | ViewElement::Group(_) => None, } } +fn circle_box(cx: f64, cy: f64, r: f64) -> Rect { + Rect { + left: cx - r, + right: cx + r, + top: cy - r, + bottom: cy + r, + } +} + +/// A flow's pipe as drawn: one box per segment, inflated by the stroke's half +/// width, so the pipe covers what it visibly covers. An axis-aligned segment +/// (the orthogonalized pipes the layout produces) is covered exactly. +pub(crate) fn pipe_rects(flow: &datamodel::view_element::Flow) -> Vec { + flow.points + .windows(2) + .map(|w| Rect { + left: w[0].x.min(w[1].x) - PIPE_HALF_WIDTH, + right: w[0].x.max(w[1].x) + PIPE_HALF_WIDTH, + top: w[0].y.min(w[1].y) - PIPE_HALF_WIDTH, + bottom: w[0].y.max(w[1].y) + PIPE_HALF_WIDTH, + }) + .collect() +} + /// The bare shape box of an alias: the aux-radius circle `render_alias` draws, /// centered on the alias position. pub(crate) fn alias_shape_box(alias: &crate::datamodel::view_element::Alias) -> Rect { use crate::diagram::constants::AUX_RADIUS; - Rect { - left: alias.x - AUX_RADIUS, - right: alias.x + AUX_RADIUS, - top: alias.y - AUX_RADIUS, - bottom: alias.y + AUX_RADIUS, - } + circle_box(alias.x, alias.y, AUX_RADIUS) } /// The label an alias renders: its SOURCE element's display name (resolved -/// through `alias_of_uid`), positioned like an aux label. Returns `None` when -/// the source uid resolves to nothing (a dangling alias renders the circle but -/// no meaningful label is derivable). +/// through `alias_of_uid`), positioned like an aux label. pub(crate) fn alias_label_props_for( alias: &crate::datamodel::view_element::Alias, source_name: &str, @@ -330,10 +448,8 @@ pub(crate) fn alias_label_props_for( /// Map each alias uid in `elements` to its source element's name. Aliases whose /// `alias_of_uid` does not resolve to a named element are omitted (dangling). -pub(crate) fn alias_source_names( - elements: &[ViewElement], -) -> std::collections::HashMap { - let names: std::collections::HashMap = elements +pub(crate) fn alias_source_names(elements: &[ViewElement]) -> HashMap { + let names: HashMap = elements .iter() .filter_map(|e| e.get_name().map(|n| (e.get_uid(), n))) .collect(); @@ -349,9 +465,10 @@ pub(crate) fn alias_source_names( } /// Build a `LabelProps` for a labeled element placed on `side`, matching the -/// renderer's label geometry (center, display name, and the element's radii). -/// Only elements that render a label return `Some`. The radii match the -/// per-element `with_radii` calls in `diagram::elements`/`diagram::flow`. +/// renderer's label geometry (center, display name, and the radii the element +/// renders its label with). Only elements that render their own name return +/// `Some`; an alias's label needs its source's name (see +/// [`alias_label_props_for`]). /// /// Exposed `pub(crate)` so the declutter pass (`layout::declutter`) can probe /// the label box an element *would* occupy on an alternative side, scoring @@ -361,7 +478,7 @@ pub(crate) fn element_label_props_for( side: crate::datamodel::view_element::LabelSide, ) -> Option { use crate::diagram::constants::{ - AUX_RADIUS, FLOW_VALVE_RADIUS, MODULE_HEIGHT, MODULE_WIDTH, STOCK_HEIGHT, STOCK_WIDTH, + AUX_RADIUS, MODULE_HEIGHT, MODULE_WIDTH, STOCK_HEIGHT, STOCK_WIDTH, }; match element { ViewElement::Aux(a) => Some( @@ -376,14 +493,11 @@ pub(crate) fn element_label_props_for( LabelProps::new(m.x, m.y, side, display_name(&m.name)) .with_radii(MODULE_WIDTH / 2.0, MODULE_HEIGHT / 2.0), ), + // `render_flow` places a flow's label around the valve's drawn radius. ViewElement::Flow(f) => Some( LabelProps::new(f.x, f.y, side, display_name(&f.name)) - .with_radii(FLOW_VALVE_RADIUS, FLOW_VALVE_RADIUS), + .with_radii(AUX_RADIUS, AUX_RADIUS), ), - // Aliases do render a label, but they have no `*_bounds` helper and are - // excluded from node bounds to match the renderer's view box; we keep - // the label-set consistent with the node-box set by also excluding - // their labels. Links/Clouds/Groups render no element label. ViewElement::Alias(_) | ViewElement::Link(_) | ViewElement::Cloud(_) @@ -391,43 +505,151 @@ pub(crate) fn element_label_props_for( } } -/// The element's own current label side, or `None` for kinds the metric does -/// not score a label for (the same set `element_label_props_for` returns `Some` -/// for). +/// The element's own current label side, or `None` for kinds that render no +/// label of their own name (the set `element_label_props_for` returns `Some` +/// for, plus aliases). fn element_label_side(element: &ViewElement) -> Option { match element { ViewElement::Aux(a) => Some(a.label_side), ViewElement::Stock(s) => Some(s.label_side), ViewElement::Module(m) => Some(m.label_side), ViewElement::Flow(f) => Some(f.label_side), - ViewElement::Alias(_) - | ViewElement::Link(_) - | ViewElement::Cloud(_) - | ViewElement::Group(_) => None, + ViewElement::Alias(a) => Some(a.label_side), + ViewElement::Link(_) | ViewElement::Cloud(_) | ViewElement::Group(_) => None, } } -/// Build a `LabelProps` for a labeled element on its *current* label side. -fn element_label_props(element: &ViewElement) -> Option { - element_label_props_for(element, element_label_side(element)?) +/// One node of the drawn scene. +struct SceneNode { + uid: i32, + shape: Rect, + label: Option, + /// A flow's pipe boxes; empty for every other node. + pipe: Vec, + /// The uids a flow's pipe attaches to (stocks, clouds); empty otherwise. + attached: Vec, + /// The renderer's visual center. + center: Point, + /// A cloud is a flow's decorative source or sink: a light mark whose + /// proximity to anything is not crowding (landing ON something is still + /// an overlap). + is_cloud: bool, } -/// Collect the drawn geometry of every connector (Link or Flow) that draws -/// something. Links use the shared `connector_polyline` (the exact geometry the -/// renderer draws and `build_view_segments` counts); flows use their point -/// polyline. Connectors that draw nothing (MultiPoint links, degenerate arcs, -/// flows with fewer than two points) are omitted entirely. -fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec { - let mut uid_elements = std::collections::HashMap::new(); - for elem in &view.elements { - uid_elements.insert(elem.get_uid(), elem); +impl SceneNode { + /// Whether this node and `other` are joined by construction (a flow and the + /// stock or cloud its pipe attaches to), so their adjacency is not a + /// layout defect. + fn attached_to(&self, other: &SceneNode) -> bool { + self.attached.contains(&other.uid) || other.attached.contains(&self.uid) + } + + /// The label-merged box: shape and label together. + fn footprint_box(&self) -> Rect { + match self.label { + Some(l) => merge_bounds(self.shape, l), + None => self.shape, + } + } +} + +fn build_scene_nodes(elements: &[ViewElement]) -> Vec { + let alias_names = alias_source_names(elements); + let not_arrayed = |_: &str| false; + elements + .iter() + .filter_map(|e| { + let shape = node_shape_box(e)?; + let label = match e { + ViewElement::Alias(a) => alias_names + .get(&a.uid) + .map(|name| label_bounds(&alias_label_props_for(a, name, a.label_side))), + _ => element_label_side(e) + .and_then(|side| element_label_props_for(e, side)) + .map(|props| label_bounds(&props)), + }; + let (pipe, attached) = match e { + ViewElement::Flow(f) => ( + pipe_rects(f), + f.points.iter().filter_map(|p| p.attached_to_uid).collect(), + ), + _ => (Vec::new(), Vec::new()), + }; + let (cx, cy) = get_visual_center(e, ¬_arrayed); + Some(SceneNode { + uid: e.get_uid(), + shape, + label, + pipe, + attached, + center: Point { x: cx, y: cy }, + is_cloud: matches!(e, ViewElement::Cloud(_)), + }) + }) + .collect() +} + +/// Whether a connector is a causal link or a flow pipe. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ConnectorKind { + Link, + Pipe, +} + +/// The drawn geometry of one connector (Link or Flow pipe): its incident node +/// uids (so overlap terms skip them) and the polyline the renderer draws. +struct ConnectorGeometry { + kind: ConnectorKind, + /// A link's two endpoints; a pipe's flow and the stocks and clouds it + /// attaches to. + incident_uids: HashSet, + /// The flow a pipe belongs to; `None` for a link. + flow_uid: Option, + /// Always at least two points (connectors that draw nothing are omitted). + polyline: Vec, + length: f64, +} + +/// Total length of the UNION of parameter intervals `[t0, t1]` (each `t` in +/// [0,1]), counting each covered sub-length once. Mutates `intervals` (sorts in +/// place); empty input yields 0.0. +fn merged_interval_length(intervals: &mut [(f64, f64)]) -> f64 { + if intervals.is_empty() { + return 0.0; + } + intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut total = 0.0; + let mut cur = intervals[0]; + for &(t0, t1) in &intervals[1..] { + if t0 <= cur.1 { + cur.1 = cur.1.max(t1); + } else { + total += cur.1 - cur.0; + cur = (t0, t1); + } } - // Center-based, deterministic: nothing is treated as arrayed (matches - // `build_view_segments`). + total += cur.1 - cur.0; + total +} + +/// Polyline length: sum of segment lengths. +fn polyline_length(points: &[Point]) -> f64 { + points + .windows(2) + .map(|w| ((w[1].x - w[0].x).powi(2) + (w[1].y - w[0].y).powi(2)).sqrt()) + .sum() +} + +/// Collect the drawn geometry of every connector that draws something. Links +/// use the shared `connector_polyline` (the exact geometry the renderer draws +/// and `build_view_segments` counts); flows use their point polyline. +fn collect_connector_geometry(elements: &[ViewElement]) -> Vec { + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); let not_arrayed = |_: &str| false; let mut out = Vec::new(); - for elem in &view.elements { + for elem in elements { match elem { ViewElement::Link(link) => { let (Some(&from), Some(&to)) = ( @@ -441,14 +663,12 @@ fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec { @@ -460,20 +680,14 @@ fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec {} @@ -482,608 +696,632 @@ fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec= 3 positioned nodes we take the -// node-box centers in cycle order and form a polygon. Its isoperimetric -// quotient Q = 4*PI*Area / Perimeter^2 is 1 for a perfect circle and tends to 0 -// as the polygon collapses toward a line (the area vanishes while the perimeter -// stays large). The per-cycle penalty is `1 - Q` (0 = ideal clean loop, ~1 = -// squished/collinear), and `loop_compactness` is the mean penalty over all -// qualifying cycles (0.0 when the view has no cycle of >= 3 nodes). It thus -// REWARDS well-spread loops and PENALIZES collapsed ones. -// -// Bounds (SD diagrams are small, so this stays O(small) and total): a simple -// cycle is enumerated only up to `MAX_CYCLE_LEN` nodes, and at most -// `MAX_CYCLES` cycles are scored; enumeration stops once the cap is hit. The -// graph is built over positioned node-box elements (aux/stock/flow/module/cloud -// -- the same set as `node_box`); links and flows supply the directed edges. -// -// Determinism: layout is deterministic per seed, but this term is additionally -// independent of element ordering. Adjacency targets are sorted, the DFS starts -// from each node in sorted uid order, and every enumerated cycle is canonicalized -// (rotated so its smallest uid is first) and de-duplicated, so the mean is the -// same regardless of how the elements are listed in the view. - -/// Maximum number of nodes in an enumerated simple cycle. SD feedback loops are -/// short; a longer "cycle" is almost always an artifact of many overlapping -/// smaller loops and is not worth the combinatorial cost. -const MAX_CYCLE_LEN: usize = 12; - -/// Maximum number of distinct simple cycles scored. Bounds the work on dense -/// graphs; the mean penalty over the first `MAX_CYCLES` cycles is a faithful -/// proxy for the whole (SD diagrams rarely approach this). -const MAX_CYCLES: usize = 64; +/// Separation distance between two rects (0 when they touch or overlap). +fn rect_gap(a: &Rect, b: &Rect) -> f64 { + let dx = (a.left - b.right).max(b.left - a.right).max(0.0); + let dy = (a.top - b.bottom).max(b.top - a.bottom).max(0.0); + (dx * dx + dy * dy).sqrt() +} -/// Directed adjacency over positioned node-box elements, keyed by uid with -/// sorted successor lists. Each node's loop vertex is the renderer's VISUAL -/// center (`diagram::connector::get_visual_center`) -- for a flow that is its -/// VALVE `(flow.x, flow.y)`, NOT the pipe-extent center of `flow_shape_bounds` -/// (which unions the valve box with every pipe point and so drifts off the valve -/// when the pipe is bent or the valve is dragged off-center); for an -/// aux/stock/module/cloud it is the element center, which already equals the -/// symmetric shape-box midpoint. Using the same visual center the SVG renderer -/// draws keeps the loop polygon faithful to the drawn diagram. -struct LoopGraph { - /// uid -> sorted, de-duplicated successor uids. - adj: BTreeMap>, - /// uid -> node visual-center point (the valve for flows; the element center - /// for aux/stock/module/cloud). - centers: BTreeMap, +fn rect_intersection(a: &Rect, b: &Rect) -> Rect { + Rect { + left: a.left.max(b.left), + top: a.top.max(b.top), + right: a.right.min(b.right), + bottom: a.bottom.min(b.bottom), + } } -/// Build the directed loop graph from the view. Nodes are exactly the elements -/// with a node box (`node_shape_box` -- aux/stock/module/cloud/flow; links, -/// aliases, and groups are excluded). Each node's loop vertex is the renderer's -/// VISUAL center (`get_visual_center`), so a flow's vertex is its VALVE -/// `(flow.x, flow.y)`, NOT the pipe-extent center of `flow_shape_bounds` (the -/// valve box unioned with every pipe point), which drifts off the valve when the -/// pipe is bent or the valve is dragged off-center. For aux/stock/module/cloud -/// the visual center is the element center, which already equals the symmetric -/// shape-box midpoint, so those vertices are unchanged. Edges to/from uids that -/// are not positioned nodes are dropped. Edges come from: -/// * each Link: `from_uid -> to_uid`; -/// * each Flow: for consecutive attached points, `source_attached -> flow.uid` -/// and `flow.uid -> dest_attached`, so a stock--flow--stock feedback path is -/// part of the graph (the flow's own valve is the intermediate node). -fn build_loop_graph(view: &datamodel::StockFlow) -> LoopGraph { - // The node-membership gate stays `node_shape_box` (it defines which elements - // are loop nodes), but the loop VERTEX is the renderer's visual center, which - // is correct for every gated kind: the valve for a flow, the element center - // for aux/stock/module/cloud. `not_arrayed` matches `collect_connector_geometry` - // / `build_view_segments` (offset 0, deterministic). - let not_arrayed = |_: &str| false; - let mut centers: BTreeMap = BTreeMap::new(); - for e in &view.elements { - if node_shape_box(e).is_some() { - let (cx, cy) = get_visual_center(e, ¬_arrayed); - centers.insert(e.get_uid(), Point { x: cx, y: cy }); - } +fn inset(r: &Rect, d: f64) -> Rect { + Rect { + left: r.left + d, + top: r.top + d, + right: r.right - d, + bottom: r.bottom - d, } +} - // Collect edges into sorted sets per source so the adjacency is canonical - // (sorted, de-duplicated) and the cycle search is order-independent. - let mut edge_sets: BTreeMap> = BTreeMap::new(); - let mut add_edge = |from: i32, to: i32, centers: &BTreeMap| { - // Both endpoints must be positioned nodes, and we never record a - // self-loop (a single-node "cycle" forms no polygon). - if from != to && centers.contains_key(&from) && centers.contains_key(&to) { - edge_sets.entry(from).or_default().insert(to); +// --- the defect terms ------------------------------------------------------------ + +/// `node_overlap`: mean covered fraction of each node's shape. +fn node_overlap_term(nodes: &[SceneNode], sink: &mut DefectSink) -> f64 { + if nodes.is_empty() { + return 0.0; + } + let mut total = 0.0; + for (i, a) in nodes.iter().enumerate() { + let area = rect_area(&a.shape); + if area <= 0.0 { + continue; } - }; + let mut covered = 0.0; + for (j, b) in nodes.iter().enumerate() { + if i == j { + continue; + } + let o = rect_overlap_area(&a.shape, &b.shape); + if o > 0.0 { + covered += o; + if i < j { + sink.push( + DefectKind::NodeOverlap, + rect_intersection(&a.shape, &b.shape), + o / area.min(rect_area(&b.shape)).max(1e-9), + ); + } + } + } + total += covered.min(area) / area; + } + total / nodes.len() as f64 +} - for e in &view.elements { - match e { - ViewElement::Link(link) => { - add_edge(link.from_uid, link.to_uid, ¢ers); +/// `node_connector_overlap`: fraction of connector length under non-incident +/// node shapes and pipes, each covered sub-length counted once. +fn node_connector_overlap_term( + nodes: &[SceneNode], + connectors: &[ConnectorGeometry], + sink: &mut DefectSink, +) -> f64 { + let total_length: f64 = connectors.iter().map(|c| c.length).sum(); + if total_length <= 0.0 { + return 0.0; + } + // Obstacles: every node's shape, and every flow's pipe boxes (a link run + // along a pipe is as misleading as one under a shape). Each obstacle is + // owned by a node uid for the incidence check. + let mut obstacles: Vec<(i32, Rect)> = Vec::new(); + for n in nodes { + obstacles.push((n.uid, n.shape)); + obstacles.extend(n.pipe.iter().map(|r| (n.uid, *r))); + } + let mut inside = 0.0; + for c in connectors { + // Length of this connector under each obstacle, for the defect report. + let mut per_obstacle: BTreeMap = BTreeMap::new(); + for seg in c.polyline.windows(2) { + let seg_len = ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)).sqrt(); + if seg_len == 0.0 { + continue; } - ViewElement::Flow(flow) => { - // Consecutive attached points define stock->flow and flow->stock - // edges through the flow's own valve uid. - let attached: Vec = flow - .points - .iter() - .filter_map(|p| p.attached_to_uid) - .collect(); - for w in attached.windows(2) { - add_edge(w[0], flow.uid, ¢ers); - add_edge(flow.uid, w[1], ¢ers); + let mut intervals: Vec<(f64, f64)> = Vec::new(); + for (k, (uid, rect)) in obstacles.iter().enumerate() { + if c.incident_uids.contains(uid) { + continue; + } + if let Some(iv) = segment_clip_interval_in_rect(&seg[0], &seg[1], rect) { + intervals.push(iv); + *per_obstacle.entry(k).or_default() += (iv.1 - iv.0) * seg_len; } } - _ => {} + inside += merged_interval_length(&mut intervals) * seg_len; + } + for (k, length) in per_obstacle { + sink.push(DefectKind::ConnectorThroughNode, obstacles[k].1, length); } } + inside / total_length +} - let adj: BTreeMap> = edge_sets - .into_iter() - .map(|(k, set)| (k, set.into_iter().collect())) +/// `label_overlap`: mean covered fraction of each label box by other labels +/// and other nodes' shapes. A pipe through a label is not coverage but a line +/// through the name, charged by `label_connector_overlap`: counting its 4px +/// band by area would make a pipe through a name several times cheaper than a +/// hairline link through it. +fn label_overlap_term(nodes: &[SceneNode], sink: &mut DefectSink) -> f64 { + let labeled: Vec<&SceneNode> = nodes.iter().filter(|n| n.label.is_some()).collect(); + if labeled.is_empty() { + return 0.0; + } + let mut total = 0.0; + for a in &labeled { + let lbl = a.label.expect("filtered to labeled nodes"); + let area = rect_area(&lbl); + if area <= 0.0 { + continue; + } + let mut covered = 0.0; + for b in nodes { + if b.uid == a.uid { + continue; + } + covered += rect_overlap_area(&lbl, &b.shape); + if let Some(other) = b.label { + covered += rect_overlap_area(&lbl, &other); + } + } + let fraction = covered.min(area) / area; + if fraction > 0.0 { + sink.push(DefectKind::LabelObscured, lbl, fraction); + } + total += fraction; + } + total / labeled.len() as f64 +} + +/// `label_connector_overlap`: mean over labels of the connector length (links +/// and pipes) through the label's text box relative to the box's smaller side. +fn label_connector_overlap_term( + nodes: &[SceneNode], + connectors: &[ConnectorGeometry], + sink: &mut DefectSink, +) -> f64 { + let labels: Vec<(i32, Rect)> = nodes + .iter() + .filter_map(|n| n.label.map(|l| (n.uid, l))) .collect(); - LoopGraph { adj, centers } + if labels.is_empty() { + return 0.0; + } + let mut total = 0.0; + for (owner, lbl) in &labels { + let fraction = label_strike_fraction(*owner, lbl, connectors); + if fraction > 0.0 { + sink.push(DefectKind::LabelCrossed, *lbl, fraction); + } + total += fraction; + } + total / labels.len() as f64 } -/// Enumerate simple directed cycles (each >= 2 nodes), bounded by -/// `MAX_CYCLE_LEN` and `MAX_CYCLES`, canonicalized and de-duplicated so the same -/// directed cycle is returned exactly once regardless of where the search -/// started. A bounded DFS suffices: SD diagrams are tiny, and the caps keep it -/// O(small) on the rare dense graph. -/// -/// Each returned cycle is a `Vec` of uids in traversal order, rotated so -/// its smallest uid is first (canonical form), and the set of returned cycles is -/// itself sorted for a fully deterministic result. -fn enumerate_simple_cycles(graph: &LoopGraph) -> Vec> { - let mut found: BTreeSet> = BTreeSet::new(); - // Start a DFS from each node in sorted uid order. To avoid re-finding the - // same cycle from each of its members we still canonicalize+dedup, but we - // also restrict each search to cycles whose minimum node is the start node, - // which prunes the bulk of the duplicate work. - let starts: Vec = graph.adj.keys().copied().collect(); - let mut path: Vec = Vec::new(); - let mut on_path: HashSet = HashSet::new(); - for &start in &starts { - path.clear(); - on_path.clear(); - dfs_cycles(graph, start, start, &mut path, &mut on_path, &mut found); - if found.len() >= MAX_CYCLES { - break; +/// How struck out the label box `lbl` of node `owner` is: the connector length +/// through its (inset) text box relative to the box's smaller side, capped at +/// 1. +fn label_strike_fraction<'a>( + owner: i32, + lbl: &Rect, + connectors: impl IntoIterator, +) -> f64 { + let text = inset(lbl, LABEL_INSET); + let side = common::rect_width(&text).min(common::rect_height(&text)); + if side <= 0.0 { + return 0.0; + } + let mut through = 0.0; + for c in connectors { + let factor = match c.kind { + // A link into or out of the labeled node at least points at (or + // leaves from) that name, the way a modeler draws an arrow to a + // variable, so it strikes the name out half as badly as a line + // passing through on its way somewhere else. + ConnectorKind::Link if c.incident_uids.contains(&owner) => OWN_LINK_STRIKE_FACTOR, + ConnectorKind::Link => 1.0, + // A flow's name sits beside its own pipe. Every other pipe through + // a name -- one entering the named stock through the face the name + // sits on included -- writes over it. + ConnectorKind::Pipe if c.flow_uid == Some(owner) => continue, + ConnectorKind::Pipe => 1.0, + }; + for seg in c.polyline.windows(2) { + if let Some((t0, t1)) = segment_clip_interval_in_rect(&seg[0], &seg[1], &text) { + let seg_len = + ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)).sqrt(); + through += factor * (t1 - t0) * seg_len; + } } } - found.into_iter().take(MAX_CYCLES).collect() + (through / side).min(1.0) } -/// Depth-first walk that records every simple cycle returning to `start` and -/// composed only of nodes whose uid is >= `start` (so each cycle is discovered -/// from its smallest member). `path`/`on_path` track the current simple path. -fn dfs_cycles( - graph: &LoopGraph, - start: i32, - current: i32, - path: &mut Vec, - on_path: &mut HashSet, - found: &mut BTreeSet>, -) { - if found.len() >= MAX_CYCLES { - return; +/// The drawn scene of a view, for a label-side chooser that must charge a +/// candidate label box as the metric would. Shapes and connectors stay put +/// while sides are chosen; the other labels' boxes are whatever the chooser +/// has picked so far, so they are supplied per call. +pub(crate) struct LabelScene { + nodes: Vec, + connectors: Vec, + /// `labels / nodes`: converts a per-node crowding deficit into the same + /// per-label units the label terms are charged in. + crowding_scale: f64, + index_of_uid: HashMap, + /// Each node by the region it can reach: its shape, grown by its label's + /// size on every side (a label may take any side) and the crowding + /// clearance. + node_grid: SceneGrid, + /// Each connector by its polyline's bounding box. + connector_grid: SceneGrid, +} + +impl LabelScene { + pub(crate) fn new(elements: &[ViewElement]) -> Self { + let nodes = build_scene_nodes(elements); + let connectors = collect_connector_geometry(elements); + let labels = nodes.iter().filter(|n| n.label.is_some()).count(); + let crowding_scale = if nodes.is_empty() { + 0.0 + } else { + labels as f64 / nodes.len() as f64 + }; + let mut node_grid = SceneGrid::default(); + for (i, n) in nodes.iter().enumerate() { + let (w, h) = n.label.map_or((0.0, 0.0), |l| { + (common::rect_width(&l), common::rect_height(&l)) + }); + let reach = w.max(h) + REACH_PAD; + node_grid.insert(i, &grown(&n.shape, reach)); + } + let mut connector_grid = SceneGrid::default(); + for (i, c) in connectors.iter().enumerate() { + let bounds = c + .polyline + .iter() + .fold(None, |acc: Option, p| { + let point = Rect { + left: p.x, + top: p.y, + right: p.x, + bottom: p.y, + }; + Some(acc.map_or(point, |r| merge_bounds(r, point))) + }) + .expect("a connector has at least two points"); + connector_grid.insert(i, &bounds); + } + LabelScene { + index_of_uid: nodes.iter().enumerate().map(|(i, n)| (n.uid, i)).collect(), + nodes, + connectors, + crowding_scale, + node_grid, + connector_grid, + } } - path.push(current); - on_path.insert(current); - if let Some(succs) = graph.adj.get(¤t) { - for &next in succs { - if next == start { - // Closed a cycle back to the start. Record it (>= 2 nodes by - // construction; self-loops were never added as edges). - if path.len() >= 2 { - found.insert(canonicalize_cycle(path)); - if found.len() >= MAX_CYCLES { - break; - } - } + /// What the metric charges node `owner` for wearing the label box `lbl`, + /// in per-label units: `w.label_overlap` times the fraction of the box + /// covered by other nodes' shapes and labels, `w.label_connector_overlap` + /// times its strike fraction, and `w.crowding` times the clearance deficit + /// of every pair `owner` forms with another node. `label_of(uid)` is + /// another node's current label box. The part of the cost that does not + /// depend on `lbl` is the same for every side, so only differences + /// between sides mean anything. + pub(crate) fn label_cost( + &self, + owner: i32, + lbl: &Rect, + label_of: impl Fn(i32) -> Option, + w: &MetricWeights, + ) -> f64 { + let area = rect_area(lbl); + if area <= 0.0 { + return 0.0; + } + let Some(&own_index) = self.index_of_uid.get(&owner) else { + return 0.0; + }; + let own = &self.nodes[own_index]; + // Only nodes whose reach meets this label or the owner's own shape + // (every pair the crowding term can charge involves one of the two) + // can contribute; the rest add exact zeros. Visiting the candidates in + // index order keeps every sum bit-identical to a full scan. + let query = grown(&merge_bounds(*lbl, own.shape), COMFORTABLE_CLEARANCE); + let mut covered = 0.0; + let mut crowding = 0.0; + for other in self + .node_grid + .query(&query) + .into_iter() + .map(|i| &self.nodes[i]) + .filter(|n| n.uid != owner) + { + let other_label = label_of(other.uid); + covered += rect_overlap_area(lbl, &other.shape); + if let Some(ol) = &other_label { + covered += rect_overlap_area(lbl, ol); + } + if own.is_cloud || other.is_cloud { continue; } - // Only extend through nodes strictly greater than the start (so the - // start is the minimum), not already on the path, within the length - // cap. - if next > start && !on_path.contains(&next) && path.len() < MAX_CYCLE_LEN { - dfs_cycles(graph, start, next, path, on_path, found); - if found.len() >= MAX_CYCLES { - break; + let (gap, _) = footprint_gap(own, Some(*lbl), other, other_label); + if gap < COMFORTABLE_CLEARANCE { + crowding += (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + } + } + let text = inset(lbl, LABEL_INSET); + let struck = self + .connector_grid + .query(&text) + .into_iter() + .map(|i| &self.connectors[i]); + w.label_overlap * covered.min(area) / area + + w.label_connector_overlap * label_strike_fraction(owner, lbl, struck) + + w.crowding * self.crowding_scale * crowding + } +} + +/// How far past a node's shape its reach extends beyond its label's size: the +/// crowding clearance plus room for the label's offset from the shape. +const REACH_PAD: f64 = 2.0 * COMFORTABLE_CLEARANCE; + +/// Cell size of [`SceneGrid`]: about a node with its label. +const SCENE_GRID_CELL: f64 = 96.0; + +/// A uniform grid of item indices by the cells their rects cover. +#[derive(Default)] +struct SceneGrid { + cells: HashMap<(i64, i64), Vec>, +} + +impl SceneGrid { + fn cell_range(r: &Rect) -> (std::ops::RangeInclusive, std::ops::RangeInclusive) { + let cell = |v: f64| (v / SCENE_GRID_CELL).floor() as i64; + (cell(r.left)..=cell(r.right), cell(r.top)..=cell(r.bottom)) + } + + fn insert(&mut self, index: usize, r: &Rect) { + let (xs, ys) = Self::cell_range(r); + for x in xs { + for y in ys.clone() { + self.cells.entry((x, y)).or_default().push(index); + } + } + } + + /// Every item whose rect's cells meet `r`'s, each once, in index order. + fn query(&self, r: &Rect) -> Vec { + let (xs, ys) = Self::cell_range(r); + let mut out = Vec::new(); + for x in xs { + for y in ys.clone() { + if let Some(items) = self.cells.get(&(x, y)) { + out.extend_from_slice(items); } } } + out.sort_unstable(); + out.dedup(); + out } +} - on_path.remove(¤t); - path.pop(); +fn grown(r: &Rect, d: f64) -> Rect { + inset(r, -d) } -/// Rotate a cycle so its smallest uid is first, preserving traversal direction. -/// The DFS already guarantees the start (= minimum) is element 0, but rotating -/// defensively keeps the canonical form correct for any caller. -/// -/// Note: this canonicalizes rotation (start at min uid) but NOT traversal -/// direction, so a directed cycle and its reverse canonicalize to distinct -/// entries. That is harmless: a reverse-direction duplicate (essentially never -/// present for directed SD feedback loops, which would require both directed -/// edge sets in the graph) would compute the same isoperimetric penalty because -/// the shoelace polygon area in `cycle_penalty` is direction-invariant. -fn canonicalize_cycle(cycle: &[i32]) -> Vec { - if cycle.is_empty() { - return Vec::new(); +/// `crossings`: crossings per connector, on the drawn polylines. +fn crossings_term( + view: &datamodel::StockFlow, + connector_count: usize, + sink: &mut DefectSink, +) -> f64 { + if connector_count == 0 { + return 0.0; } - let min_idx = cycle - .iter() - .enumerate() - .min_by_key(|&(_, v)| *v) - .map(|(i, _)| i) - .unwrap_or(0); - let mut out = Vec::with_capacity(cycle.len()); - for k in 0..cycle.len() { - out.push(cycle[(min_idx + k) % cycle.len()]); + let segments = build_view_segments(view); + let mut count = 0usize; + for i in 0..segments.len() { + for j in (i + 1)..segments.len() { + if let Some(p) = segment_intersection(&segments[i], &segments[j]) { + count += 1; + sink.push_point(DefectKind::Crossing, Point { x: p.x, y: p.y }, 1.0); + } + } } - out + count as f64 / connector_count as f64 } -/// Isoperimetric penalty `1 - Q` for one cycle's node-box centers, or `None` if -/// the cycle does not qualify (fewer than 3 distinct positioned nodes, or a -/// degenerate zero-perimeter polygon). `Q = 4*PI*Area / Perimeter^2` is clamped -/// to [0, 1]; `Area` is the shoelace area (absolute value) and `Perimeter` the -/// summed edge length over the closed polygon. -fn cycle_penalty(cycle: &[i32], centers: &BTreeMap) -> Option { - // Distinct positioned nodes only: a polygon needs >= 3 vertices. - let distinct: BTreeSet = cycle.iter().copied().collect(); - if distinct.len() < 3 { - return None; +/// `crowding`: the mean clearance deficit per node over pairs of non-cloud +/// nodes whose footprints come closer than `COMFORTABLE_CLEARANCE`, plus the +/// mean deficit per link over links whose visible length (outside both +/// endpoint shapes) falls below `MIN_VISIBLE_LINK`. +fn crowding_term( + nodes: &[SceneNode], + connectors: &[ConnectorGeometry], + sink: &mut DefectSink, +) -> f64 { + if nodes.len() < 2 { + return 0.0; } - let pts: Vec = cycle + let shapes: HashMap = nodes.iter().map(|n| (n.uid, n.shape)).collect(); + let links: Vec<&ConnectorGeometry> = connectors .iter() - .filter_map(|uid| centers.get(uid).copied()) + .filter(|c| c.kind == ConnectorKind::Link) .collect(); - if pts.len() < 3 { - return None; + let mut short = 0.0; + for c in &links { + let hidden: f64 = c + .incident_uids + .iter() + .filter_map(|uid| shapes.get(uid)) + .map(|shape| { + c.polyline + .windows(2) + .filter_map(|seg| { + segment_clip_interval_in_rect(&seg[0], &seg[1], shape).map(|(t0, t1)| { + (t1 - t0) + * ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)) + .sqrt() + }) + }) + .sum::() + }) + .sum(); + let visible = (c.length - hidden).max(0.0); + if visible < MIN_VISIBLE_LINK { + let deficit = (1.0 - visible / MIN_VISIBLE_LINK).powi(2); + short += deficit; + let mid = c.polyline[c.polyline.len() / 2]; + sink.push_point(DefectKind::Crowded, mid, deficit); + } } - - let n = pts.len(); - let mut area2 = 0.0; - let mut perimeter = 0.0; - for i in 0..n { - let a = pts[i]; - let b = pts[(i + 1) % n]; - area2 += a.x * b.y - b.x * a.y; - let dx = b.x - a.x; - let dy = b.y - a.y; - perimeter += (dx * dx + dy * dy).sqrt(); + let short_rate = if links.is_empty() { + 0.0 + } else { + short / links.len() as f64 + }; + let boxes: Vec = nodes.iter().map(SceneNode::footprint_box).collect(); + let mut total = 0.0; + for i in 0..nodes.len() { + for j in (i + 1)..nodes.len() { + if nodes[i].is_cloud || nodes[j].is_cloud { + continue; + } + // Cheap reject: the merged boxes are already comfortably apart. + if rect_gap(&boxes[i], &boxes[j]) >= COMFORTABLE_CLEARANCE { + continue; + } + let (gap, closest) = + footprint_gap(&nodes[i], nodes[i].label, &nodes[j], nodes[j].label); + if gap < COMFORTABLE_CLEARANCE { + let deficit = (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + total += deficit; + sink.push( + DefectKind::Crowded, + merge_bounds(closest.0, closest.1), + deficit, + ); + } + } } - if perimeter <= 0.0 { - // All centers coincide: no polygon. Guarded so the division below is - // never NaN; such a degenerate cycle simply does not contribute. - return None; + total / nodes.len() as f64 + short_rate +} + +/// The clearance between two nodes' footprints -- each one's shape and its +/// label box `*_label` -- as `crowding` measures it, with the two rects that +/// realize it. A flow's valve sits a fixed short pipe away from the stock or +/// cloud it attaches to by construction: their SHAPES being close is structure, +/// but either one's label crowding the other is not. +fn footprint_gap( + a: &SceneNode, + a_label: Option, + b: &SceneNode, + b_label: Option, +) -> (f64, (Rect, Rect)) { + let attached = a.attached_to(b); + let a_rects = [Some((false, a.shape)), a_label.map(|l| (true, l))]; + let b_rects = [Some((false, b.shape)), b_label.map(|l| (true, l))]; + let mut gap = f64::INFINITY; + let mut closest = (a.shape, b.shape); + for &(a_is_label, ra) in a_rects.iter().flatten() { + for &(b_is_label, rb) in b_rects.iter().flatten() { + if attached && !a_is_label && !b_is_label { + continue; + } + let g = rect_gap(&ra, &rb); + if g < gap { + gap = g; + closest = (ra, rb); + } + } } - let area = area2.abs() / 2.0; - let q = (4.0 * std::f64::consts::PI * area / (perimeter * perimeter)).clamp(0.0, 1.0); - Some(1.0 - q) + (gap, closest) } -/// `loop_compactness`: mean isoperimetric penalty `1 - Q` over the view's -/// bounded simple directed cycles of >= 3 positioned nodes. 0.0 when there is no -/// qualifying cycle. Deterministic for a given view regardless of element order -/// (see the module comment above). PURE. -fn compute_loop_compactness(view: &datamodel::StockFlow) -> f64 { - let graph = build_loop_graph(view); - let cycles = enumerate_simple_cycles(&graph); - let penalties: Vec = cycles +/// `long_connectors`: mean excess of each link over `LONG_CONNECTOR_FACTOR` +/// times the median link length. +fn long_connectors_term(connectors: &[ConnectorGeometry], sink: &mut DefectSink) -> f64 { + let links: Vec<&ConnectorGeometry> = connectors .iter() - .filter_map(|c| cycle_penalty(c, &graph.centers)) + .filter(|c| c.kind == ConnectorKind::Link) .collect(); - if penalties.is_empty() { - 0.0 + if links.len() < 2 { + return 0.0; + } + let mut lengths: Vec = links.iter().map(|c| c.length).collect(); + lengths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = lengths.len() / 2; + let median = if lengths.len().is_multiple_of(2) { + (lengths[mid - 1] + lengths[mid]) / 2.0 } else { - penalties.iter().sum::() / penalties.len() as f64 + lengths[mid] + }; + let threshold = LONG_CONNECTOR_FACTOR * median.max(1.0); + let mut total = 0.0; + for c in &links { + let excess = (c.length / threshold - 1.0).max(0.0); + if excess > 0.0 { + total += excess; + let mid_point = c.polyline[c.polyline.len() / 2]; + sink.push_point(DefectKind::LongConnector, mid_point, excess); + } } + total / links.len() as f64 } -// --- loop_straightness (are feedback-loop connectors drawn as visible curves) - -// -// loop_compactness above scores how circular a loop's NODE arrangement is, but -// not whether the connectors between those nodes are actually drawn as arcs. A -// loop can have well-spread node centers yet still read as a zig-zag if its -// causal connectors are straight chords. This term measures exactly that: the -// shortfall of each loop connector's drawn curvature below a target bow. It is -// primarily a Goodhart guard -- `apply_loop_curvature` curves loop connectors -// deterministically, so a healthy layout scores ~0; if any future change flattens -// loop connectors, this term (and the metric) rises, so the optimizer can never -// trade away the curvature that makes a loop legible. Flow pipes in a loop are -// exempt: they are orthogonal by convention, never arced. +/// `misalignment`: fraction of nodes sharing no row or column with a nearby +/// node. +fn misalignment_term(nodes: &[SceneNode]) -> f64 { + if nodes.len() < 2 { + return 0.0; + } + let aligned = nodes + .iter() + .filter(|a| { + nodes.iter().any(|b| { + if a.uid == b.uid { + return false; + } + let dx = (a.center.x - b.center.x).abs(); + let dy = (a.center.y - b.center.y).abs(); + (dx <= ALIGN_TOLERANCE && dy <= ALIGN_REACH) + || (dy <= ALIGN_TOLERANCE && dx <= ALIGN_REACH) + }) + }) + .count(); + 1.0 - aligned as f64 / nodes.len() as f64 +} -/// Target bow ratio (max perpendicular deviation / chord length) for a loop's -/// causal connectors. A quarter-circle arc -- a clearly visible loop curve -- -/// has a bow of ~0.21; 0.15 treats moderate curvature as "enough" so the term -/// only fires on connectors drawn (near-)straight. -const LOOP_LINK_TARGET_BOW: f64 = 0.15; - -/// Maximum perpendicular deviation of a polyline from its straight chord -/// (first -> last point), divided by the chord length. 0 for a straight two-point -/// line; ~0.21 for a quarter-circle arc. Returns 0 for a degenerate (near-zero) -/// chord so the ratio is always finite. -fn polyline_bow_ratio(polyline: &[Point]) -> f64 { - if polyline.len() < 3 { - return 0.0; - } - let a = polyline[0]; - let b = polyline[polyline.len() - 1]; - let cx = b.x - a.x; - let cy = b.y - a.y; - let chord = (cx * cx + cy * cy).sqrt(); - if chord < 1e-9 { - return 0.0; - } - let mut max_perp = 0.0_f64; - for p in &polyline[1..polyline.len() - 1] { - // Perpendicular distance from p to the infinite line through a,b. - let perp = (cx * (a.y - p.y) - cy * (a.x - p.x)).abs() / chord; - max_perp = max_perp.max(perp); - } - max_perp / chord -} - -/// Map each directed causal connector (Link) `from_uid -> to_uid` to the polyline -/// the renderer draws for it, so loop-straightness can look up the drawn -/// curvature of a loop edge. Flows are not included (loop edges through a flow -/// valve have no Link and are correctly skipped). -fn link_polylines( - view: &datamodel::StockFlow, -) -> std::collections::HashMap<(i32, i32), Vec> { - let mut uid_elements: std::collections::HashMap = - std::collections::HashMap::new(); - for elem in &view.elements { - uid_elements.insert(elem.get_uid(), elem); - } - let not_arrayed = |_: &str| false; - let mut out: std::collections::HashMap<(i32, i32), Vec> = - std::collections::HashMap::new(); - for elem in &view.elements { - if let ViewElement::Link(link) = elem - && let (Some(&from), Some(&to)) = ( - uid_elements.get(&link.from_uid), - uid_elements.get(&link.to_uid), - ) - { - let polyline = connector_polyline(link, from, to, ¬_arrayed, ARC_POLYLINE_SAMPLES); - if polyline.len() >= 2 { - out.insert((link.from_uid, link.to_uid), polyline); - } - } - } - out -} - -/// `loop_straightness`: mean bow shortfall over the causal connectors that -/// participate in a feedback loop. 0.0 = every loop connector is drawn with at -/// least the target curvature (the loop reads as a visible circle); 1.0 = loop -/// connectors are straight (the loop collapses to a zig-zag). 0.0 when the view -/// has no loop with a causal connector. Deterministic and PURE; reuses the same -/// loop graph / cycle enumeration as loop_compactness. -fn compute_loop_straightness(view: &datamodel::StockFlow) -> f64 { - let graph = build_loop_graph(view); - let cycles = enumerate_simple_cycles(&graph); - if cycles.is_empty() { - return 0.0; - } - let polys = link_polylines(view); - let mut seen: HashSet<(i32, i32)> = HashSet::new(); - let mut total = 0.0; - let mut count = 0usize; - for cycle in &cycles { - let n = cycle.len(); - for k in 0..n { - let edge = (cycle[k], cycle[(k + 1) % n]); - let Some(poly) = polys.get(&edge) else { - continue; // a flow-pipe edge (no Link): exempt - }; - if !seen.insert(edge) { - continue; // count each loop connector once - } - let bow = polyline_bow_ratio(poly); - let shortfall = (LOOP_LINK_TARGET_BOW - bow).max(0.0) / LOOP_LINK_TARGET_BOW; - total += shortfall; - count += 1; - } - } - if count == 0 { - 0.0 - } else { - total / count as f64 - } +/// Union of rects, or `None` for an empty set. +fn view_bounding_box(boxes: &[Rect]) -> Option { + let mut iter = boxes.iter(); + let first = *iter.next()?; + Some(iter.fold(first, |acc, r| merge_bounds(acc, *r))) } /// Compute the layout quality metrics for a completed view. /// /// PURE: takes data, returns scalars, performs no I/O. The `_config` parameter -/// is kept to match the design's optimizer-facing signature and for forward -/// compatibility; the box geometry is sourced entirely from the `diagram` -/// helpers (which use fixed pixel element sizes), so the config is presently -/// unused. Every term is guaranteed finite (each division guards a zero -/// denominator by returning 0), so empty and single-element views yield -/// all-zero, NaN-free metrics. +/// is kept for the optimizer-facing signature; all geometry comes from the +/// `diagram` helpers (fixed pixel element sizes). Every term is finite: each +/// division guards a zero denominator by returning 0. pub fn compute_layout_metrics( view: &datamodel::StockFlow, _config: &LayoutConfig, ) -> LayoutMetrics { - // --- node boxes (with their owning element for incidence checks) --- - // - // Two box sets, used by different terms: - // * `node_boxes` is the LABEL-MERGED box (`node_box`): each element's own - // label unioned into its shape. The view's visual extent and its - // characteristic node size both include labels, so `sprawl` and - // `aspect_penalty` use this set. - // * `node_shape_boxes` is the bare SHAPE box (`node_shape_box`): - // label-free. `node_overlap` and `node_connector_overlap` use this set - // so they measure exactly what the user cares about -- node SHAPES - // overlapping other node shapes, and a connector passing under a node - // SHAPE (a false-causal-connection at a glance). A connector passing - // only under a node's LABEL is mild noise (labels are semi-transparent - // and no connector terminates on one) and must NOT be charged here; - // label collisions are the province of `label_overlap`. - // Aliases render an aux circle + their source element's label; resolve - // those names once so aliases are charged like any other node (an - // invisible alias would let alias generation game the score). - let alias_names = alias_source_names(&view.elements); - let alias_node_box = |a: &crate::datamodel::view_element::Alias| -> Rect { - let shape = alias_shape_box(a); - match alias_names.get(&a.uid) { - Some(name) => { - let props = alias_label_props_for(a, name, a.label_side); - merge_bounds(shape, label_bounds(&props)) - } - // Dangling alias: the circle still renders; no label. - None => shape, - } - }; - - let node_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Alias(a) => Some((a.uid, alias_node_box(a))), - _ => node_box(e).map(|r| (e.get_uid(), r)), - }) - .collect(); - let node_shape_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| node_shape_box(e).map(|r| (e.get_uid(), r))) - .collect(); + analyze(view, &mut DefectSink { defects: None }) +} - // --- node_overlap (bare shape boxes, normalized by total shape-box area) --- - let total_shape_area: f64 = node_shape_boxes.iter().map(|(_, r)| rect_area(r)).sum(); - let node_overlap = if total_shape_area > 0.0 { - let mut overlap = 0.0; - for i in 0..node_shape_boxes.len() { - for j in (i + 1)..node_shape_boxes.len() { - overlap += rect_overlap_area(&node_shape_boxes[i].1, &node_shape_boxes[j].1); - } - } - overlap / total_shape_area - } else { - 0.0 +/// The metrics of `view` plus every defect behind them, located on the diagram. +/// Computed by the same code as [`compute_layout_metrics`]. +pub fn analyze_layout(view: &datamodel::StockFlow) -> LayoutAnalysis { + let mut sink = DefectSink { + defects: Some(Vec::new()), }; + let metrics = analyze(view, &mut sink); + LayoutAnalysis { + metrics, + defects: sink.defects.unwrap_or_default(), + } +} - // --- connector geometry (shared by several terms) --- - let connectors = collect_connector_geometry(view); - let total_connector_length: f64 = connectors.iter().map(|c| c.length).sum(); - - // --- node_connector_overlap (length inside non-incident shape boxes) --- - // - // Documented as a "fraction of total connector length", so each physical - // sub-length of connector covered by ANY non-incident node shape box must be - // counted AT MOST ONCE. Summing the per-box clipped length double-counts the - // region where two non-incident boxes overlap, which can push the normalized - // value above 1.0 (overlapping shape boxes are common -- a Flow's shape box is - // its whole-pipe bounding box, which frequently overlaps stocks/auxes/other - // flows). Instead, for EACH segment we collect the clip intervals over all - // non-incident boxes and UNION them (merge overlapping/adjacent intervals) - // before summing, so each covered sub-length contributes once and the term is - // a true fraction in [0, 1]. The per-segment merge result is order-independent, - // so this is deterministic regardless of `node_shape_boxes` iteration order. - let node_connector_overlap = if total_connector_length > 0.0 { - let mut inside = 0.0; - for c in &connectors { - for seg in c.polyline.windows(2) { - let dx = seg[1].x - seg[0].x; - let dy = seg[1].y - seg[0].y; - let seg_len = (dx * dx + dy * dy).sqrt(); - if seg_len == 0.0 { - continue; // degenerate segment covers no length - } - // Clip interval [t0, t1] of this segment within each non-incident - // box, in segment-parameter space (t in [0,1]). - let mut intervals: Vec<(f64, f64)> = Vec::new(); - for (uid, rect) in &node_shape_boxes { - if c.incident_uids.contains(uid) { - continue; // skip the connector's own endpoints - } - if let Some(iv) = segment_clip_interval_in_rect(&seg[0], &seg[1], rect) { - intervals.push(iv); - } - } - inside += merged_interval_length(&mut intervals) * seg_len; - } - } - inside / total_connector_length - } else { - 0.0 - }; +fn analyze(view: &datamodel::StockFlow, sink: &mut DefectSink) -> LayoutMetrics { + let nodes = build_scene_nodes(&view.elements); + let connectors = collect_connector_geometry(&view.elements); - // --- label_overlap (per-label obscuration) --- - // - // For each labeled element L, measure how much of its label box B_L is - // covered (obscured) by OTHER drawn geometry, then SUM each label's obscured - // fraction. This is per-label rather than a single corpus-wide ratio: a - // small-but-readability-killing overlap (e.g. a node circle clipping the last - // two characters of a short label) registers at its true obscuration - // fraction instead of being diluted to ~0 by the corpus's total label area - // (the prior `sum_of_overlaps / total_label_area` definition under-counted - // exactly this case). - // - // The coverers of B_L are (a) any OTHER label box and (b) any OTHER element's - // bare *shape* box (`node_shape_box`, NOT the label-merged `node_box`): - // * A label is never charged against its OWN element's shape box. By - // construction a label sits adjacent to (and within the merged bounds of) - // its own element, so charging it there would always add a constant that - // is not a real collision. - // * Comparing against the bare shape box (not the label-merged box) keeps - // "label lands on another label" and "label lands on another node's - // shape" cleanly separate -- the merged box unions that node's own label, - // which would re-count the label-vs-label coverage already captured by - // the label-box term. - // - // A pixel-exact union of all coverers is unnecessary: the covered area is - // approximated by the SUM of individual overlap areas, capped at area(B_L) so - // a label's obscured fraction stays in [0,1] even when coverers overlap each - // other. This is a monotone proxy (more/larger overlaps never decrease the - // fraction). A mutual label-label collision is charged from BOTH labels' - // perspectives -- intended, since both are unreadable. Guards area(B_L) == 0 - // (degenerate label) by skipping it, so the term is always finite. - let label_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Alias(a) => alias_names.get(&a.uid).map(|name| { - let props = alias_label_props_for(a, name, a.label_side); - (a.uid, label_bounds(&props)) - }), - _ => element_label_props(e).map(|props| (e.get_uid(), label_bounds(&props))), - }) - .collect(); - // `node_shape_boxes` is computed once above (shared with node_overlap and - // node_connector_overlap). - let mut label_overlap = 0.0; - for (lbl_uid, lbl) in &label_boxes { - let lbl_area = rect_area(lbl); - if lbl_area <= 0.0 { - continue; // degenerate label box: no NaN, contributes nothing - } - let mut covered = 0.0; - // Covered by every OTHER label box. - for (other_uid, other) in &label_boxes { - if other_uid == lbl_uid { - continue; - } - covered += rect_overlap_area(lbl, other); - } - // Covered by every OTHER element's bare shape box. - for (node_uid, node) in &node_shape_boxes { - if node_uid == lbl_uid { - continue; - } - covered += rect_overlap_area(lbl, node); - } - // Cap the (possibly over-counted) covered area at the label's own area - // so the obscured fraction is in [0,1]. - let obscured_fraction = (covered.min(lbl_area)) / lbl_area; - label_overlap += obscured_fraction; - } + let node_overlap = node_overlap_term(&nodes, sink); + let node_connector_overlap = node_connector_overlap_term(&nodes, &connectors, sink); + let label_overlap = label_overlap_term(&nodes, sink); + let label_connector_overlap = label_connector_overlap_term(&nodes, &connectors, sink); + let crossings = crossings_term(view, connectors.len(), sink); + let crowding = crowding_term(&nodes, &connectors, sink); + let long_connectors = long_connectors_term(&connectors, sink); + let misalignment = misalignment_term(&nodes); - // --- crossings --- - let connector_count = connectors.len(); - let crossings = if connector_count > 0 { - count_crossings(&build_view_segments(view)) as f64 / connector_count as f64 - } else { - 0.0 - }; + let footprints: Vec = nodes.iter().map(SceneNode::footprint_box).collect(); + let total_connector_length: f64 = connectors.iter().map(|c| c.length).sum(); // --- sprawl --- - let sprawl = if !connectors.is_empty() && !node_boxes.is_empty() { + let sprawl = if !connectors.is_empty() && !footprints.is_empty() { let mean_connector_length = total_connector_length / connectors.len() as f64; - let characteristic_node_size = node_boxes + let characteristic_node_size = footprints .iter() - .map(|(_, r)| { + .map(|r| { let w = common::rect_width(r); let h = common::rect_height(r); (w * w + h * h).sqrt() }) .sum::() - / node_boxes.len() as f64; + / footprints.len() as f64; if characteristic_node_size > 0.0 { mean_connector_length / characteristic_node_size } else { @@ -1100,12 +1338,9 @@ pub fn compute_layout_metrics( if mean > 0.0 { let variance = connectors .iter() - .map(|c| { - let d = c.length - mean; - d * d - }) + .map(|c| (c.length - mean).powi(2)) .sum::() - / n; // population variance + / n; variance.sqrt() / mean } else { 0.0 @@ -1114,12 +1349,8 @@ pub fn compute_layout_metrics( 0.0 }; - // --- aspect_penalty --- - // Bounding box over node boxes (union). The aspect ratio is the long side - // over the short side (always >= 1); we penalize the amount by which it - // exceeds the target band. Chosen formula: `ar - TARGET_AR_MAX` (a plain - // unit-of-ratio overshoot). Documented here and matched in the AC1.5 test. - let aspect_penalty = match view_bounding_box(&node_boxes) { + // --- aspect_penalty: long side over short side, beyond the target band --- + let aspect_penalty = match view_bounding_box(&footprints) { Some(bbox) => { let w = common::rect_width(&bbox); let h = common::rect_height(&bbox); @@ -1127,17 +1358,13 @@ pub fn compute_layout_metrics( if short <= 0.0 { 0.0 } else { - let ar = long / short; - (ar - TARGET_AR_MAX).max(0.0) + (long / short - TARGET_AR_MAX).max(0.0) } } None => 0.0, }; - // --- loop_compactness (isoperimetric feedback-loop quality) --- let loop_compactness = compute_loop_compactness(view); - - // --- loop_straightness (loop connectors drawn as visible curves) --- let loop_straightness = compute_loop_straightness(view); // --- flow_bends (mean right-angle bends per flow pipe) --- @@ -1161,1830 +1388,414 @@ pub fn compute_layout_metrics( node_overlap, node_connector_overlap, label_overlap, + label_connector_overlap, crossings, + crowding, sprawl, + long_connectors, edge_length_cv, aspect_penalty, - // reserved; computed in a future rung - chain_straightness: 0.0, + misalignment, loop_compactness, flow_bends, loop_straightness, } } -/// Union of the node boxes, or `None` if there are no node boxes. -fn view_bounding_box(node_boxes: &[(i32, Rect)]) -> Option { - let mut iter = node_boxes.iter(); - let first = iter.next()?.1; - Some(iter.fold(first, |acc, (_, r)| merge_bounds(acc, *r))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::datamodel::view_element::{self, LabelSide, LinkShape}; - // `segment_length_in_rect` is the simple single-box clip; the AC1.3 tests and - // the union tests use it as an independent reference oracle to cross-check the - // production union path (which composes `segment_clip_interval_in_rect`). - use crate::diagram::common::segment_length_in_rect; - use crate::diagram::constants::STOCK_WIDTH; - use proptest::prelude::*; - - // --- fixture helpers --- - - fn stock(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { - ViewElement::Stock(view_element::Stock { - name: name.to_string(), - uid, - x, - y, - label_side: LabelSide::Bottom, - compat: None, - }) - } - - fn aux(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { - ViewElement::Aux(view_element::Aux { - name: name.to_string(), - uid, - x, - y, - label_side: LabelSide::Bottom, - compat: None, - }) - } - - /// A cloud at `(x, y)`. A cloud is a positioned node with a bare shape box - /// (`cloud_bounds`, a 27x27 square: CLOUD_RADIUS = 13.5) and NO rendered - /// label, so it is the cleanest "obscuring shape" fixture for label_overlap. - fn cloud(uid: i32, x: f64, y: f64) -> ViewElement { - ViewElement::Cloud(view_element::Cloud { - uid, - flow_uid: -1, - x, - y, - compat: None, - }) - } +// --- loop_compactness (isoperimetric feedback-loop quality) ----------------- +// +// What it measures: how cleanly the view draws its feedback loops as visible +// circles. For each simple directed cycle of >= 3 positioned nodes we take the +// node-box centers in cycle order and form a polygon. Its isoperimetric +// quotient Q = 4*PI*Area / Perimeter^2 is 1 for a perfect circle and tends to 0 +// as the polygon collapses toward a line (the area vanishes while the perimeter +// stays large). The per-cycle penalty is `1 - Q` (0 = ideal clean loop, ~1 = +// squished/collinear), and `loop_compactness` is the mean penalty over all +// qualifying cycles (0.0 when the view has no cycle of >= 3 nodes). It thus +// REWARDS well-spread loops and PENALIZES collapsed ones. +// +// Bounds (SD diagrams are small, so this stays O(small) and total): a simple +// cycle is enumerated only up to `MAX_CYCLE_LEN` nodes, and at most +// `MAX_CYCLES` cycles are scored; enumeration stops once the cap is hit. The +// graph is built over positioned node-box elements (aux/stock/flow/module/cloud +// -- the same set as `node_box`); links and flows supply the directed edges. +// +// Determinism: layout is deterministic per seed, but this term is additionally +// independent of element ordering. Adjacency targets are sorted, the DFS starts +// from each node in sorted uid order, and every enumerated cycle is canonicalized +// (rotated so its smallest uid is first) and de-duplicated, so the mean is the +// same regardless of how the elements are listed in the view. - fn straight_link(uid: i32, from_uid: i32, to_uid: i32) -> ViewElement { - ViewElement::Link(view_element::Link { - uid, - from_uid, - to_uid, - shape: LinkShape::Straight, - polarity: None, - }) - } +/// Maximum number of nodes in an enumerated simple cycle. SD feedback loops are +/// short; a longer "cycle" is almost always an artifact of many overlapping +/// smaller loops and is not worth the combinatorial cost. +const MAX_CYCLE_LEN: usize = 12; - fn arc_link(uid: i32, from_uid: i32, to_uid: i32, angle: f64) -> ViewElement { - ViewElement::Link(view_element::Link { - uid, - from_uid, - to_uid, - shape: LinkShape::Arc(angle), - polarity: None, - }) - } +/// Maximum number of distinct simple cycles scored. Bounds the work on dense +/// graphs; the mean penalty over the first `MAX_CYCLES` cycles is a faithful +/// proxy for the whole (SD diagrams rarely approach this). +const MAX_CYCLES: usize = 64; - /// A flow valve at `(x, y)` with a two-point polyline whose endpoints attach - /// to `from_uid` and `to_uid` (a stock--flow--stock segment). The point - /// coordinates are irrelevant to `loop_compactness` (which uses node-box - /// centers, not flow points), so they are placed at the valve. - fn flow_between( - uid: i32, - name: &str, - x: f64, - y: f64, - from_uid: i32, - to_uid: i32, - ) -> ViewElement { - ViewElement::Flow(view_element::Flow { - name: name.to_string(), - uid, - x, - y, - label_side: LabelSide::Bottom, - points: vec![ - view_element::FlowPoint { - x, - y, - attached_to_uid: Some(from_uid), - }, - view_element::FlowPoint { - x, - y, - attached_to_uid: Some(to_uid), - }, - ], - compat: None, - label_compat: None, - }) - } +/// Directed adjacency over positioned node-box elements, keyed by uid with +/// sorted successor lists. Each node's loop vertex is the renderer's VISUAL +/// center (`diagram::connector::get_visual_center`) -- for a flow that is its +/// VALVE `(flow.x, flow.y)`, NOT the pipe-extent center of `flow_shape_bounds` +/// (which unions the valve box with every pipe point and so drifts off the valve +/// when the pipe is bent or the valve is dragged off-center); for an +/// aux/stock/module/cloud it is the element center, which already equals the +/// symmetric shape-box midpoint. Using the same visual center the SVG renderer +/// draws keeps the loop polygon faithful to the drawn diagram. +struct LoopGraph { + /// uid -> sorted, de-duplicated successor uids. + adj: BTreeMap>, + /// uid -> node visual-center point (the valve for flows; the element center + /// for aux/stock/module/cloud). + centers: BTreeMap, +} - fn make_view(elements: Vec) -> datamodel::StockFlow { - datamodel::StockFlow { - name: None, - elements, - view_box: datamodel::Rect { - x: 0.0, - y: 0.0, - width: 1000.0, - height: 1000.0, - }, - zoom: 1.0, - use_lettered_polarity: false, - font: None, - sketch_compat: None, +/// Build the directed loop graph from the view. Nodes are exactly the elements +/// with a node box (`node_shape_box` -- aux/stock/module/cloud/flow; links, +/// aliases, and groups are excluded). Each node's loop vertex is the renderer's +/// VISUAL center (`get_visual_center`), so a flow's vertex is its VALVE +/// `(flow.x, flow.y)`, NOT the pipe-extent center of `flow_shape_bounds` (the +/// valve box unioned with every pipe point), which drifts off the valve when the +/// pipe is bent or the valve is dragged off-center. For aux/stock/module/cloud +/// the visual center is the element center, which already equals the symmetric +/// shape-box midpoint, so those vertices are unchanged. Edges to/from uids that +/// are not positioned nodes are dropped. Edges come from: +/// * each Link: `from_uid -> to_uid`; +/// * each Flow: for consecutive attached points, `source_attached -> flow.uid` +/// and `flow.uid -> dest_attached`, so a stock--flow--stock feedback path is +/// part of the graph (the flow's own valve is the intermediate node). +fn build_loop_graph(view: &datamodel::StockFlow) -> LoopGraph { + // The node-membership gate stays `node_shape_box` (it defines which elements + // are loop nodes), but the loop VERTEX is the renderer's visual center, which + // is correct for every gated kind: the valve for a flow, the element center + // for aux/stock/module/cloud. `not_arrayed` matches `collect_connector_geometry` + // / `build_view_segments` (offset 0, deterministic). + let not_arrayed = |_: &str| false; + let mut centers: BTreeMap = BTreeMap::new(); + for e in &view.elements { + if node_shape_box(e).is_some() { + let (cx, cy) = get_visual_center(e, ¬_arrayed); + centers.insert(e.get_uid(), Point { x: cx, y: cy }); } } - fn cfg() -> LayoutConfig { - LayoutConfig::default() - } - - /// An alias (ghost) of the element with uid `alias_of_uid`, at `(x, y)`. - /// Renders as an aux-sized circle labeled with the SOURCE element's name. - fn alias_of(uid: i32, alias_of_uid: i32, x: f64, y: f64) -> ViewElement { - ViewElement::Alias(view_element::Alias { - uid, - alias_of_uid, - x, - y, - label_side: LabelSide::Bottom, - compat: None, - }) - } - - // --- alias scoring --- - // - // An alias renders as an aux-sized circle plus its source element's label; - // the metric must charge it like any other node. If aliases were invisible - // (the pre-rung-4 state), alias GENERATION could game the score: ghosts - // could pile on top of anything for free. - - #[test] - fn test_alias_node_overlap_charged() { - // An alias stacked exactly on an aux vs the same alias far away: the - // stacked layout must score strictly worse on node_overlap. - let stacked = make_view(vec![ - aux(1, "source variable", 100.0, 100.0), - aux(2, "another aux", 300.0, 100.0), - alias_of(3, 1, 300.0, 100.0), - ]); - let apart = make_view(vec![ - aux(1, "source variable", 100.0, 100.0), - aux(2, "another aux", 300.0, 100.0), - alias_of(3, 1, 600.0, 100.0), - ]); - let m_stacked = compute_layout_metrics(&stacked, &cfg()); - let m_apart = compute_layout_metrics(&apart, &cfg()); - assert!( - m_stacked.node_overlap > m_apart.node_overlap, - "an alias stacked on a node must be charged: stacked {} vs apart {}", - m_stacked.node_overlap, - m_apart.node_overlap, - ); - assert!( - m_apart.node_overlap.abs() < 1e-9, - "the far-apart alias layout has no overlap to charge" - ); - } - - #[test] - fn test_alias_label_sized_by_source_name() { - // The alias's label box is the SOURCE element's name. Two layouts with - // identical geometry, differing only in the source's name length: the - // long-named source's alias label must collide with a nearby aux's - // label while the short-named one's must not. - let dx = 80.0; - let long_name = make_view(vec![ - aux(1, "an extremely long variable name here", 100.0, 600.0), - aux(2, "consumer", 300.0, 100.0), - alias_of(3, 1, 300.0 + dx, 100.0), - ]); - let short_name = make_view(vec![ - aux(1, "x", 100.0, 600.0), - aux(2, "consumer", 300.0, 100.0), - alias_of(3, 1, 300.0 + dx, 100.0), - ]); - let m_long = compute_layout_metrics(&long_name, &cfg()); - let m_short = compute_layout_metrics(&short_name, &cfg()); - assert!( - m_long.label_overlap > m_short.label_overlap, - "a long source name must widen the alias label and collide: long {} vs short {}", - m_long.label_overlap, - m_short.label_overlap, - ); - } + // Collect edges into sorted sets per source so the adjacency is canonical + // (sorted, de-duplicated) and the cycle search is order-independent. + let mut edge_sets: BTreeMap> = BTreeMap::new(); + let mut add_edge = |from: i32, to: i32, centers: &BTreeMap| { + // Both endpoints must be positioned nodes, and we never record a + // self-loop (a single-node "cycle" forms no polygon). + if from != to && centers.contains_key(&from) && centers.contains_key(&to) { + edge_sets.entry(from).or_default().insert(to); + } + }; - #[test] - fn test_alias_with_dangling_source_is_ignored() { - // An alias whose alias_of_uid resolves to nothing (corrupt/partial - // view) must not panic and must not be charged a label. - let view = make_view(vec![ - aux(1, "real aux", 100.0, 100.0), - alias_of(2, 999, 100.0, 100.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - // The dangling alias still has a SHAPE (it renders a circle), so - // node_overlap is charged; but no label can be derived for it. - assert!(m.node_overlap > 0.0, "the alias circle still overlaps"); - assert!(m.label_overlap.is_finite()); - } - - #[test] - fn test_alias_extends_view_bounding_box() { - // A far-flung alias must extend the layout's bounding box (it is a - // drawn element), which shows up in the aspect_penalty/sprawl inputs. - // Compare a compact two-aux view against the same view plus an alias - // parked far to the right: the bounding box must widen. - let compact = make_view(vec![ - aux(1, "a", 100.0, 100.0), - aux(2, "b", 300.0, 100.0), - straight_link(10, 1, 2), - ]); - let with_far_alias = make_view(vec![ - aux(1, "a", 100.0, 100.0), - aux(2, "b", 300.0, 100.0), - straight_link(10, 1, 2), - alias_of(3, 1, 2000.0, 100.0), - ]); - let m_compact = compute_layout_metrics(&compact, &cfg()); - let m_far = compute_layout_metrics(&with_far_alias, &cfg()); - assert!( - m_far.aspect_penalty > m_compact.aspect_penalty, - "a far-flung alias must widen the bounding box and trip the aspect \ - penalty: with {} vs without {}", - m_far.aspect_penalty, - m_compact.aspect_penalty, - ); - } - - /// Scale every coordinate of a view by `s` (element centers and any - /// flow/connector points). Used by the AC1.8 scale-invariance test. - fn scale_view(view: &datamodel::StockFlow, s: f64) -> datamodel::StockFlow { - let elements = view - .elements - .iter() - .map(|e| match e { - ViewElement::Aux(a) => ViewElement::Aux(view_element::Aux { - x: a.x * s, - y: a.y * s, - ..a.clone() - }), - ViewElement::Stock(st) => ViewElement::Stock(view_element::Stock { - x: st.x * s, - y: st.y * s, - ..st.clone() - }), - ViewElement::Flow(f) => ViewElement::Flow(view_element::Flow { - x: f.x * s, - y: f.y * s, - points: f - .points - .iter() - .map(|p| view_element::FlowPoint { - x: p.x * s, - y: p.y * s, - attached_to_uid: p.attached_to_uid, - }) - .collect(), - ..f.clone() - }), - ViewElement::Module(m) => ViewElement::Module(view_element::Module { - x: m.x * s, - y: m.y * s, - ..m.clone() - }), - ViewElement::Cloud(c) => ViewElement::Cloud(view_element::Cloud { - x: c.x * s, - y: c.y * s, - ..c.clone() - }), - ViewElement::Alias(a) => ViewElement::Alias(view_element::Alias { - x: a.x * s, - y: a.y * s, - ..a.clone() - }), - other => other.clone(), - }) - .collect(); - datamodel::StockFlow { - elements, - ..view.clone() + for e in &view.elements { + match e { + ViewElement::Link(link) => { + add_edge(link.from_uid, link.to_uid, ¢ers); + } + ViewElement::Flow(flow) => { + // Consecutive attached points define stock->flow and flow->stock + // edges through the flow's own valve uid. + let attached: Vec = flow + .points + .iter() + .filter_map(|p| p.attached_to_uid) + .collect(); + for w in attached.windows(2) { + add_edge(w[0], flow.uid, ¢ers); + add_edge(flow.uid, w[1], ¢ers); + } + } + _ => {} } } - // --- AC1.1: node_overlap equals known overlap / total node area --- - - #[test] - fn test_node_overlap_known_overlap_fraction() { - // Two stocks (45x35) whose centers are 20px apart horizontally and at - // the same y. node_overlap is computed on the bare SHAPE boxes (not the - // label-merged boxes), so the expected value comes from - // `stock_shape_bounds` and is normalized by the total SHAPE-box area. - let s1 = stock(1, "a", 100.0, 100.0); - let s2 = stock(2, "b", 120.0, 100.0); - let view = make_view(vec![s1.clone(), s2.clone()]); - - let m = compute_layout_metrics(&view, &cfg()); - - // Expected: compute directly from the two bare shape boxes the renderer - // draws (the rects, label-free). - let b1 = node_shape_box(&s1).unwrap(); - let b2 = node_shape_box(&s2).unwrap(); - let expected_overlap = rect_overlap_area(&b1, &b2); - let expected_total = rect_area(&b1) + rect_area(&b2); - assert!(expected_overlap > 0.0, "fixture must actually overlap"); - let expected = expected_overlap / expected_total; - assert!( - (m.node_overlap - expected).abs() < 1e-9, - "node_overlap {} != expected {}", - m.node_overlap, - expected - ); - } - - #[test] - fn test_node_overlap_simple_hand_computed() { - // Two stocks with exactly one stock-width of horizontal center - // separation. node_overlap is a sum over the bare SHAPE boxes, so only - // the rects matter (labels are irrelevant to this term now). - let s1 = stock(1, "a", 0.0, 0.0); - let s2 = stock(2, "b", STOCK_WIDTH, 0.0); // centers exactly one width apart - let view = make_view(vec![s1, s2]); - let m = compute_layout_metrics(&view, &cfg()); - // Centers one full width apart -> the 45-wide shape boxes just touch in - // x (right edge of #1 at +22.5, left edge of #2 at +22.5): zero shape - // overlap. So node_overlap == 0. - assert_eq!(m.node_overlap, 0.0); - } - - // --- AC1.2: pairwise-disjoint nodes => node_overlap == 0 --- - - #[test] - fn test_node_overlap_disjoint_is_zero() { - let view = make_view(vec![ - stock(1, "a", 0.0, 0.0), - stock(2, "b", 500.0, 500.0), - aux(3, "c", 1000.0, 0.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.node_overlap, 0.0); - } - - // node_overlap is computed on the bare SHAPE boxes, NOT the label-merged - // boxes. The user cares about node shapes overlapping other node shapes; - // a label landing on another node's shape (or another label) is the - // province of `label_overlap`. This test distinguishes the two regimes and - // would FAIL against the prior label-merged-box implementation. - - #[test] - fn test_node_overlap_labels_overlap_shapes_disjoint_is_zero() { - // Two `LabelSide::Bottom` auxes named "samename" (8 chars), 40px apart - // horizontally at the same y -- the same fixture as the label_overlap - // double-count regression test: - // aux1 @ (0,0): shape [-9,9]x[-9,9], label [-29,29]x[13,27] - // aux2 @ (40,0): shape [31,49]x[-9,9], label [11,69]x[13,27] - // The SHAPE boxes are disjoint (9 < 31), so node_overlap == 0. The - // LABEL boxes overlap, but that collision belongs to label_overlap, not - // node_overlap. Under the old label-merged boxes node_overlap would be - // > 0 (the merged boxes [-29,29]x[-9,27] and [11,69]x[-9,27] overlap), - // so this assertion pins the new shape-only behavior. - let view = make_view(vec![ - aux(1, "samename", 0.0, 0.0), - aux(2, "samename", 40.0, 0.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!( - m.node_overlap, 0.0, - "node_overlap must ignore label-only overlap (shapes are disjoint)" - ); - // Sanity: the label collision IS captured by label_overlap, confirming - // the overlap was not simply lost. - assert!( - m.label_overlap > 0.0, - "the label-vs-label overlap must still be charged by label_overlap" - ); - } - - // --- AC1.3: node_connector_overlap --- - - #[test] - fn test_node_connector_overlap_through_third_node() { - // Connector from aux #1 (far left) to aux #2 (far right), passing - // horizontally through a stock #3 sitting on the line at the middle. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let mid = stock(3, "s", 200.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, mid, link]); - - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.node_connector_overlap > 0.0, - "connector passing through a non-incident stock must contribute" - ); + let adj: BTreeMap> = edge_sets + .into_iter() + .map(|(k, set)| (k, set.into_iter().collect())) + .collect(); + LoopGraph { adj, centers } +} - // Expected = clipped length inside the stock SHAPE box / total polyline - // len. node_connector_overlap charges against the bare shape box, not - // the label-merged box. (The connector is horizontal at y=0, so the - // clipped length happens to be identical to the label-merged box here; - // the SHAPE box is the contract regardless.) - let connectors = collect_connector_geometry(&view); - assert_eq!(connectors.len(), 1); - let c = &connectors[0]; - let stock_box = node_shape_box(&stock(3, "s", 200.0, 0.0)).unwrap(); - let mut inside = 0.0; - for seg in c.polyline.windows(2) { - inside += segment_length_in_rect(&seg[0], &seg[1], &stock_box); +/// Enumerate simple directed cycles (each >= 2 nodes), bounded by +/// `MAX_CYCLE_LEN` and `MAX_CYCLES`, canonicalized and de-duplicated so the same +/// directed cycle is returned exactly once regardless of where the search +/// started. A bounded DFS suffices: SD diagrams are tiny, and the caps keep it +/// O(small) on the rare dense graph. +/// +/// Each returned cycle is a `Vec` of uids in traversal order, rotated so +/// its smallest uid is first (canonical form), and the set of returned cycles is +/// itself sorted for a fully deterministic result. +fn enumerate_simple_cycles(graph: &LoopGraph) -> Vec> { + let mut found: BTreeSet> = BTreeSet::new(); + // Start a DFS from each node in sorted uid order. To avoid re-finding the + // same cycle from each of its members we still canonicalize+dedup, but we + // also restrict each search to cycles whose minimum node is the start node, + // which prunes the bulk of the duplicate work. + let starts: Vec = graph.adj.keys().copied().collect(); + let mut path: Vec = Vec::new(); + let mut on_path: HashSet = HashSet::new(); + for &start in &starts { + path.clear(); + on_path.clear(); + dfs_cycles(graph, start, start, &mut path, &mut on_path, &mut found); + if found.len() >= MAX_CYCLES { + break; } - let expected = inside / c.length; - assert!( - (m.node_connector_overlap - expected).abs() < 1e-9, - "got {} expected {}", - m.node_connector_overlap, - expected - ); - } - - #[test] - fn test_node_connector_overlap_avoids_all_is_zero() { - // Connector between two auxes with a third node well off the line. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let off = stock(3, "s", 200.0, 500.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, off, link]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.node_connector_overlap, 0.0); - } - - // node_connector_overlap charges a connector for the length it spends - // inside a non-incident node's bare SHAPE box, NOT its label-merged box. - // The user reads a connector passing under a node SHAPE as a false causal - // connection (high priority); a connector passing only under a node's LABEL - // is mild noise (labels are semi-transparent, no connector starts/ends on a - // label) and must NOT be charged. These two tests pin that distinction; the - // first would FAIL against the prior label-merged-box implementation. - - #[test] - fn test_node_connector_overlap_under_label_only_is_zero() { - // Connector from aux #1 (0,0) to aux #2 (400,0): a horizontal line at - // y=0 (clipped to the 9px aux radii, so drawn x in [9, 391]). A - // non-incident `LabelSide::Bottom` stock #3 named "s" (1 char) is placed - // ABOVE the line so its SHAPE box clears y=0 but its label (which hangs - // BELOW the shape) reaches down across y=0: - // stock #3 @ (200,-25): - // shape box x [177.5, 222.5], y [-42.5, -7.5] (does NOT cross 0) - // label box x [192, 208], y [-3.5, 10.5] (DOES cross 0) - // The connector at y=0 passes through the label band but never enters - // the shape box, so node_connector_overlap == 0. Under the old - // label-merged box (which unions the label, y [-42.5, 10.5]) the line - // WOULD be charged, so this assertion is the load-bearing distinction. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let label_only = stock(3, "s", 200.0, -25.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, label_only, link]); - - // Confirm the fixture geometry is what we claim before asserting on the - // metric: shape box clears the line, merged box does not. - let shape = node_shape_box(&stock(3, "s", 200.0, -25.0)).unwrap(); - let merged = node_box(&stock(3, "s", 200.0, -25.0)).unwrap(); - assert!( - shape.bottom < 0.0, - "shape box must clear the connector line (bottom {} < 0)", - shape.bottom - ); - assert!( - merged.bottom > 0.0, - "merged box must cross the connector line via the label (bottom {} > 0)", - merged.bottom - ); - - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!( - m.node_connector_overlap, 0.0, - "a connector passing only under a node's LABEL must not be charged" - ); } + found.into_iter().take(MAX_CYCLES).collect() +} - #[test] - fn test_node_connector_overlap_under_shape_is_positive() { - // Same connector, but the non-incident stock sits ON the line so the - // connector crosses its SHAPE box -- the false-causal-connection case - // the user cares about. node_connector_overlap > 0. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let on_line = stock(3, "s", 200.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, on_line, link]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.node_connector_overlap > 0.0, - "a connector passing under a node SHAPE must be charged" - ); +/// Depth-first walk that records every simple cycle returning to `start` and +/// composed only of nodes whose uid is >= `start` (so each cycle is discovered +/// from its smallest member). `path`/`on_path` track the current simple path. +fn dfs_cycles( + graph: &LoopGraph, + start: i32, + current: i32, + path: &mut Vec, + on_path: &mut HashSet, + found: &mut BTreeSet>, +) { + if found.len() >= MAX_CYCLES { + return; } + path.push(current); + on_path.insert(current); - // node_connector_overlap is documented as a "fraction of total connector - // length", so it must count each physical sub-length of connector covered by - // ANY non-incident node shape box AT MOST ONCE. When two non-incident shape - // boxes overlap, the prior implementation summed the per-box clipped lengths, - // double-counting the connector segment that lies in the overlap region; the - // normalized value could then exceed 1.0 and over-inflate weighted_cost. The - // correct value is the UNION length covered by (box A OR box B) over the total - // connector length. These two tests pin the union contract. - - /// Length of segment p0->p1 covered by the UNION of `rects` (each physical - /// sub-length counted once). Independent reference implementation used by the - /// union tests: collect each rect's Liang-Barsky clip interval, merge, sum. - fn union_segment_length_in_rects(p0: &Point, p1: &Point, rects: &[Rect]) -> f64 { - let seg_len = { - let dx = p1.x - p0.x; - let dy = p1.y - p0.y; - (dx * dx + dy * dy).sqrt() - }; - if seg_len == 0.0 { - return 0.0; - } - let mut intervals: Vec<(f64, f64)> = Vec::new(); - for r in rects { - // Recover [t0, t1] from segment_length_in_rect's reported length: the - // tests use axis-aligned horizontal segments, so the clipped length is - // an exact multiple of seg_len. We instead build intervals from the - // covered length by reconstructing endpoints via the rect bounds for a - // horizontal segment at constant y (the only geometry these tests use). - let covered = segment_length_in_rect(p0, p1, r); - if covered <= 0.0 { + if let Some(succs) = graph.adj.get(¤t) { + for &next in succs { + if next == start { + // Closed a cycle back to the start. Record it (>= 2 nodes by + // construction; self-loops were never added as edges). + if path.len() >= 2 { + found.insert(canonicalize_cycle(path)); + if found.len() >= MAX_CYCLES { + break; + } + } continue; } - // For a horizontal segment (y constant) inside [left,right], the - // covered x-range is [max(min_x,left), min(max_x,right)]. Convert to t. - let (xa, xb) = (p0.x.min(p1.x), p0.x.max(p1.x)); - let lo_x = xa.max(r.left); - let hi_x = xb.min(r.right); - let span = p1.x - p0.x; - let t_lo = ((lo_x - p0.x) / span).clamp(0.0, 1.0); - let t_hi = ((hi_x - p0.x) / span).clamp(0.0, 1.0); - let (t0, t1) = if t_lo <= t_hi { - (t_lo, t_hi) - } else { - (t_hi, t_lo) - }; - intervals.push((t0, t1)); - } - intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - let mut total = 0.0; - let mut cur: Option<(f64, f64)> = None; - for (t0, t1) in intervals { - match cur { - None => cur = Some((t0, t1)), - Some((c0, c1)) => { - if t0 <= c1 { - cur = Some((c0, c1.max(t1))); - } else { - total += c1 - c0; - cur = Some((t0, t1)); - } + // Only extend through nodes strictly greater than the start (so the + // start is the minimum), not already on the path, within the length + // cap. + if next > start && !on_path.contains(&next) && path.len() < MAX_CYCLE_LEN { + dfs_cycles(graph, start, next, path, on_path, found); + if found.len() >= MAX_CYCLES { + break; } } } - if let Some((c0, c1)) = cur { - total += c1 - c0; - } - total * seg_len - } - - #[test] - fn test_node_connector_overlap_union_of_overlapping_boxes() { - // A horizontal Link between aux #1 (0,0) and aux #2 (400,0) at y=0. Two - // NON-incident stocks straddle the line AND overlap each other: - // stock #3 @ (200,0): shape x [177.5, 222.5] - // stock #4 @ (210,0): shape x [187.5, 232.5] - // Their shape boxes overlap in x [187.5, 222.5]. The OLD code charged the - // connector for box A (length 45) PLUS box B (length 45) = 90, but the - // physical connector length under (A OR B) is the union x [177.5, 232.5] - // = 55. The new metric must equal union/total, and the old sum/total - // strictly exceeds it. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let s3 = stock(3, "s3", 200.0, 0.0); - let s4 = stock(4, "s4", 210.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, s3.clone(), s4.clone(), link]); - - let m = compute_layout_metrics(&view, &cfg()); - - let connectors = collect_connector_geometry(&view); - assert_eq!(connectors.len(), 1); - let c = &connectors[0]; - let box3 = node_shape_box(&s3).unwrap(); - let box4 = node_shape_box(&s4).unwrap(); - - // Independent union reference and the old (double-counting) sum. - let mut union_len = 0.0; - let mut old_sum_len = 0.0; - for seg in c.polyline.windows(2) { - union_len += union_segment_length_in_rects(&seg[0], &seg[1], &[box3, box4]); - old_sum_len += segment_length_in_rect(&seg[0], &seg[1], &box3) - + segment_length_in_rect(&seg[0], &seg[1], &box4); - } - let expected = union_len / c.length; - let old_value = old_sum_len / c.length; - - // The fixture must actually overlap so the old sum strictly exceeds the - // union (otherwise the test proves nothing). - assert!( - old_value > expected + 1e-9, - "fixture must double-count: old {old_value} should exceed union {expected}" - ); - assert!( - (m.node_connector_overlap - expected).abs() < 1e-9, - "node_connector_overlap must equal the union fraction: got {} expected {} \ - (old double-counted value was {})", - m.node_connector_overlap, - expected, - old_value - ); - assert!( - m.node_connector_overlap <= 1.0, - "node_connector_overlap is a fraction and must be <= 1.0, got {}", - m.node_connector_overlap - ); } - #[test] - fn test_node_connector_overlap_coincident_boxes_counted_once() { - // Starker variant: a connector sub-length fully inside TWO COINCIDENT - // non-incident boxes is counted ONCE, not twice. Two stocks at the same - // position (200,0) each fully contain the connector segment x [177.5, - // 222.5]. The OLD code would count that length twice (~2x); the union - // counts it once. We also build the fixture so the total connector length - // is small enough that the OLD value EXCEEDS 1.0 -- impossible for a - // documented fraction. Auxes are placed close in (x 180 and 220) so the - // drawn connector is short and lies entirely within the coincident boxes. - let a = aux(1, "a", 180.0, 0.0); - let b = aux(2, "b", 220.0, 0.0); - let s3 = stock(3, "s3", 200.0, 0.0); - let s4 = stock(4, "s4", 200.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, s3.clone(), s4.clone(), link]); - - let m = compute_layout_metrics(&view, &cfg()); - - let connectors = collect_connector_geometry(&view); - assert_eq!(connectors.len(), 1); - let c = &connectors[0]; - let box3 = node_shape_box(&s3).unwrap(); - let box4 = node_shape_box(&s4).unwrap(); - - let mut union_len = 0.0; - let mut old_sum_len = 0.0; - for seg in c.polyline.windows(2) { - union_len += union_segment_length_in_rects(&seg[0], &seg[1], &[box3, box4]); - old_sum_len += segment_length_in_rect(&seg[0], &seg[1], &box3) - + segment_length_in_rect(&seg[0], &seg[1], &box4); - } - let expected = union_len / c.length; - let old_value = old_sum_len / c.length; - - // With two coincident boxes both covering the whole drawn connector, the - // union fraction is 1.0 and the old value is ~2.0 (> 1.0, impossible for a - // fraction). - assert!( - old_value > 1.0, - "coincident-box fixture must drive the OLD value above 1.0 (got {old_value})" - ); - assert!( - (expected - 1.0).abs() < 1e-9, - "union of two coincident boxes covering the whole connector is the full \ - length (fraction 1.0), got {expected}" - ); - assert!( - (m.node_connector_overlap - expected).abs() < 1e-9, - "coincident non-incident boxes must be counted once: got {} expected {} \ - (old double-counted value was {})", - m.node_connector_overlap, - expected, - old_value - ); - assert!( - m.node_connector_overlap <= 1.0 + 1e-9, - "node_connector_overlap is a fraction and must be <= 1.0, got {}", - m.node_connector_overlap - ); - } - - // --- AC1.4: label_overlap (per-label obscuration) --- - // - // label_overlap is the SUM over labeled elements of each label's obscured - // fraction: the area of the label box covered by any OTHER label box or any - // OTHER element's bare shape box, capped at the label's own area and divided - // by it (so each term is in [0,1]). 0 = no label obscured. A small overlap - // registers at its true per-label obscuration fraction rather than being - // diluted by the corpus's total label area (the old area/total definition's - // under-counting; see `test_label_overlap_small_clip_is_sensitive`). - - #[test] - fn test_label_overlap_overlapping_labels() { - // Two auxes at the same position -> their labels (Bottom) coincide - // exactly. Each label is fully covered by the other (capped at its own - // area), so each obscured fraction is 1.0 and the sum is 2.0. - let view = make_view(vec![ - aux(1, "samename", 100.0, 100.0), - aux(2, "samename", 100.0, 100.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - (m.label_overlap - 2.0).abs() < 1e-9, - "two coincident labels are each fully obscured: expected 2.0, got {}", - m.label_overlap - ); - } + on_path.remove(¤t); + path.pop(); +} - #[test] - fn test_label_overlap_disjoint_is_zero() { - // Two auxes far apart -> no label is covered by anything. Sum of - // obscured fractions is 0.0. - let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 1000.0, 1000.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.label_overlap, 0.0); - } - - #[test] - fn test_label_overlap_counts_label_pair_exactly_once() { - // The Phase-1 double-count guard, restated for per-label obscuration: a - // label is never charged against its OWN element's shape box, and a - // label-vs-label collision is counted from each label's own perspective - // (both labels are unreadable -- that is intended), not via the other - // node's label-merged bounds. - // - // Fixture: two `LabelSide::Bottom` auxes named "samename" (8 chars). - // AUX_RADIUS = 9; label editor width = 8*6 + 10 = 58, height = 14. - // With Bottom labels, label top = cy + 9 + LABEL_PADDING(4) = cy + 13, - // bottom = cy + 27, left = cx - 29, right = cx + 29. - // - // Place them 40px apart horizontally, same y: - // aux1 @ (0,0): shape [-9,9]x[-9,9], label [-29,29]x[13,27] - // aux2 @ (40,0): shape [31,49]x[-9,9], label [11,69]x[13,27] - // - // SHAPE boxes do NOT overlap (9 < 31), and each label clears the OTHER - // aux's bare shape box entirely (label y [13,27] vs shape y [-9,9]). The - // LABELS overlap by x:[11,29]=18, y:[13,27]=14 -> 252. Each label box has - // area 58*14 = 812 and is covered only by the other label (252 < 812, no - // cap), so each obscured fraction is 252/812 and the sum is 504/812. - let view = make_view(vec![ - aux(1, "samename", 0.0, 0.0), - aux(2, "samename", 40.0, 0.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - - let label_area = 58.0 * 14.0; // 812.0 - let overlap = 18.0 * 14.0; // 252.0, the single label-label intersection - let expected = (overlap / label_area) + (overlap / label_area); // 504/812 - assert!( - (m.label_overlap - expected).abs() < 1e-9, - "per-label obscuration should sum each label's fraction once: got {} expected {}", - m.label_overlap, - expected - ); +/// Rotate a cycle so its smallest uid is first, preserving traversal direction. +/// The DFS already guarantees the start (= minimum) is element 0, but rotating +/// defensively keeps the canonical form correct for any caller. +/// +/// Note: this canonicalizes rotation (start at min uid) but NOT traversal +/// direction, so a directed cycle and its reverse canonicalize to distinct +/// entries. That is harmless: a reverse-direction duplicate (essentially never +/// present for directed SD feedback loops, which would require both directed +/// edge sets in the graph) would compute the same isoperimetric penalty because +/// the shoelace polygon area in `cycle_penalty` is direction-invariant. +fn canonicalize_cycle(cycle: &[i32]) -> Vec { + if cycle.is_empty() { + return Vec::new(); } - - #[test] - fn test_label_overlap_never_charged_against_own_shape() { - // A single labeled aux: its Bottom label sits adjacent to (and partly - // within the merged bounds of) its OWN shape. A label is never charged - // against its own element's shape, and there is no other element, so the - // obscured fraction is 0 and label_overlap is exactly 0.0. - let view = make_view(vec![aux(1, "samename", 0.0, 0.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!( - m.label_overlap, 0.0, - "a label must never be charged against its own element's shape box" - ); + let min_idx = cycle + .iter() + .enumerate() + .min_by_key(|&(_, v)| *v) + .map(|(i, _)| i) + .unwrap_or(0); + let mut out = Vec::with_capacity(cycle.len()); + for k in 0..cycle.len() { + out.push(cycle[(min_idx + k) % cycle.len()]); } + out +} - #[test] - fn test_label_overlap_small_clip_is_sensitive() { - // A small node SHAPE clipping a few characters of a short label must - // register at its true per-label obscuration fraction, NOT be diluted to - // ~0 by the corpus's total label area (the old area/total under-count). - // - // L: aux "ab" (2 chars) @ (0,0), Bottom label. - // editor_width = 2*6 + 10 = 22, height 14 -> label area 308. - // label box: left -11, right 11, top 13, bottom 27. - // O: a cloud (no label) @ (18, 20). cloud_bounds (CLOUD_RADIUS 13.5): - // x [4.5, 31.5], y [6.5, 33.5]. - // Overlap with L's label: x [4.5,11]=6.5, y [13,27]=14 -> 91. - // obscured_fraction(L) = 91/308 ~= 0.2955; the cloud has no label, so - // the sum is exactly 91/308. - // Plus 15 far-apart auxes with long (20-char) labels: each label area - // 20*6+10 = 130 wide * 14 = 1820, none overlapping anything. They add - // nothing to the per-label SUM (obscured fraction 0 each) but bloat the - // OLD denominator (total label area), so the OLD area/total score for - // the same clip collapses to ~0.003 -- the under-count this fixes. - let mut elements = vec![aux(1, "ab", 0.0, 0.0), cloud(2, 18.0, 20.0)]; - for k in 0..15 { - // Far apart on a 1000px grid so nothing overlaps; 20-char names. - elements.push(aux( - 100 + k, - "abcdefghijklmnopqrst", - 3000.0 + f64::from(k) * 1000.0, - 3000.0, - )); - } - let view = make_view(elements); - let m = compute_layout_metrics(&view, &cfg()); - - let label_area = 22.0 * 14.0; // 308.0 - let clip_area = 6.5 * 14.0; // 91.0 - let expected = clip_area / label_area; // ~0.2955 - assert!( - (m.label_overlap - expected).abs() < 1e-9, - "small clip must score its per-label obscuration fraction: got {} expected {}", - m.label_overlap, - expected - ); - assert!( - m.label_overlap > 0.1, - "a readability-killing clip must register clearly (> 0.1), got {}", - m.label_overlap - ); - - // Confirm the OLD area/total definition would have under-counted this to - // near-zero: the same clip area divided by the corpus total label area. - let total_label_area = label_area + 15.0 * (130.0 * 14.0); // 308 + 27300 - let old_score = clip_area / total_label_area; // ~0.0033 - assert!( - old_score < 0.01, - "fixture must demonstrate the old under-count (< 0.01), got {}", - old_score - ); - assert!( - m.label_overlap > old_score * 50.0, - "new per-label score {} must be far larger than the old {}", - m.label_overlap, - old_score - ); +/// Isoperimetric penalty `1 - Q` for one cycle's node-box centers, or `None` if +/// the cycle does not qualify (fewer than 3 distinct positioned nodes, or a +/// degenerate zero-perimeter polygon). `Q = 4*PI*Area / Perimeter^2` is clamped +/// to [0, 1]; `Area` is the shoelace area (absolute value) and `Perimeter` the +/// summed edge length over the closed polygon. +fn cycle_penalty(cycle: &[i32], centers: &BTreeMap) -> Option { + // Distinct positioned nodes only: a polygon needs >= 3 vertices. + let distinct: BTreeSet = cycle.iter().copied().collect(); + if distinct.len() < 3 { + return None; } - - // --- AC1.5: aspect_penalty --- - - #[test] - fn test_aspect_penalty_thin_box_positive() { - // Two auxes stacked far apart vertically and close horizontally -> the - // node bounding box is tall and thin (ar >> target), so penalty > 0. - let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 0.0, 1000.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.aspect_penalty > 0.0, - "a tall thin bbox must be penalized, got {}", - m.aspect_penalty - ); - - // Verify it equals exactly `ar - TARGET_AR_MAX` for the computed bbox. - let node_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| node_box(e).map(|r| (e.get_uid(), r))) - .collect(); - let bbox = view_bounding_box(&node_boxes).unwrap(); - let w = common::rect_width(&bbox); - let h = common::rect_height(&bbox); - let (long, short) = if w >= h { (w, h) } else { (h, w) }; - let expected = (long / short - TARGET_AR_MAX).max(0.0); - assert!((m.aspect_penalty - expected).abs() < 1e-9); - } - - #[test] - fn test_aspect_penalty_balanced_box_zero() { - // Four auxes placed so the bounding box is ~4:3 (well inside the 16:9 - // band) -> zero penalty. Width 400, height 300 between centers; the - // fixed node radii add a small symmetric margin that keeps ar < 16/9. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 400.0, 0.0), - aux(3, "c", 0.0, 300.0), - aux(4, "d", 400.0, 300.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - - // Confirm the bbox aspect ratio really is inside the band for this - // fixture, then assert the penalty is exactly zero. - let node_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| node_box(e).map(|r| (e.get_uid(), r))) - .collect(); - let bbox = view_bounding_box(&node_boxes).unwrap(); - let w = common::rect_width(&bbox); - let h = common::rect_height(&bbox); - let ar = w.max(h) / w.min(h); - assert!(ar <= TARGET_AR_MAX, "fixture bbox ar {} not in band", ar); - assert_eq!(m.aspect_penalty, 0.0); - } - - // --- AC1.6: weighted_cost is the exact linear combination --- - - #[test] - fn test_weighted_cost_exact_linear_combination() { - let m = LayoutMetrics { - node_overlap: 1.5, - node_connector_overlap: 2.0, - label_overlap: 0.5, - crossings: 3.0, - sprawl: 4.0, - edge_length_cv: 0.25, - aspect_penalty: 6.0, - chain_straightness: 7.0, - loop_compactness: 8.0, - flow_bends: 9.0, - loop_straightness: 11.0, - }; - let w = MetricWeights { - node_overlap: 10.0, - node_connector_overlap: 20.0, - label_overlap: 30.0, - crossings: 40.0, - sprawl: 50.0, - edge_length_cv: 60.0, - aspect_penalty: 70.0, - chain_straightness: 80.0, - loop_compactness: 90.0, - flow_bends: 100.0, - loop_straightness: 110.0, - }; - let expected = 1.5 * 10.0 - + 2.0 * 20.0 - + 0.5 * 30.0 - + 3.0 * 40.0 - + 4.0 * 50.0 - + 0.25 * 60.0 - + 6.0 * 70.0 - + 7.0 * 80.0 - + 8.0 * 90.0 - + 9.0 * 100.0 - + 11.0 * 110.0; - assert!((m.weighted_cost(&w) - expected).abs() < 1e-9); - } - - // --- AC5.1: the committed calibrated default expresses readability dominance --- - // - // The Phase-1 placeholder default was all-zeros (so a pre-calibration - // `weighted_cost` was inert). Phase 4 commits real, user-signed-off weights - // (2026-05-23), so the default is no longer all-zeros and `weighted_cost` - // under it is now meaningful. This test pins the DOMINANCE ORDERING the - // committed weights encode -- relationships rather than magic numbers, so it - // documents the intent and survives minor retuning -- and re-confirms that - // `weighted_cost` applies the default exactly as Σ wᵢ·termᵢ. It replaces the - // old "default is all-zeros so cost is inert" assertion, which is no longer - // true by design. - - #[test] - fn test_default_weights_readability_dominant_ordering() { - let w = MetricWeights::default(); - - // The dominant "overlap + crossings" family: each term that hurts - // readability (shapes overlapping shapes, connectors under shapes, labels - // obscured, edges crossing) must outweigh every compactness/aspect term. - let dominant = [ - w.node_overlap, - w.node_connector_overlap, - w.label_overlap, - w.crossings, - ]; - let compactness = [w.sprawl, w.edge_length_cv, w.aspect_penalty]; - for &d in &dominant { - for &c in &compactness { - assert!( - d > c, - "every readability term ({d}) must strictly exceed every \ - compactness/aspect term ({c})" - ); - } - } - - // `sprawl` is a GENTLE compactness counterweight: strictly positive (so - // unbounded inflation is penalized and the cost has a finite optimum at - // "spread just enough"), but far below the dominant readability family - // (checked by the dominant>compactness loop above), so readability still - // wins decisively over compactness. - assert!( - w.sprawl > 0.0, - "sprawl must be a positive compactness counterweight, got {}", - w.sprawl - ); - assert!( - w.sprawl < 0.5 * w.label_overlap, - "sprawl ({}) must stay well below the readability terms ({})", - w.sprawl, - w.label_overlap - ); - // Edge-length uniformity and aspect ratio remain non-goals. - assert_eq!( - w.edge_length_cv, 0.0, - "edge-length uniformity is not a goal" - ); - assert_eq!(w.aspect_penalty, 0.0, "aspect ratio is not a goal"); - - // chain_straightness is reserved (not yet computed), so it carries no - // weight. - assert_eq!( - w.chain_straightness, 0.0, - "chain_straightness is reserved and must stay zero" - ); - - // loop_compactness rewards visible feedback-loop circles, but only as a - // gentle nudge: a low, non-dominant weight strictly between zero and the - // dominant family. - assert!( - w.loop_compactness > 0.0, - "loop_compactness should gently reward visible loops, got {}", - w.loop_compactness - ); - assert!( - w.loop_compactness < w.node_overlap, - "loop_compactness ({}) must stay below the dominant node_overlap ({})", - w.loop_compactness, - w.node_overlap - ); - - // flow_bends nudges toward straight pipes (aligned stocks), a convention - // aid: positive but well below the dominant family. - assert!( - w.flow_bends > 0.0, - "flow_bends should nudge toward straight flows, got {}", - w.flow_bends - ); - assert!( - w.flow_bends < w.node_overlap, - "flow_bends ({}) must stay below the dominant node_overlap ({})", - w.flow_bends, - w.node_overlap - ); - - // loop_straightness is a Goodhart guard for loop curvature: positive but - // well below the dominant family. - assert!( - w.loop_straightness > 0.0, - "loop_straightness should guard loop curvature, got {}", - w.loop_straightness - ); - assert!( - w.loop_straightness < w.node_overlap, - "loop_straightness ({}) must stay below the dominant node_overlap ({})", - w.loop_straightness, - w.node_overlap - ); - - // `weighted_cost` under the default is still the exact linear combination - // (the default is now meaningful, not inert): verify against an explicit - // Σ wᵢ·termᵢ over a hand-set metrics value. - let m = LayoutMetrics { - node_overlap: 0.3, - node_connector_overlap: 0.1, - label_overlap: 0.7, - crossings: 2.0, - sprawl: 5.0, - edge_length_cv: 0.4, - aspect_penalty: 1.5, - chain_straightness: 0.0, - loop_compactness: 0.8, - flow_bends: 1.0, - loop_straightness: 0.6, - }; - let expected = m.node_overlap * w.node_overlap - + m.node_connector_overlap * w.node_connector_overlap - + m.label_overlap * w.label_overlap - + m.crossings * w.crossings - + m.sprawl * w.sprawl - + m.edge_length_cv * w.edge_length_cv - + m.aspect_penalty * w.aspect_penalty - + m.chain_straightness * w.chain_straightness - + m.loop_compactness * w.loop_compactness - + m.flow_bends * w.flow_bends - + m.loop_straightness * w.loop_straightness; - assert!( - (m.weighted_cost(&w) - expected).abs() < 1e-12, - "weighted_cost under the default must equal Σ wᵢ·termᵢ: got {} expected {}", - m.weighted_cost(&w), - expected - ); + let pts: Vec = cycle + .iter() + .filter_map(|uid| centers.get(uid).copied()) + .collect(); + if pts.len() < 3 { + return None; } - // --- AC1.7: empty / single-element views are all-zero and finite --- - - fn assert_all_finite(m: &LayoutMetrics) { - assert!(m.node_overlap.is_finite()); - assert!(m.node_connector_overlap.is_finite()); - assert!(m.label_overlap.is_finite()); - assert!(m.crossings.is_finite()); - assert!(m.sprawl.is_finite()); - assert!(m.edge_length_cv.is_finite()); - assert!(m.aspect_penalty.is_finite()); - assert!(m.chain_straightness.is_finite()); - assert!(m.loop_compactness.is_finite()); - assert!(m.flow_bends.is_finite()); - assert!(m.loop_straightness.is_finite()); - } - - fn assert_all_zero(m: &LayoutMetrics) { - assert_eq!(m.node_overlap, 0.0); - assert_eq!(m.node_connector_overlap, 0.0); - assert_eq!(m.label_overlap, 0.0); - assert_eq!(m.crossings, 0.0); - assert_eq!(m.sprawl, 0.0); - assert_eq!(m.edge_length_cv, 0.0); - assert_eq!(m.aspect_penalty, 0.0); - assert_eq!(m.chain_straightness, 0.0); - assert_eq!(m.loop_compactness, 0.0); - assert_eq!(m.flow_bends, 0.0); - assert_eq!(m.loop_straightness, 0.0); - } - - #[test] - fn test_empty_view_all_zero_finite() { - let view = make_view(vec![]); - let m = compute_layout_metrics(&view, &cfg()); - assert_all_finite(&m); - assert_all_zero(&m); - } - - #[test] - fn test_single_element_view_all_zero_finite() { - let view = make_view(vec![aux(1, "only", 100.0, 100.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert_all_finite(&m); - // A single node has no overlaps, no connectors, and a degenerate (zero - // short-side? no -- a real box) bounding box. Its aspect ratio is the - // single aux box's own ar, which for a square-ish aux box is ~1 (inside - // the band), so aspect_penalty is 0; all connector terms are 0. - assert_eq!(m.node_overlap, 0.0); - assert_eq!(m.node_connector_overlap, 0.0); - assert_eq!(m.crossings, 0.0); - assert_eq!(m.sprawl, 0.0); - assert_eq!(m.edge_length_cv, 0.0); - } - - // --- AC1.8 (scoped): scale invariance under uniform coordinate scaling --- - // - // SCOPING (correction to the AC1.8 plan note, 2026-05-22): the plan listed - // `node_connector_overlap`, `crossings`, `edge_length_cv`, and - // `aspect_penalty` as scale-free. After implementing the metric against the - // ACTUAL renderer geometry (the design's load-bearing invariant: metrics - // are computed on the same geometry the renderer draws), only `crossings` - // is exactly scale-invariant -- and even then only for crossings that lie - // INTERIOR to both connectors, away from the fixed-size node boundaries the - // polylines are clipped to (a crossing grazing a node boundary near a - // segment endpoint can flip; see the detailed note at the assertion below). - // This fixture's crossing is at the center of the square the two links form, - // squarely in that interior regime. The reason the other terms are not - // exactly invariant is the same fixed-pixel element geometry the plan - // already cites for node_overlap/label_overlap/sprawl, and it propagates - // further than the plan anticipated: - // - // * Connectors are clipped to fixed-radius element boundaries, so a - // straight link's drawn length is `s*center_dist - r_from - r_to` - // (AFFINE in `s`, not linear). Hence `edge_length_cv = stddev/mean` of - // those affine lengths is only ASYMPTOTICALLY invariant (the fixed - // offset shrinks relative to the scaled spread), not exactly. - // * `node_connector_overlap` divides an inside-fixed-box overlap length - // (which does NOT scale) by total connector length (which does), so it - // shrinks like ~1/s -- scale-SENSITIVE, like `sprawl`. - // * The view bounding box is `union(fixed boxes around scaled centers)`, - // so its width/height are each `s*span + fixed_box_size`; the aspect - // ratio is therefore only asymptotically invariant. - // - // The principled resolution keeps renderer-faithful geometry (the whole - // point of the phase) and accepts that only the topological `crossings` - // term is exactly scale-invariant. This test asserts that exactly, and - // additionally pins the documented scale-SENSITIVITY of - // `node_connector_overlap` (clean ~1/s) so the scoping is non-vacuous. The - // mismatch with the plan's term list is surfaced in the executor report and - // tracked for the calibration phase. - // - // The fixture has zero node-overlap and zero label-overlap so those - // scale-sensitive area terms are trivially 0 before and after scaling. - #[test] - fn test_scale_invariance_of_scale_free_terms() { - // A small connected, well-separated view: three auxes and two stocks, - // far enough apart that there is no node-overlap and no label-overlap, - // with two straight links (one of which passes through a non-incident - // node so node_connector_overlap is nonzero and meaningful). - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 400.0, 0.0), - stock(3, "s", 200.0, 0.0), // on the a->b line: nonzero conn overlap - aux(4, "c", 0.0, 300.0), - stock(5, "t", 400.0, 320.0), - straight_link(10, 1, 2), // passes through stock #3 - straight_link(11, 4, 5), - ]); - - let base = compute_layout_metrics(&view, &cfg()); - // Sanity: the fixture must have zero node/label overlap (so the - // scale-sensitive area terms are trivially scale-equal) and a nonzero - // conn-overlap (so the documented scale-SENSITIVITY check is - // non-vacuous). - assert_eq!(base.node_overlap, 0.0, "fixture must have no node overlap"); - assert_eq!( - base.label_overlap, 0.0, - "fixture must have no label overlap" - ); - assert!( - base.node_connector_overlap > 0.0, - "fixture must have a connector through a non-incident node" - ); - - let s = 3.0; - let scaled = compute_layout_metrics(&scale_view(&view, s), &cfg()); - - // The one exactly scale-invariant term here: edge crossings. - // - // Crossings are NOT *universally* scale-invariant. A crossing is counted - // on the drawn polylines, which are clipped to the same fixed-pixel node - // boxes (the connector endpoints sit on element boundaries that do not - // scale). A crossing that merely grazes a node boundary near a segment - // endpoint can therefore appear or disappear under uniform scale. - // Crossings that lie comfortably INTERIOR to both connectors (away from - // those fixed-size boundaries) are exactly preserved, because the - // interior of each polyline is an exact affine image of itself under - // uniform scale and an intersection of two segments is invariant under a - // shared affine map. This fixture's crossing is at the center of the - // square the two links form -- maximally far from every node box -- so - // it is squarely in the scale-invariant interior regime and the count is - // preserved exactly. - assert!( - (scaled.crossings - base.crossings).abs() < 1e-9, - "crossings not scale-invariant: {} vs {}", - scaled.crossings, - base.crossings - ); - - // Documented scale-SENSITIVITY of node_connector_overlap: with - // fixed-size node boxes, scaling the coordinates by `s` leaves the - // inside-box overlap length essentially unchanged (the box and the - // line's center crossing are fixed) while total connector length grows - // with `s`, so the ratio strictly DECREASES under up-scaling. (It does - // not drop by exactly 1/s because the denominator -- connector length - // clipped to fixed-radius element boundaries -- is affine in `s`, not - // linear; we assert the robust direction rather than a brittle factor.) - assert!( - scaled.node_connector_overlap < base.node_connector_overlap, - "node_connector_overlap should DROP under up-scaling (fixed boxes): \ - scaled {} should be < base {}", - scaled.node_connector_overlap, - base.node_connector_overlap - ); + let n = pts.len(); + let mut area2 = 0.0; + let mut perimeter = 0.0; + for i in 0..n { + let a = pts[i]; + let b = pts[(i + 1) % n]; + area2 += a.x * b.y - b.x * a.y; + let dx = b.x - a.x; + let dy = b.y - a.y; + perimeter += (dx * dx + dy * dy).sqrt(); } - - // --- Property test: node_overlap is symmetric under element shuffle --- - - proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - - /// node_overlap is a sum over unordered element pairs, so it must be - /// invariant under any permutation of the element list. - #[test] - fn prop_node_overlap_shuffle_invariant( - // four stocks at small integer-ish coordinates so some overlap and - // some don't; coordinates kept modest to stay fast. - xs in prop::collection::vec(-50.0f64..50.0, 4), - ys in prop::collection::vec(-50.0f64..50.0, 4), - perm in prop::sample::subsequence(vec![0usize, 1, 2, 3], 4), - ) { - let elems: Vec = (0..4) - .map(|i| stock(i as i32 + 1, "n", xs[i], ys[i])) - .collect(); - - let base = compute_layout_metrics(&make_view(elems.clone()), &cfg()); - - // `perm` is a random ordering of [0,1,2,3]; reorder accordingly. - let shuffled: Vec = perm.iter().map(|&i| elems[i].clone()).collect(); - let other = compute_layout_metrics(&make_view(shuffled), &cfg()); - - prop_assert!( - (base.node_overlap - other.node_overlap).abs() < 1e-9, - "node_overlap changed under shuffle: {} vs {}", - base.node_overlap, - other.node_overlap - ); - } + if perimeter <= 0.0 { + // All centers coincide: no polygon. Guarded so the division below is + // never NaN; such a degenerate cycle simply does not contribute. + return None; } + let area = area2.abs() / 2.0; + let q = (4.0 * std::f64::consts::PI * area / (perimeter * perimeter)).clamp(0.0, 1.0); + Some(1.0 - q) +} - // --- loop_compactness (isoperimetric loop quality) --- - - /// The center of a node's bare shape box (which is symmetric about the - /// element position, so this is the element center). Mirrors the centers the - /// metric uses to build each loop polygon. - fn shape_center(e: &ViewElement) -> Point { - let r = node_shape_box(e).unwrap(); - Point { - x: (r.left + r.right) / 2.0, - y: (r.top + r.bottom) / 2.0, - } +/// `loop_compactness`: mean isoperimetric penalty `1 - Q` over the view's +/// bounded simple directed cycles of >= 3 positioned nodes. 0.0 when there is no +/// qualifying cycle. Deterministic for a given view regardless of element order +/// (see the module comment above). PURE. +fn compute_loop_compactness(view: &datamodel::StockFlow) -> f64 { + let graph = build_loop_graph(view); + let cycles = enumerate_simple_cycles(&graph); + let penalties: Vec = cycles + .iter() + .filter_map(|c| cycle_penalty(c, &graph.centers)) + .collect(); + if penalties.is_empty() { + 0.0 + } else { + penalties.iter().sum::() / penalties.len() as f64 } +} - /// Hand-computed isoperimetric penalty `1 - Q` for a polygon over the given - /// centers in order (shoelace area, summed-edge perimeter, Q clamped to - /// [0,1]). The test's independent oracle for `loop_compactness`. - fn expected_loop_penalty(centers: &[Point]) -> f64 { - let n = centers.len(); - let mut area2 = 0.0; - let mut perim = 0.0; - for i in 0..n { - let a = centers[i]; - let b = centers[(i + 1) % n]; - area2 += a.x * b.y - b.x * a.y; - let dx = b.x - a.x; - let dy = b.y - a.y; - perim += (dx * dx + dy * dy).sqrt(); - } - let area = area2.abs() / 2.0; - let q = (4.0 * std::f64::consts::PI * area / (perim * perim)).clamp(0.0, 1.0); - 1.0 - q - } - - #[test] - fn test_loop_compactness_circle_loop_near_zero() { - // Eight stocks placed on a circle of radius 300, wired into a directed - // 8-cycle by links 1->2->...->8->1. A well-spread loop reads as a clean - // circle, so its isoperimetric quotient Q is close to 1 and the penalty - // (1 - Q) is small. - let n: i32 = 8; - let radius = 300.0; - let mut elements: Vec = Vec::new(); - let mut centers: Vec = Vec::new(); - for i in 0..n { - let theta = 2.0 * std::f64::consts::PI * f64::from(i) / f64::from(n); - let x = radius * theta.cos(); - let y = radius * theta.sin(); - let e = stock(i + 1, "n", x, y); - centers.push(shape_center(&e)); - elements.push(e); - } - for i in 0..n { - let from = i + 1; - let to = (i + 1) % n + 1; - elements.push(straight_link(100 + i, from, to)); - } - let view = make_view(elements); - let m = compute_layout_metrics(&view, &cfg()); - - let expected = expected_loop_penalty(¢ers); - assert!( - (m.loop_compactness - expected).abs() < 1e-9, - "loop_compactness {} != hand-computed penalty {}", - m.loop_compactness, - expected - ); - // A regular octagon's penalty is ~0.05 -- "near 0" (a clean circle). - assert!( - m.loop_compactness < 0.1, - "a well-spread circular loop should score near 0, got {}", - m.loop_compactness - ); - } +// --- loop_straightness (are feedback-loop connectors drawn as visible curves) - +// +// loop_compactness above scores how circular a loop's NODE arrangement is, but +// not whether the connectors between those nodes are actually drawn as arcs. A +// loop can have well-spread node centers yet still read as a zig-zag if its +// causal connectors are straight chords. This term measures exactly that: the +// shortfall of each loop connector's drawn curvature below a target bow. It is +// primarily a Goodhart guard -- `apply_loop_curvature` curves loop connectors +// deterministically, so a healthy layout scores ~0; if any future change flattens +// loop connectors, this term (and the metric) rises, so the optimizer can never +// trade away the curvature that makes a loop legible. Flow pipes in a loop are +// exempt: they are orthogonal by convention, never arced. - #[test] - fn test_loop_compactness_collapsed_loop_higher() { - // The SAME directed 8-cycle, but the nodes are squished onto a nearly - // straight line (a collapsed/collinear loop). The polygon area shrinks - // toward zero while the perimeter stays large, so Q -> 0 and the penalty - // (1 - Q) -> 1: clearly higher than the circular placement. - let n: i32 = 8; - let mut elements: Vec = Vec::new(); - let mut centers: Vec = Vec::new(); - for i in 0..n { - // Spread along x, with a tiny alternating y wobble so the polygon is - // non-degenerate (nonzero perimeter) but nearly collinear. - let x = f64::from(i) * 100.0; - let y = if i % 2 == 0 { 0.0 } else { 1.0 }; - let e = stock(i + 1, "n", x, y); - centers.push(shape_center(&e)); - elements.push(e); - } - for i in 0..n { - let from = i + 1; - let to = (i + 1) % n + 1; - elements.push(straight_link(100 + i, from, to)); - } - let view = make_view(elements); - let m = compute_layout_metrics(&view, &cfg()); - - let expected = expected_loop_penalty(¢ers); - assert!( - (m.loop_compactness - expected).abs() < 1e-9, - "loop_compactness {} != hand-computed penalty {}", - m.loop_compactness, - expected - ); - // A nearly-collinear loop scores near 1 (squished). - assert!( - m.loop_compactness > 0.9, - "a collapsed/collinear loop should score near 1, got {}", - m.loop_compactness - ); - } +/// Target bow ratio (max perpendicular deviation / chord length) for a loop's +/// causal connectors. A quarter-circle arc -- a clearly visible loop curve -- +/// has a bow of ~0.21; 0.15 treats moderate curvature as "enough" so the term +/// only fires on connectors drawn (near-)straight. +const LOOP_LINK_TARGET_BOW: f64 = 0.15; - #[test] - fn test_loop_compactness_no_cycle_is_zero() { - // A pure chain a -> b -> c (no feedback) has no directed cycle, so there - // is nothing to score: loop_compactness == 0.0. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 200.0, 0.0), - aux(3, "c", 400.0, 0.0), - straight_link(10, 1, 2), - straight_link(11, 2, 3), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.loop_compactness, 0.0); - } - - // --- loop_straightness (loop connectors drawn as visible curves) --- - - /// A square 4-node loop wired with STRAIGHT links scores high - /// loop_straightness: the loop's causal connectors are drawn as flat chords, - /// so the loop reads as a zig-zag, not a circle. - #[test] - fn test_loop_straightness_straight_loop_is_high() { - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 300.0, 0.0), - aux(3, "c", 300.0, 300.0), - aux(4, "d", 0.0, 300.0), - straight_link(11, 1, 2), - straight_link(12, 2, 3), - straight_link(13, 3, 4), - straight_link(14, 4, 1), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.loop_straightness > 0.9, - "a loop drawn with straight chords should score near 1, got {}", - m.loop_straightness - ); +/// Maximum perpendicular deviation of a polyline from its straight chord +/// (first -> last point), divided by the chord length. 0 for a straight two-point +/// line; ~0.21 for a quarter-circle arc. Returns 0 for a degenerate (near-zero) +/// chord so the ratio is always finite. +fn polyline_bow_ratio(polyline: &[Point]) -> f64 { + if polyline.len() < 3 { + return 0.0; } - - /// The SAME square loop wired with ARC links that bow well outward scores - /// near zero: every loop connector is drawn as a visible curve. - #[test] - fn test_loop_straightness_curved_loop_is_low() { - // 45deg takeoff arcs bow ~0.2 (a quarter-circle), above the target bow. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 300.0, 0.0), - aux(3, "c", 300.0, 300.0), - aux(4, "d", 0.0, 300.0), - arc_link(11, 1, 2, 45.0), - arc_link(12, 2, 3, 45.0), - arc_link(13, 3, 4, 45.0), - arc_link(14, 4, 1, 45.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.loop_straightness < m.loop_compactness.max(0.5), - "a curved loop should score lower loop_straightness than a straight one" - ); - assert!( - m.loop_straightness < 0.5, - "a loop drawn with well-bowed arcs should score low, got {}", - m.loop_straightness - ); + let a = polyline[0]; + let b = polyline[polyline.len() - 1]; + let cx = b.x - a.x; + let cy = b.y - a.y; + let chord = (cx * cx + cy * cy).sqrt(); + if chord < 1e-9 { + return 0.0; } - - /// A pure chain (no cycle) has no loop connector, so loop_straightness is 0. - #[test] - fn test_loop_straightness_no_loop_is_zero() { - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 200.0, 0.0), - aux(3, "c", 400.0, 0.0), - straight_link(10, 1, 2), - straight_link(11, 2, 3), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.loop_straightness, 0.0); - } - - #[test] - fn test_loop_compactness_two_node_mutual_pair_is_zero() { - // A 2-node mutual pair (a -> b -> a) is a cycle, but two points form no - // polygon (fewer than 3 distinct nodes), so it contributes nothing. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 200.0, 0.0), - straight_link(10, 1, 2), - straight_link(11, 2, 1), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.loop_compactness, 0.0); - } - - #[test] - fn test_loop_compactness_flow_feedback_path_is_a_cycle() { - // A stock--flow--stock feedback path must enter the loop graph: stock #1 - // and stock #2 connected by flow #3 (so #1 -> #3 -> #2), plus a link - // #2 -> #1 closing the loop. The cycle is {#1, #3, #2}: three distinct - // positioned nodes -> a real polygon -> a positive penalty. - let s1 = stock(1, "a", 0.0, 0.0); - let s2 = stock(2, "b", 300.0, 0.0); - let f = flow_between(3, "f", 150.0, 200.0, 1, 2); - let link = straight_link(10, 2, 1); - let view = make_view(vec![s1, s2, f, link]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.loop_compactness > 0.0, - "a stock--flow--stock feedback path must form a scored loop, got {}", - m.loop_compactness - ); + let mut max_perp = 0.0_f64; + for p in &polyline[1..polyline.len() - 1] { + // Perpendicular distance from p to the infinite line through a,b. + let perp = (cx * (a.y - p.y) - cy * (a.x - p.x)).abs() / chord; + max_perp = max_perp.max(perp); } + max_perp / chord +} - /// A stock--flow--stock loop whose flow has an extra pipe point placed far - /// from the valve, plus a closing link. The flow valve sits at `valve`; an - /// interior pipe point at `bend` (between the two attached endpoints) bends - /// the drawn pipe. `loop_compactness` must score the loop on the flow's - /// VALVE (its visual center), NOT on `flow_shape_bounds`' pipe-extent bbox - /// center, so the result must depend only on `valve` -- never on `bend`. - fn bent_flow_loop_view(valve: Point, bend: Point) -> datamodel::StockFlow { - let s1 = stock(1, "a", 0.0, 0.0); - let s2 = stock(2, "b", 300.0, 0.0); - let f = ViewElement::Flow(view_element::Flow { - name: "f".to_string(), - uid: 3, - x: valve.x, - y: valve.y, - label_side: LabelSide::Bottom, - points: vec![ - view_element::FlowPoint { - x: 0.0, - y: 0.0, - attached_to_uid: Some(1), - }, - // An interior pipe point that bends the drawn pipe and stretches - // `flow_shape_bounds`' bbox, but is NOT the valve. - view_element::FlowPoint { - x: bend.x, - y: bend.y, - attached_to_uid: None, - }, - view_element::FlowPoint { - x: 300.0, - y: 0.0, - attached_to_uid: Some(2), - }, - ], - compat: None, - label_compat: None, - }); - let link = straight_link(10, 2, 1); - make_view(vec![s1, s2, f, link]) - } - - #[test] - fn test_loop_compactness_scored_on_flow_valve_not_pipe_extent() { - // The loop vertex for a flow must be its VALVE (the renderer's visual - // center), not the center of `flow_shape_bounds` (which unions the valve - // box with every pipe point). Extending the pipe with a far interior - // point moves the pipe-extent bbox center but leaves the valve fixed, so - // `loop_compactness` -- which scores the feedback-loop polygon -- must be - // UNCHANGED. On the buggy (shape-box-midpoint) implementation it changes. - let valve = Point { x: 150.0, y: 200.0 }; - - // A pipe bend near the valve vs. one stretched far away. The valve is - // identical in both, so the loop polygon (stock--valve--stock) is too. - let near = compute_layout_metrics( - &bent_flow_loop_view(valve, Point { x: 150.0, y: 210.0 }), - &cfg(), - ); - let far = compute_layout_metrics( - &bent_flow_loop_view( - valve, - Point { - x: 150.0, - y: 2000.0, - }, - ), - &cfg(), - ); - - assert!( - near.loop_compactness > 0.0, - "fixture must form a real (positive-penalty) loop, got {}", - near.loop_compactness - ); - assert!( - (near.loop_compactness - far.loop_compactness).abs() < 1e-12, - "loop_compactness must score the flow VALVE, not the pipe-extent bbox \ - center: stretching the pipe changed it from {} to {}", - near.loop_compactness, - far.loop_compactness - ); - - // Non-vacuous guard: MOVING the valve (with the same pipe bend) DOES - // change the loop polygon, so the metric is not trivially constant. - let moved_valve = compute_layout_metrics( - &bent_flow_loop_view(Point { x: 150.0, y: 400.0 }, Point { x: 150.0, y: 210.0 }), - &cfg(), - ); - assert!( - (near.loop_compactness - moved_valve.loop_compactness).abs() > 1e-9, - "moving the valve must change loop_compactness (test is not trivially \ - constant): {} vs {}", - near.loop_compactness, - moved_valve.loop_compactness - ); +/// Map each directed causal connector (Link) `from_uid -> to_uid` to the polyline +/// the renderer draws for it, so loop-straightness can look up the drawn +/// curvature of a loop edge. Flows are not included (loop edges through a flow +/// valve have no Link and are correctly skipped). +fn link_polylines( + view: &datamodel::StockFlow, +) -> std::collections::HashMap<(i32, i32), Vec> { + let mut uid_elements: std::collections::HashMap = + std::collections::HashMap::new(); + for elem in &view.elements { + uid_elements.insert(elem.get_uid(), elem); } - - #[test] - fn test_loop_compactness_deterministic_under_shuffle() { - // loop_compactness is a mean over cycles, each computed from node-box - // centers in cycle order. It must be invariant to the order elements - // appear in the view's element list. - let n: i32 = 6; - let radius = 250.0; - let mut elements: Vec = Vec::new(); - for i in 0..n { - let theta = 2.0 * std::f64::consts::PI * f64::from(i) / f64::from(n); - elements.push(stock( - i + 1, - "n", - radius * theta.cos(), - radius * theta.sin(), - )); - } - for i in 0..n { - let from = i + 1; - let to = (i + 1) % n + 1; - elements.push(straight_link(100 + i, from, to)); - } - let base = compute_layout_metrics(&make_view(elements.clone()), &cfg()); - - // Reverse the element order (links before nodes, nodes reversed); the - // graph and its cycles are unchanged. - let mut shuffled = elements.clone(); - shuffled.reverse(); - let other = compute_layout_metrics(&make_view(shuffled), &cfg()); - - assert!( - (base.loop_compactness - other.loop_compactness).abs() < 1e-12, - "loop_compactness changed under element shuffle: {} vs {}", - base.loop_compactness, - other.loop_compactness - ); - assert!(base.loop_compactness > 0.0); - } - - // --- AC5.2: human-vs-auto reference-pair ordering under the committed weights --- - // - // The committed `MetricWeights::default()` must agree with the user's visual - // taste: on the agreed reference pairs the SHIPPED, hand-authored ("human") - // layout must score a lower `weighted_cost` than a machine-generated - // ("auto") layout of the SAME model. This is the objective validation of the - // calibration (Phase 4, AC5.2): if the metric and the weights did not agree - // with human taste on an obvious pair, the metric or the pair would be wrong. - // - // Construction (b) -- "human view vs generated layout" (design glossary): the - // four `default_projects` models each ship a hand-authored main view. We - // score that as-loaded view (human) and a fixed-seed `generate_layout_with_config` - // layout (auto) of the same model, and assert `human < auto`. - // - // Determinism + budget: layout is deterministic per seed (fix #633), so ONE - // fixed seed (not `generate_best_layout`'s multi-seed search) makes the test - // reproducible AND fast. The four default_projects are small (<= 42 - // elements), so a single layout generation each is well under the per-test - // budget. - // - // Anchors: reliability, fishbanks, population, dp(logistic-growth). These all - // flip the right way under the committed weights (verified during - // calibration). `sir` is deliberately NOT a human datamodel::Project { - let path = format!( - "{}/../../default_projects/{}/model.xmile", - env!("CARGO_MANIFEST_DIR"), - dir - ); - let file = - std::fs::File::open(&path).unwrap_or_else(|e| panic!("failed to open {path}: {e}")); - let mut reader = std::io::BufReader::new(file); - crate::compat::open_xmile(&mut reader) - .unwrap_or_else(|e| panic!("failed to parse {path}: {e:?}")) - } - - /// The model's as-loaded, hand-authored main `StockFlow` view (the "human" - /// reference). Panics if the model has no such view -- every chosen anchor - /// ships one, so its absence is a fixture regression. - fn human_view(project: &datamodel::Project) -> datamodel::StockFlow { - let model = project - .get_model("main") - .expect("anchor model must have a 'main' model"); - match model.views.first() { - Some(datamodel::View::StockFlow(sf)) if !sf.elements.is_empty() => sf.clone(), - _ => panic!("anchor model must ship a non-empty hand-authored main view"), + let not_arrayed = |_: &str| false; + let mut out: std::collections::HashMap<(i32, i32), Vec> = + std::collections::HashMap::new(); + for elem in &view.elements { + if let ViewElement::Link(link) = elem + && let (Some(&from), Some(&to)) = ( + uid_elements.get(&link.from_uid), + uid_elements.get(&link.to_uid), + ) + { + let polyline = connector_polyline(link, from, to, ¬_arrayed, ARC_POLYLINE_SAMPLES); + if polyline.len() >= 2 { + out.insert((link.from_uid, link.to_uid), polyline); + } } } + out +} - /// `weighted_cost` of the shipped human layout under the committed default - /// weights. - fn human_cost(project: &datamodel::Project) -> f64 { - let view = human_view(project); - compute_layout_metrics(&view, &LayoutConfig::default()) - .weighted_cost(&MetricWeights::default()) - } - - /// `weighted_cost` of a single fixed-seed generated layout under the committed - /// default weights. Deterministic per seed, so the score is reproducible. - fn auto_cost(project: &datamodel::Project) -> f64 { - let cfg = LayoutConfig { - annealing_random_seed: REF_PAIR_SEED, - ..LayoutConfig::default() - }; - let view = crate::layout::generate_layout_with_config(project, "main", cfg.clone(), None) - .expect("auto layout generation must succeed for the anchor model"); - compute_layout_metrics(&view, &cfg).weighted_cost(&MetricWeights::default()) - } - - /// Assert the human reference beats the auto layout for one anchor model, - /// naming the model and both costs on failure (so a calibration regression is - /// immediately legible). - fn assert_human_beats_auto(dir: &str) { - let project = load_default_project(dir); - let human = human_cost(&project); - let auto = auto_cost(&project); - assert!( - human < auto, - "reference pair {dir}: expected human_cost ({human}) < auto_cost ({auto}) \ - under MetricWeights::default()" - ); - } - - #[test] - fn test_reference_pair_reliability_human_beats_auto() { - assert_human_beats_auto("reliability"); - } - - #[test] - fn test_reference_pair_fishbanks_human_beats_auto() { - assert_human_beats_auto("fishbanks"); - } - - // Population is a MARGINAL taste anchor: under the committed default weights - // its human cost (~0.0521) beats auto (~0.0533) by only ~2.3%, far thinner - // than the other anchors (reliability ~8.5%, fishbanks ~12%, - // logistic-growth ~58%). The layout is deterministic per seed, so the - // assertion is not flaky -- but if it ever fails it should be read as - // "population sits near the boundary" rather than necessarily a real metric - // regression. The robust signal lives in reliability/fishbanks/logistic-growth. - #[test] - fn test_reference_pair_population_human_beats_auto() { - assert_human_beats_auto("population"); +/// `loop_straightness`: mean bow shortfall over the causal connectors that +/// participate in a feedback loop. 0.0 = every loop connector is drawn with at +/// least the target curvature (the loop reads as a visible circle); 1.0 = loop +/// connectors are straight (the loop collapses to a zig-zag). 0.0 when the view +/// has no loop with a causal connector. Deterministic and PURE; reuses the same +/// loop graph / cycle enumeration as loop_compactness. +fn compute_loop_straightness(view: &datamodel::StockFlow) -> f64 { + let graph = build_loop_graph(view); + let cycles = enumerate_simple_cycles(&graph); + if cycles.is_empty() { + return 0.0; } - - #[test] - fn test_reference_pair_dp_logistic_growth_human_beats_auto() { - assert_human_beats_auto("logistic-growth"); + let polys = link_polylines(view); + let mut seen: HashSet<(i32, i32)> = HashSet::new(); + let mut total = 0.0; + let mut count = 0usize; + for cycle in &cycles { + let n = cycle.len(); + for k in 0..n { + let edge = (cycle[k], cycle[(k + 1) % n]); + let Some(poly) = polys.get(&edge) else { + continue; // a flow-pipe edge (no Link): exempt + }; + if !seen.insert(edge) { + continue; // count each loop connector once + } + let bow = polyline_bow_ratio(poly); + let shortfall = (LOOP_LINK_TARGET_BOW - bow).max(0.0) / LOOP_LINK_TARGET_BOW; + total += shortfall; + count += 1; + } } - - #[test] - fn test_sir_auto_beats_reference_under_default_weights() { - // The documented NON-anchor: SIR's shipped reference obscures more labels - // than the auto layout, so the metric correctly prefers the auto. This - // pins that direction so the asymmetry (why SIR is excluded from the - // human ViewElement { + ViewElement::Stock(view_element::Stock { + name: name.to_string(), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +fn aux(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { + aux_side(uid, name, x, y, LabelSide::Bottom) +} + +fn aux_side(uid: i32, name: &str, x: f64, y: f64, side: LabelSide) -> ViewElement { + ViewElement::Aux(view_element::Aux { + name: name.to_string(), + uid, + x, + y, + label_side: side, + compat: None, + }) +} + +/// A cloud at `(x, y)`: a 27x27 shape box and NO label, the cleanest +/// "obscuring shape" fixture for label terms. +fn cloud(uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Cloud(view_element::Cloud { + uid, + flow_uid: -1, + x, + y, + compat: None, + }) +} + +fn straight_link(uid: i32, from_uid: i32, to_uid: i32) -> ViewElement { + ViewElement::Link(view_element::Link { + uid, + from_uid, + to_uid, + shape: LinkShape::Straight, + polarity: None, + }) +} + +fn arc_link(uid: i32, from_uid: i32, to_uid: i32, angle: f64) -> ViewElement { + ViewElement::Link(view_element::Link { + uid, + from_uid, + to_uid, + shape: LinkShape::Arc(angle), + polarity: None, + }) +} + +/// A flow valve at `(x, y)` with a two-point polyline through the valve whose +/// endpoints attach to `from_uid` and `to_uid`. +fn flow_between(uid: i32, name: &str, x: f64, y: f64, from_uid: i32, to_uid: i32) -> ViewElement { + flow_with_points( + uid, + name, + (x, y), + vec![(x, y, Some(from_uid)), (x, y, Some(to_uid))], + ) +} + +fn flow_with_points( + uid: i32, + name: &str, + valve: (f64, f64), + points: Vec<(f64, f64, Option)>, +) -> ViewElement { + ViewElement::Flow(view_element::Flow { + name: name.to_string(), + uid, + x: valve.0, + y: valve.1, + label_side: LabelSide::Bottom, + points: points + .into_iter() + .map(|(x, y, attached_to_uid)| view_element::FlowPoint { + x, + y, + attached_to_uid, + }) + .collect(), + compat: None, + label_compat: None, + }) +} + +fn make_view(elements: Vec) -> datamodel::StockFlow { + datamodel::StockFlow { + name: None, + elements, + view_box: datamodel::Rect { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }, + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + } +} + +fn cfg() -> LayoutConfig { + LayoutConfig::default() +} + +/// An alias (ghost) of the element with uid `alias_of_uid`, at `(x, y)`. +fn alias_of(uid: i32, alias_of_uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Alias(view_element::Alias { + uid, + alias_of_uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +/// The label box an element's own name occupies, measured exactly as the +/// metric measures it. +fn label_box(e: &ViewElement) -> Rect { + let side = element_label_side(e).expect("labeled element"); + label_bounds(&element_label_props_for(e, side).expect("labeled element")) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +// --- the drawn scene --- + +#[test] +fn test_flow_valve_is_scored_at_its_drawn_radius() { + // `render_flow` draws the valve circle at AUX_RADIUS (9px). A stock whose + // edge sits 7px from the valve center overlaps the drawn circle; scoring the + // valve at the 6px bounds radius would miss it. + let valve_x = 100.0; + let stock_center_x = valve_x + 7.0 + STOCK_WIDTH / 2.0; + let view = make_view(vec![ + flow_with_points( + 1, + "f", + (valve_x, 100.0), + vec![(40.0, 100.0, None), (valve_x - 20.0, 100.0, None)], + ), + stock(2, "s", stock_center_x, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + m.node_overlap > 0.0, + "a stock 7px from the valve center covers the drawn 9px valve circle" + ); + let shape = node_shape_box(&view.elements[0]).unwrap(); + assert!(close(common::rect_width(&shape), 2.0 * AUX_RADIUS)); +} + +#[test] +fn test_pipe_through_a_label_strikes_it_but_own_pipe_does_not() { + // A horizontal pipe (flow #1, valve far to the right) runs straight through + // aux #2's Bottom label: a line through the name, struck out exactly as a + // link through it would be, not a thin band of covered area. The flow's own + // label is never charged against its own pipe. + let a = aux(2, "a fairly long name", 200.0, 100.0); + let lbl = label_box(&a); + let pipe_y = (lbl.top + lbl.bottom) / 2.0; + let view = make_view(vec![ + flow_with_points( + 1, + "f", + (600.0, pipe_y), + vec![(0.0, pipe_y, None), (700.0, pipe_y, None)], + ), + a, + ]); + let m = compute_layout_metrics(&view, &cfg()); + // Two labels in the view: the aux's (fully struck: the run through the + // text far exceeds its height) and the flow's own (clear of anything). + assert!( + close(m.label_connector_overlap, 1.0 / 2.0), + "label_connector_overlap {}", + m.label_connector_overlap + ); + assert_eq!( + m.label_overlap, 0.0, + "a pipe strikes a name; it covers no area" + ); +} + +#[test] +fn test_a_pipe_into_a_stock_through_the_stocks_name_strikes_it() { + // An inflow arrives from above into stock #1 whose name sits on top: the + // pipe runs down through the name. Unlike a node's own link, which at + // least points at the name, a pipe along the face's normal simply writes + // over it, so it counts in full. Two labels -> a rate of 1/2. + let s = ViewElement::Stock(view_element::Stock { + name: "a long stock name".to_string(), + uid: 1, + x: 200.0, + y: 200.0, + label_side: LabelSide::Top, + compat: None, + }); + let top = 200.0 - crate::diagram::constants::STOCK_HEIGHT / 2.0; + let view = make_view(vec![ + s, + flow_with_points( + 2, + "f", + (500.0, 60.0), + vec![(200.0, 0.0, None), (200.0, top, Some(1))], + ), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.label_connector_overlap, 1.0 / 2.0), + "label_connector_overlap {}", + m.label_connector_overlap + ); +} + +// --- alias scoring --- + +#[test] +fn test_alias_node_overlap_charged() { + // An alias stacked exactly on an aux vs the same alias far away: the + // stacked layout must score strictly worse on node_overlap. + let stacked = make_view(vec![ + aux(1, "source variable", 100.0, 100.0), + aux(2, "another aux", 300.0, 100.0), + alias_of(3, 1, 300.0, 100.0), + ]); + let apart = make_view(vec![ + aux(1, "source variable", 100.0, 100.0), + aux(2, "another aux", 300.0, 100.0), + alias_of(3, 1, 600.0, 100.0), + ]); + let m_stacked = compute_layout_metrics(&stacked, &cfg()); + let m_apart = compute_layout_metrics(&apart, &cfg()); + assert!(m_stacked.node_overlap > m_apart.node_overlap); + assert!(m_apart.node_overlap.abs() < 1e-9); +} + +#[test] +fn test_alias_label_sized_by_source_name() { + // The alias's label box is the SOURCE element's name: a long source name + // collides with a nearby aux's label where a short one does not. + let dx = 80.0; + let long_name = make_view(vec![ + aux(1, "an extremely long variable name here", 100.0, 600.0), + aux(2, "consumer", 300.0, 100.0), + alias_of(3, 1, 300.0 + dx, 100.0), + ]); + let short_name = make_view(vec![ + aux(1, "x", 100.0, 600.0), + aux(2, "consumer", 300.0, 100.0), + alias_of(3, 1, 300.0 + dx, 100.0), + ]); + let m_long = compute_layout_metrics(&long_name, &cfg()); + let m_short = compute_layout_metrics(&short_name, &cfg()); + assert!(m_long.label_overlap > m_short.label_overlap); +} + +#[test] +fn test_alias_with_dangling_source_is_ignored() { + // An alias whose source resolves to nothing still draws its circle (so it + // can overlap) but has no derivable label; nothing panics. + let view = make_view(vec![ + aux(1, "real aux", 100.0, 100.0), + alias_of(2, 999, 100.0, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(m.node_overlap > 0.0); + assert!(m.label_overlap.is_finite()); +} + +#[test] +fn test_alias_extends_view_bounding_box() { + // A far-flung alias is a drawn element: it widens the bounding box. + let compact = make_view(vec![ + aux(1, "a", 100.0, 100.0), + aux(2, "b", 300.0, 100.0), + straight_link(10, 1, 2), + ]); + let with_far_alias = make_view(vec![ + aux(1, "a", 100.0, 100.0), + aux(2, "b", 300.0, 100.0), + straight_link(10, 1, 2), + alias_of(3, 1, 2000.0, 100.0), + ]); + let m_compact = compute_layout_metrics(&compact, &cfg()); + let m_far = compute_layout_metrics(&with_far_alias, &cfg()); + assert!(m_far.aspect_penalty > m_compact.aspect_penalty); +} + +/// Scale every coordinate of a view by `s` (element centers and flow points). +fn scale_view(view: &datamodel::StockFlow, s: f64) -> datamodel::StockFlow { + let elements = view + .elements + .iter() + .map(|e| match e { + ViewElement::Aux(a) => ViewElement::Aux(view_element::Aux { + x: a.x * s, + y: a.y * s, + ..a.clone() + }), + ViewElement::Stock(st) => ViewElement::Stock(view_element::Stock { + x: st.x * s, + y: st.y * s, + ..st.clone() + }), + ViewElement::Flow(f) => ViewElement::Flow(view_element::Flow { + x: f.x * s, + y: f.y * s, + points: f + .points + .iter() + .map(|p| view_element::FlowPoint { + x: p.x * s, + y: p.y * s, + attached_to_uid: p.attached_to_uid, + }) + .collect(), + ..f.clone() + }), + ViewElement::Module(m) => ViewElement::Module(view_element::Module { + x: m.x * s, + y: m.y * s, + ..m.clone() + }), + ViewElement::Cloud(c) => ViewElement::Cloud(view_element::Cloud { + x: c.x * s, + y: c.y * s, + ..c.clone() + }), + ViewElement::Alias(a) => ViewElement::Alias(view_element::Alias { + x: a.x * s, + y: a.y * s, + ..a.clone() + }), + other => other.clone(), + }) + .collect(); + datamodel::StockFlow { + elements, + ..view.clone() + } +} + +// --- node_overlap: mean covered fraction of each node's shape --- + +#[test] +fn test_node_overlap_known_overlap_fraction() { + // Two stocks whose centers are 20px apart: each shape is covered over a + // 25x35 band of its 45x35 area, so both nodes are 25/45 covered and the + // mean over the two nodes is 25/45. + let view = make_view(vec![ + stock(1, "a", 100.0, 100.0), + stock(2, "b", 120.0, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let expected = 25.0 / 45.0; + assert!( + close(m.node_overlap, expected), + "node_overlap {} != {expected}", + m.node_overlap + ); +} + +#[test] +fn test_node_overlap_is_a_rate_over_nodes() { + // The same stacked pair plus eight far-away stocks: the two covered nodes + // are now two of ten, so the rate drops to a fifth of the pair's. + let pair = make_view(vec![ + stock(1, "a", 100.0, 100.0), + stock(2, "b", 120.0, 100.0), + ]); + let mut elements = vec![stock(1, "a", 100.0, 100.0), stock(2, "b", 120.0, 100.0)]; + for k in 0..8 { + elements.push(stock(10 + k, "far", 1000.0 + f64::from(k) * 200.0, 1000.0)); + } + let diluted = make_view(elements); + let m_pair = compute_layout_metrics(&pair, &cfg()); + let m_diluted = compute_layout_metrics(&diluted, &cfg()); + assert!(close(m_diluted.node_overlap, m_pair.node_overlap / 5.0)); +} + +#[test] +fn test_node_overlap_touching_shapes_is_zero() { + let view = make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", STOCK_WIDTH, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_overlap, 0.0); +} + +#[test] +fn test_node_overlap_disjoint_is_zero() { + let view = make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", 500.0, 500.0), + aux(3, "c", 1000.0, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_overlap, 0.0); +} + +#[test] +fn test_node_overlap_labels_overlap_shapes_disjoint_is_zero() { + // Two Bottom-labeled auxes 40px apart: shapes disjoint, labels overlapping. + // node_overlap ignores labels; label_overlap charges them. + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_overlap, 0.0); + assert!(m.label_overlap > 0.0); +} + +// --- node_connector_overlap --- + +#[test] +fn test_node_connector_overlap_through_third_node() { + // A link between two far-apart auxes passes horizontally through a stock. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + stock(3, "s", 200.0, 0.0), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let connectors = collect_connector_geometry(&view.elements); + assert_eq!(connectors.len(), 1); + let c = &connectors[0]; + let stock_box = node_shape_box(&stock(3, "s", 200.0, 0.0)).unwrap(); + let inside: f64 = c + .polyline + .windows(2) + .map(|seg| segment_length_in_rect(&seg[0], &seg[1], &stock_box)) + .sum(); + assert!(close(m.node_connector_overlap, inside / c.length)); +} + +#[test] +fn test_node_connector_overlap_avoids_all_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + stock(3, "s", 200.0, 500.0), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_connector_overlap, 0.0); +} + +#[test] +fn test_node_connector_overlap_under_label_only_is_zero() { + // The link at y=0 passes under stock #3's label (which hangs below its + // shape) but never under the shape itself: not charged here (the label + // term charges it). + let label_only = stock(3, "s", 200.0, -25.0); + let shape = node_shape_box(&label_only).unwrap(); + let lbl = label_box(&label_only); + assert!(shape.bottom < 0.0, "fixture: the shape clears the line"); + assert!( + lbl.bottom > 0.0 && lbl.top < 0.0, + "fixture: the label spans the line" + ); + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + label_only, + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_connector_overlap, 0.0); + assert!(m.label_connector_overlap > 0.0); +} + +#[test] +fn test_link_along_a_foreign_pipe_is_charged() { + // A link runs along a pipe it has nothing to do with: it reads as part of + // the flow. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + flow_with_points( + 3, + "f", + (200.0, 300.0), + vec![(100.0, 0.0, None), (300.0, 0.0, None)], + ), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(m.node_connector_overlap > 0.0); +} + +/// Length of segment p0->p1 covered by the UNION of `rects`, for horizontal +/// segments only: the independent oracle for the union tests. +fn union_segment_length_in_rects(p0: &Point, p1: &Point, rects: &[Rect]) -> f64 { + let seg_len = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt(); + if seg_len == 0.0 { + return 0.0; + } + let mut intervals: Vec<(f64, f64)> = Vec::new(); + for r in rects { + if segment_length_in_rect(p0, p1, r) <= 0.0 { + continue; + } + let (xa, xb) = (p0.x.min(p1.x), p0.x.max(p1.x)); + let span = p1.x - p0.x; + let t_lo = ((xa.max(r.left) - p0.x) / span).clamp(0.0, 1.0); + let t_hi = ((xb.min(r.right) - p0.x) / span).clamp(0.0, 1.0); + intervals.push((t_lo.min(t_hi), t_lo.max(t_hi))); + } + merged_interval_length(&mut intervals) * seg_len +} + +#[test] +fn test_node_connector_overlap_union_of_overlapping_boxes() { + // Two overlapping non-incident stocks straddle the link: the covered length + // is their UNION, not the sum. + let s3 = stock(3, "s3", 200.0, 0.0); + let s4 = stock(4, "s4", 210.0, 0.0); + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + s3.clone(), + s4.clone(), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let connectors = collect_connector_geometry(&view.elements); + let c = &connectors[0]; + let boxes = [node_shape_box(&s3).unwrap(), node_shape_box(&s4).unwrap()]; + let union_len: f64 = c + .polyline + .windows(2) + .map(|seg| union_segment_length_in_rects(&seg[0], &seg[1], &boxes)) + .sum(); + assert!(close(m.node_connector_overlap, union_len / c.length)); + assert!(m.node_connector_overlap <= 1.0); +} + +#[test] +fn test_node_connector_overlap_coincident_boxes_counted_once() { + // A short link fully inside two coincident stocks: the fraction is exactly + // 1.0, never the 2.0 a per-box sum would report. + let view = make_view(vec![ + aux(1, "a", 180.0, 0.0), + aux(2, "b", 220.0, 0.0), + stock(3, "s3", 200.0, 0.0), + stock(4, "s4", 200.0, 0.0), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.node_connector_overlap, 1.0), + "{}", + m.node_connector_overlap + ); +} + +// --- label_overlap: mean covered fraction of each label --- + +#[test] +fn test_label_overlap_coincident_labels_are_fully_obscured() { + let view = make_view(vec![ + aux(1, "samename", 100.0, 100.0), + aux(2, "samename", 100.0, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.label_overlap, 1.0), "{}", m.label_overlap); +} + +#[test] +fn test_label_overlap_disjoint_is_zero() { + let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 1000.0, 1000.0)]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.label_overlap, 0.0); +} + +#[test] +fn test_label_overlap_hand_computed_pair() { + // Two Bottom-labeled "samename" auxes 40px apart. Each label box is 58x14 + // (8*6+10 wide), the labels overlap over 18x14, and neither label reaches + // the other aux's shape. Each label is 252/812 covered: the mean over the + // two labels is 252/812. + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.label_overlap, 252.0 / 812.0), "{}", m.label_overlap); +} + +#[test] +fn test_label_overlap_never_charged_against_own_shape() { + let view = make_view(vec![aux(1, "samename", 0.0, 0.0)]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.label_overlap, 0.0); +} + +#[test] +fn test_label_overlap_normalizes_by_label_count_not_area() { + // A cloud clips 6.5x14 of a 22x14 label (91/308 covered). Fifteen far-away + // labels dilute the rate by COUNT -- identically whether their names are + // long or short, so a big label elsewhere never hides a small collision. + let build = |filler: &str| { + let mut elements = vec![aux(1, "ab", 0.0, 0.0), cloud(2, 18.0, 20.0)]; + for k in 0..15 { + elements.push(aux(100 + k, filler, 3000.0 + f64::from(k) * 1000.0, 3000.0)); + } + make_view(elements) + }; + let expected = (91.0 / 308.0) / 16.0; + for filler in ["abcdefghijklmnopqrst", "x"] { + let m = compute_layout_metrics(&build(filler), &cfg()); + assert!( + close(m.label_overlap, expected), + "filler {filler:?}: {} expected {expected}", + m.label_overlap + ); + } +} + +// --- label_connector_overlap: lines through names --- + +#[test] +fn test_link_through_a_label_strikes_it_out() { + // A horizontal link through the middle of aux #3's single-line label: the + // run inside the (inset) text box far exceeds the box's height, so that + // label is fully struck (1.0). Three labels -> a rate of 1/3. + let target = aux(3, "a long label name", 200.0, -30.0); + let lbl = label_box(&target); + let y = (lbl.top + lbl.bottom) / 2.0; + let view = make_view(vec![ + aux(1, "a", 0.0, y), + aux(2, "b", 400.0, y), + target, + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.label_connector_overlap, 1.0 / 3.0), + "{}", + m.label_connector_overlap + ); +} + +#[test] +fn test_a_link_into_its_own_node_through_the_name_counts_at_the_own_link_factor() { + // An arrow from below into aux #1 passes vertically through #1's Bottom + // label on the way in. The run inside the inset text box is the box's + // height, so a foreign line would strike the label fully; the node's own + // link counts at OWN_LINK_STRIKE_FACTOR. Two labels -> a rate of half that. + let view = make_view(vec![ + aux(1, "a long label name", 200.0, 0.0), + aux(2, "source", 200.0, 300.0), + straight_link(10, 2, 1), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.label_connector_overlap, OWN_LINK_STRIKE_FACTOR / 2.0), + "{}", + m.label_connector_overlap + ); +} + +#[test] +fn test_link_grazing_label_padding_is_not_charged() { + // A link just inside the label box's top edge (within LABEL_INSET) passes + // through padding, not text. + let target = aux(3, "a long label name", 200.0, -30.0); + let lbl = label_box(&target); + let y = lbl.top + LABEL_INSET / 2.0; + let view = make_view(vec![ + aux(1, "a", 0.0, y), + aux(2, "b", 400.0, y), + target, + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.label_connector_overlap, 0.0); +} + +// --- crowding: clearance deficits --- + +/// Two Right-labeled auxes on one row, `gap` px between the first's label box +/// and the second's shape. +fn crowded_pair(gap: f64) -> datamodel::StockFlow { + let first = aux_side(1, "name", 0.0, 0.0, LabelSide::Right); + let lbl = label_box(&first); + let second_x = lbl.right + gap + AUX_RADIUS; + make_view(vec![ + first, + aux_side(2, "name", second_x, 0.0, LabelSide::Right), + ]) +} + +#[test] +fn test_crowding_charges_the_squared_clearance_deficit() { + let gap = 3.0; + let m = compute_layout_metrics(&crowded_pair(gap), &cfg()); + let deficit = (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + // One crowded pair over two nodes. + assert!( + close(m.crowding, deficit / 2.0), + "{} vs {}", + m.crowding, + deficit / 2.0 + ); +} + +#[test] +fn test_crowding_is_zero_beyond_the_clearance_and_monotone_within_it() { + assert_eq!( + compute_layout_metrics(&crowded_pair(COMFORTABLE_CLEARANCE + 1.0), &cfg()).crowding, + 0.0 + ); + let gaps = [0.0, 1.5, 3.0, 5.0, 7.0]; + let costs: Vec = gaps + .iter() + .map(|&g| compute_layout_metrics(&crowded_pair(g), &cfg()).crowding) + .collect(); + assert!( + costs.windows(2).all(|w| w[0] > w[1]), + "crowding must fall as the gap grows: {costs:?}" + ); +} + +#[test] +fn test_a_link_too_short_to_show_its_arrow_is_crowding() { + // Two auxes whose circles are 8px apart: the straight link between them is + // drawn 8px long, below MIN_VISIBLE_LINK, so crowding includes that link's + // deficit (one short link over one link) on top of the pair's clearance + // deficit. + let view = make_view(vec![ + aux_side(1, "a", 0.0, 0.0, LabelSide::Top), + aux_side(2, "b", 2.0 * AUX_RADIUS + 8.0, 0.0, LabelSide::Bottom), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let connectors = collect_connector_geometry(&view.elements); + let visible = connectors[0].length; + assert!( + visible < MIN_VISIBLE_LINK, + "fixture link must be short: {visible}" + ); + let far = make_view(vec![ + aux_side(1, "a", 0.0, 0.0, LabelSide::Top), + aux_side(2, "b", 300.0, 0.0, LabelSide::Bottom), + straight_link(10, 1, 2), + ]); + let m_far = compute_layout_metrics(&far, &cfg()); + assert_eq!(m_far.crowding, 0.0); + assert!( + m.crowding >= (1.0 - visible / MIN_VISIBLE_LINK).powi(2) - 1e-9, + "the short link's deficit must be charged: {}", + m.crowding + ); +} + +/// A stock with a flow leaving its right face, the valve `valve_offset` px +/// past the face; labels on the given sides. +fn stock_with_outflow( + valve_offset: f64, + stock_side: LabelSide, + flow_side: LabelSide, +) -> datamodel::StockFlow { + let edge = 100.0 + STOCK_WIDTH / 2.0; + let mut s = stock(1, "stock", 100.0, 100.0); + if let ViewElement::Stock(st) = &mut s { + st.label_side = stock_side; + } + let mut f = flow_with_points( + 2, + "f", + (edge + valve_offset, 100.0), + vec![(edge, 100.0, Some(1)), (edge + 300.0, 100.0, None)], + ); + if let ViewElement::Flow(fl) = &mut f { + fl.label_side = flow_side; + } + make_view(vec![s, f]) +} + +#[test] +fn test_a_flow_is_not_crowded_by_the_stock_its_pipe_attaches_to() { + // The valve circle sits 16px past the stock face (7px of clearance, less + // than COMFORTABLE_CLEARANCE): joined by construction, not jammed together. + // Labels on opposite sides stay clear of each other and of both shapes. + let m = compute_layout_metrics( + &stock_with_outflow(16.0, LabelSide::Top, LabelSide::Right), + &cfg(), + ); + assert_eq!(m.crowding, 0.0); +} + +#[test] +fn test_labels_of_an_attached_flow_and_stock_still_crowd() { + // The same pair with both labels Bottom: the flow's name runs into the + // stock's. Attachment excuses the shapes, never the labels. + let m = compute_layout_metrics( + &stock_with_outflow(12.0, LabelSide::Bottom, LabelSide::Bottom), + &cfg(), + ); + assert!(m.crowding > 0.0); +} + +// --- long_connectors --- + +#[test] +fn test_one_parameter_across_the_diagram_is_a_long_connector() { + // Five short links (drawn length 100: centers 118 apart, minus both 9px + // radii) and one drawn 1182 long. Median 100, threshold 300: the long link + // exceeds it by 1182/300 - 1. Mean over six links. + let mut elements = Vec::new(); + for k in 0..5 { + let base = f64::from(k) * 1000.0; + elements.push(aux(10 + k, "x", base, 0.0)); + elements.push(aux(20 + k, "y", base + 118.0, 0.0)); + elements.push(straight_link(30 + k, 10 + k, 20 + k)); + } + elements.push(aux(40, "far", 0.0, 1000.0)); + elements.push(aux(41, "consumer", 1200.0, 1000.0)); + elements.push(straight_link(42, 40, 41)); + let m = compute_layout_metrics(&make_view(elements), &cfg()); + let expected = (1182.0 / 300.0 - 1.0) / 6.0; + assert!( + (m.long_connectors - expected).abs() < 1e-6, + "{} expected {expected}", + m.long_connectors + ); +} + +#[test] +fn test_uniformly_scaled_links_are_not_long() { + // Every link the same length: nothing stands out, whatever the scale. + let mut elements = Vec::new(); + for k in 0..4 { + let base = f64::from(k) * 2000.0; + elements.push(aux(10 + k, "x", base, 0.0)); + elements.push(aux(20 + k, "y", base + 900.0, 0.0)); + elements.push(straight_link(30 + k, 10 + k, 20 + k)); + } + let m = compute_layout_metrics(&make_view(elements), &cfg()); + assert_eq!(m.long_connectors, 0.0); +} + +// --- misalignment --- + +#[test] +fn test_misalignment_counts_nodes_sharing_no_row_or_column() { + // Three auxes on one row are aligned; a fourth off every row and column + // within reach is not. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 100.0, 0.0), + aux(3, "c", 200.0, 0.0), + aux(4, "d", 50.0, 70.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.misalignment, 0.25), "{}", m.misalignment); +} + +// --- aspect_penalty --- + +#[test] +fn test_aspect_penalty_thin_box_positive() { + let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 0.0, 1000.0)]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(m.aspect_penalty > 0.0); + let boxes: Vec = build_scene_nodes(&view.elements) + .iter() + .map(SceneNode::footprint_box) + .collect(); + let bbox = view_bounding_box(&boxes).unwrap(); + let (w, h) = (common::rect_width(&bbox), common::rect_height(&bbox)); + let expected = (w.max(h) / w.min(h) - TARGET_AR_MAX).max(0.0); + assert!(close(m.aspect_penalty, expected)); +} + +#[test] +fn test_aspect_penalty_balanced_box_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + aux(3, "c", 0.0, 300.0), + aux(4, "d", 400.0, 300.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.aspect_penalty, 0.0); +} + +// --- weighted_cost --- + +#[test] +fn test_weighted_cost_exact_linear_combination() { + let m = LayoutMetrics { + node_overlap: 1.5, + node_connector_overlap: 2.0, + label_overlap: 0.5, + label_connector_overlap: 0.75, + crossings: 3.0, + crowding: 0.3, + sprawl: 4.0, + long_connectors: 0.2, + edge_length_cv: 0.25, + aspect_penalty: 6.0, + misalignment: 0.4, + loop_compactness: 8.0, + flow_bends: 9.0, + loop_straightness: 11.0, + }; + let w = MetricWeights { + node_overlap: 10.0, + node_connector_overlap: 20.0, + label_overlap: 30.0, + label_connector_overlap: 35.0, + crossings: 40.0, + crowding: 45.0, + sprawl: 50.0, + long_connectors: 55.0, + edge_length_cv: 60.0, + aspect_penalty: 70.0, + misalignment: 75.0, + loop_compactness: 90.0, + flow_bends: 100.0, + loop_straightness: 110.0, + }; + let expected: f64 = m + .terms() + .iter() + .zip(w.terms().iter()) + .map(|((name_m, v), (name_w, wt))| { + assert_eq!(name_m, name_w, "terms() and weights terms() must align"); + v * wt + }) + .sum(); + assert!(close(m.weighted_cost(&w), expected)); + // And spelled out, so `terms()` itself is checked against the fields. + let spelled = 1.5 * 10.0 + + 2.0 * 20.0 + + 0.5 * 30.0 + + 0.75 * 35.0 + + 3.0 * 40.0 + + 0.3 * 45.0 + + 4.0 * 50.0 + + 0.2 * 55.0 + + 0.25 * 60.0 + + 6.0 * 70.0 + + 0.4 * 75.0 + + 8.0 * 90.0 + + 9.0 * 100.0 + + 11.0 * 110.0; + assert!(close(m.weighted_cost(&w), spelled)); +} + +#[test] +fn test_default_weights_encode_illegibility_dominance() { + let w = MetricWeights::default(); + // Information-destroying defects outweigh crossings and spacing. + for illegible in [w.node_overlap, w.label_overlap] { + assert!(illegible > w.crossings); + assert!(illegible > w.crowding); + } + // Spacing has a finite optimum: both directions carry weight, gently. + assert!(w.crowding > 0.0 && w.sprawl > 0.0); + assert!(w.sprawl < w.crowding); + // Diagnostics carry none. + assert_eq!(w.edge_length_cv, 0.0); + assert_eq!(w.aspect_penalty, 0.0); + // Conventions are nudges below every defect weight. + for convention in [ + w.loop_compactness, + w.flow_bends, + w.loop_straightness, + w.misalignment, + ] { + assert!(convention > 0.0 && convention < w.crossings); + } +} + +// --- degenerate views --- + +fn assert_all_finite(m: &LayoutMetrics) { + for (name, v) in m.terms() { + assert!(v.is_finite(), "{name} is not finite: {v}"); + } +} + +#[test] +fn test_empty_view_all_zero_finite() { + let m = compute_layout_metrics(&make_view(vec![]), &cfg()); + assert_all_finite(&m); + for (name, v) in m.terms() { + assert_eq!(v, 0.0, "{name}"); + } +} + +#[test] +fn test_single_element_view_all_zero_finite() { + let m = compute_layout_metrics(&make_view(vec![aux(1, "only", 100.0, 100.0)]), &cfg()); + assert_all_finite(&m); + for (name, v) in m.terms() { + assert_eq!(v, 0.0, "{name}"); + } +} + +// --- scale behavior --- + +#[test] +fn test_scale_invariance_of_scale_free_terms() { + // Crossings interior to both connectors are exactly scale-invariant; the + // fraction of connector length under a fixed-size shape DROPS as the view + // is scaled up (the shape does not scale, the connector does). + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + stock(3, "s", 200.0, 0.0), + aux(4, "c", 0.0, 300.0), + stock(5, "t", 400.0, 320.0), + straight_link(10, 1, 2), + straight_link(11, 4, 5), + ]); + let base = compute_layout_metrics(&view, &cfg()); + assert_eq!(base.node_overlap, 0.0); + assert!(base.node_connector_overlap > 0.0); + let scaled = compute_layout_metrics(&scale_view(&view, 3.0), &cfg()); + assert!(close(scaled.crossings, base.crossings)); + assert!(scaled.node_connector_overlap < base.node_connector_overlap); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Every term is a function of the view's geometry, not the order its + /// elements are listed in. + #[test] + fn prop_metrics_shuffle_invariant( + xs in prop::collection::vec(-80.0f64..80.0, 4), + ys in prop::collection::vec(-80.0f64..80.0, 4), + perm in prop::sample::subsequence(vec![0usize, 1, 2, 3], 4), + ) { + let elems: Vec = (0..4) + .map(|i| if i % 2 == 0 { + stock(i as i32 + 1, "n", xs[i], ys[i]) + } else { + aux(i as i32 + 1, "an aux", xs[i], ys[i]) + }) + .collect(); + let base = compute_layout_metrics(&make_view(elems.clone()), &cfg()); + let shuffled: Vec = perm.iter().map(|&i| elems[i].clone()).collect(); + let other = compute_layout_metrics(&make_view(shuffled), &cfg()); + for ((name, a), (_, b)) in base.terms().iter().zip(other.terms().iter()) { + prop_assert!((a - b).abs() < 1e-9, "{} changed under shuffle: {} vs {}", name, a, b); + } + } +} + +// --- analyze_layout: defects agree with the score --- + +#[test] +fn test_analyze_layout_metrics_equal_compute_layout_metrics() { + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + aux(3, "c", 0.0, 300.0), + stock(4, "t", 400.0, 320.0), + straight_link(10, 1, 4), + straight_link(11, 3, 2), + ]); + let analysis = analyze_layout(&view); + assert_eq!(analysis.metrics, compute_layout_metrics(&view, &cfg())); +} + +#[test] +fn test_analyze_layout_locates_a_crossing_at_the_intersection() { + // Two links crossing at the center of the square their endpoints form. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 200.0), + aux(3, "c", 200.0, 0.0), + aux(4, "d", 0.0, 200.0), + straight_link(10, 1, 2), + straight_link(11, 3, 4), + ]); + let analysis = analyze_layout(&view); + let crossings: Vec<&Defect> = analysis + .defects + .iter() + .filter(|d| d.kind == DefectKind::Crossing) + .collect(); + assert_eq!(crossings.len(), 1); + let [l, t, r, b] = crossings[0].region; + assert!((l - 100.0).abs() < 1e-6 && (t - 100.0).abs() < 1e-6); + assert!(close(l, r) && close(t, b), "a crossing is a point"); + assert!( + close(analysis.metrics.crossings, 0.5), + "one crossing over two connectors" + ); +} + +#[test] +fn test_analyze_layout_reports_each_obscured_label_with_its_fraction() { + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + ]); + let analysis = analyze_layout(&view); + let obscured: Vec<&Defect> = analysis + .defects + .iter() + .filter(|d| d.kind == DefectKind::LabelObscured) + .collect(); + assert_eq!(obscured.len(), 2); + for d in obscured { + assert!(close(d.severity, 252.0 / 812.0)); + } +} + +// --- loop_compactness (isoperimetric loop quality) --- + +fn shape_center(e: &ViewElement) -> Point { + let r = node_shape_box(e).unwrap(); + Point { + x: (r.left + r.right) / 2.0, + y: (r.top + r.bottom) / 2.0, + } +} + +/// Hand-computed isoperimetric penalty `1 - Q` for a polygon over `centers`. +fn expected_loop_penalty(centers: &[Point]) -> f64 { + let n = centers.len(); + let mut area2 = 0.0; + let mut perim = 0.0; + for i in 0..n { + let a = centers[i]; + let b = centers[(i + 1) % n]; + area2 += a.x * b.y - b.x * a.y; + perim += ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt(); + } + let area = area2.abs() / 2.0; + 1.0 - (4.0 * std::f64::consts::PI * area / (perim * perim)).clamp(0.0, 1.0) +} + +fn cycle_view(positions: &[(f64, f64)]) -> (datamodel::StockFlow, Vec) { + let n = positions.len() as i32; + let mut elements: Vec = Vec::new(); + let mut centers = Vec::new(); + for (i, &(x, y)) in positions.iter().enumerate() { + let e = stock(i as i32 + 1, "n", x, y); + centers.push(shape_center(&e)); + elements.push(e); + } + for i in 0..n { + elements.push(straight_link(100 + i, i + 1, (i + 1) % n + 1)); + } + (make_view(elements), centers) +} + +#[test] +fn test_loop_compactness_circle_loop_near_zero() { + let positions: Vec<(f64, f64)> = (0..8) + .map(|i| { + let theta = 2.0 * std::f64::consts::PI * f64::from(i) / 8.0; + (300.0 * theta.cos(), 300.0 * theta.sin()) + }) + .collect(); + let (view, centers) = cycle_view(&positions); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.loop_compactness, expected_loop_penalty(¢ers))); + assert!(m.loop_compactness < 0.1); +} + +#[test] +fn test_loop_compactness_collapsed_loop_higher() { + let positions: Vec<(f64, f64)> = (0..8) + .map(|i| (f64::from(i) * 100.0, if i % 2 == 0 { 0.0 } else { 1.0 })) + .collect(); + let (view, centers) = cycle_view(&positions); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.loop_compactness, expected_loop_penalty(¢ers))); + assert!(m.loop_compactness > 0.9); +} + +#[test] +fn test_loop_compactness_no_cycle_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 0.0), + aux(3, "c", 400.0, 0.0), + straight_link(10, 1, 2), + straight_link(11, 2, 3), + ]); + assert_eq!(compute_layout_metrics(&view, &cfg()).loop_compactness, 0.0); +} + +#[test] +fn test_loop_straightness_straight_loop_is_high() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 300.0, 0.0), + aux(3, "c", 300.0, 300.0), + aux(4, "d", 0.0, 300.0), + straight_link(11, 1, 2), + straight_link(12, 2, 3), + straight_link(13, 3, 4), + straight_link(14, 4, 1), + ]); + assert!(compute_layout_metrics(&view, &cfg()).loop_straightness > 0.9); +} + +#[test] +fn test_loop_straightness_curved_loop_is_low() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 300.0, 0.0), + aux(3, "c", 300.0, 300.0), + aux(4, "d", 0.0, 300.0), + arc_link(11, 1, 2, 45.0), + arc_link(12, 2, 3, 45.0), + arc_link(13, 3, 4, 45.0), + arc_link(14, 4, 1, 45.0), + ]); + assert!(compute_layout_metrics(&view, &cfg()).loop_straightness < 0.5); +} + +#[test] +fn test_loop_straightness_no_loop_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 0.0), + aux(3, "c", 400.0, 0.0), + straight_link(10, 1, 2), + straight_link(11, 2, 3), + ]); + assert_eq!(compute_layout_metrics(&view, &cfg()).loop_straightness, 0.0); +} + +#[test] +fn test_loop_compactness_two_node_mutual_pair_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 0.0), + straight_link(10, 1, 2), + straight_link(11, 2, 1), + ]); + assert_eq!(compute_layout_metrics(&view, &cfg()).loop_compactness, 0.0); +} + +#[test] +fn test_loop_compactness_flow_feedback_path_is_a_cycle() { + let view = make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", 300.0, 0.0), + flow_between(3, "f", 150.0, 200.0, 1, 2), + straight_link(10, 2, 1), + ]); + assert!(compute_layout_metrics(&view, &cfg()).loop_compactness > 0.0); +} + +fn bent_flow_loop_view(valve: Point, bend: Point) -> datamodel::StockFlow { + make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", 300.0, 0.0), + flow_with_points( + 3, + "f", + (valve.x, valve.y), + vec![ + (0.0, 0.0, Some(1)), + (bend.x, bend.y, None), + (300.0, 0.0, Some(2)), + ], + ), + straight_link(10, 2, 1), + ]) +} + +#[test] +fn test_loop_compactness_scored_on_flow_valve_not_pipe_extent() { + // The loop vertex for a flow is its valve: stretching the pipe with a far + // interior point must not change the loop polygon; moving the valve must. + let valve = Point { x: 150.0, y: 200.0 }; + let near = compute_layout_metrics( + &bent_flow_loop_view(valve, Point { x: 150.0, y: 210.0 }), + &cfg(), + ); + let far = compute_layout_metrics( + &bent_flow_loop_view( + valve, + Point { + x: 150.0, + y: 2000.0, + }, + ), + &cfg(), + ); + assert!(near.loop_compactness > 0.0); + assert!((near.loop_compactness - far.loop_compactness).abs() < 1e-12); + let moved = compute_layout_metrics( + &bent_flow_loop_view(Point { x: 150.0, y: 400.0 }, Point { x: 150.0, y: 210.0 }), + &cfg(), + ); + assert!((near.loop_compactness - moved.loop_compactness).abs() > 1e-9); +} + +// --- metric validity: visibly worse diagrams must cost more --- +// +// The shipped default projects are hand-drawn, clean diagrams. Every +// degradation in the taste battery is an edit a modeler would call a +// regression; the committed metric must penalize each one on each of these +// exemplars, or an optimizer driving the metric is free to produce that very +// defect. `StraightenLinks` is excluded: flattening a non-loop connector is a +// matter of style, and only loops care (`loop_straightness`, a gentle nudge). +// +// The eval harness runs the same battery over the whole corpus, references +// and generated layouts alike; this test pins the exemplars a regression can +// never be allowed to reach. + +/// A shipped default project's hand-drawn main view. +fn default_project_view(dir: &str) -> datamodel::StockFlow { + let path = format!( + "{}/../../default_projects/{}/model.xmile", + env!("CARGO_MANIFEST_DIR"), + dir + ); + let file = std::fs::File::open(&path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(file)) + .unwrap_or_else(|e| panic!("parse {path}: {e:?}")); + match project.get_model("main").and_then(|m| m.views.first()) { + Some(datamodel::View::StockFlow(sf)) => sf.clone(), + _ => panic!("{dir} ships no main view"), + } +} + +#[test] +fn test_metric_penalizes_every_degradation_of_the_exemplars() { + let weights = MetricWeights::default(); + let mut misses = Vec::new(); + for dir in ["logistic-growth", "population", "fishbanks", "reliability"] { + let view = default_project_view(dir); + let base_metrics = compute_layout_metrics(&view, &cfg()); + let base = base_metrics.weighted_cost(&weights); + for degradation in Degradation::battery() { + if degradation == Degradation::StraightenLinks { + continue; + } + let Some(degraded) = degrade(&view, degradation) else { + continue; + }; + let metrics = compute_layout_metrics(°raded, &cfg()); + let cost = metrics.weighted_cost(&weights); + if cost <= base * 1.01 { + // Name the weighted terms that moved, so a miss is diagnosable. + let moved: Vec = metrics + .terms() + .iter() + .zip(base_metrics.terms().iter()) + .zip(weights.terms().iter()) + .filter(|((_, _), (_, w))| *w > 0.0) + .filter(|(((_, after), (_, before)), _)| (after - before).abs() > 1e-6) + .map(|(((name, after), (_, before)), (_, w))| { + format!("{name} {:+.4}", (after - before) * w) + }) + .collect(); + misses.push(format!( + "{dir}/{}: {base:.4} -> {cost:.4} [{}]", + degradation.name(), + moved.join(", ") + )); + } + } + } + assert!( + misses.is_empty(), + "the metric failed to penalize visibly worse diagrams:\n {}", + misses.join("\n ") + ); +} + +// --- human-vs-auto reference pairs under the committed weights --- +// +// On the shipped default projects, the hand-authored ("human") layout must +// score a lower cost than a fixed-seed generated ("auto") layout of the same +// model: if the metric preferred the generator's output over these exemplars, +// the metric -- not the exemplar -- would be wrong. + +const REF_PAIR_SEED: u64 = 42; + +fn human_cost(dir: &str) -> f64 { + compute_layout_metrics(&default_project_view(dir), &cfg()) + .weighted_cost(&MetricWeights::default()) +} + +fn auto_cost(dir: &str) -> f64 { + let path = format!( + "{}/../../default_projects/{}/model.xmile", + env!("CARGO_MANIFEST_DIR"), + dir + ); + let file = std::fs::File::open(&path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(file)) + .unwrap_or_else(|e| panic!("parse {path}: {e:?}")); + let config = LayoutConfig { + annealing_random_seed: REF_PAIR_SEED, + ..LayoutConfig::default() + }; + let view = crate::layout::generate_layout_with_config(&project, "main", config.clone(), None) + .expect("auto layout generation must succeed for the anchor model"); + compute_layout_metrics(&view, &config).weighted_cost(&MetricWeights::default()) +} + +fn assert_human_beats_auto(dir: &str) { + let human = human_cost(dir); + let auto = auto_cost(dir); + assert!( + human < auto, + "reference pair {dir}: expected human_cost ({human}) < auto_cost ({auto})" + ); +} + +#[test] +fn test_reference_pair_reliability_human_beats_auto() { + assert_human_beats_auto("reliability"); +} + +#[test] +fn test_reference_pair_fishbanks_human_beats_auto() { + assert_human_beats_auto("fishbanks"); +} + +#[test] +fn test_reference_pair_population_human_beats_auto() { + assert_human_beats_auto("population"); +} + +#[test] +fn test_reference_pair_logistic_growth_human_beats_auto() { + assert_human_beats_auto("logistic-growth"); +} + +// --- the label-side chooser's scene --- + +#[test] +fn test_label_scene_cost_equals_a_full_scan() { + // `LabelScene::label_cost` visits only the nodes and connectors its grid + // says can reach the candidate label. It must charge exactly what a scan of + // the whole scene charges -- bit for bit, since the chooser compares costs + // with a tie tolerance. Rows: every label side of every named element in + // the shipped exemplar diagrams, against the other labels as drawn. + use crate::datamodel::view_element::LabelSide; + let weights = MetricWeights::default(); + for dir in ["logistic-growth", "population", "fishbanks", "reliability"] { + let view = default_project_view(dir); + let scene = LabelScene::new(&view.elements); + let nodes = build_scene_nodes(&view.elements); + let connectors = collect_connector_geometry(&view.elements); + let drawn: HashMap = nodes + .iter() + .filter_map(|n| n.label.map(|l| (n.uid, l))) + .collect(); + let label_of = |uid: i32| drawn.get(&uid).copied(); + for elem in &view.elements { + for side in [ + LabelSide::Top, + LabelSide::Bottom, + LabelSide::Left, + LabelSide::Right, + ] { + let Some(props) = element_label_props_for(elem, side) else { + continue; + }; + let lbl = label_bounds(&props); + let owner = elem.get_uid(); + let indexed = scene.label_cost(owner, &lbl, label_of, &weights); + + let own = nodes + .iter() + .find(|n| n.uid == owner) + .expect("owner in scene"); + let area = rect_area(&lbl); + let mut covered = 0.0; + let mut crowding = 0.0; + for other in nodes.iter().filter(|n| n.uid != owner) { + let other_label = label_of(other.uid); + covered += rect_overlap_area(&lbl, &other.shape); + if let Some(ol) = &other_label { + covered += rect_overlap_area(&lbl, ol); + } + if own.is_cloud || other.is_cloud { + continue; + } + let (gap, _) = footprint_gap(own, Some(lbl), other, other_label); + if gap < COMFORTABLE_CLEARANCE { + crowding += (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + } + } + let labels = nodes.iter().filter(|n| n.label.is_some()).count(); + let scanned = weights.label_overlap * covered.min(area) / area + + weights.label_connector_overlap + * label_strike_fraction(owner, &lbl, &connectors) + + weights.crowding * (labels as f64 / nodes.len() as f64) * crowding; + assert_eq!( + indexed.to_bits(), + scanned.to_bits(), + "{dir}: element {owner} side {side:?}: indexed {indexed} vs scanned {scanned}" + ); + } + } + } +} diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index 2efad46b1..a83bf78e7 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -13,12 +13,16 @@ mod detect_ltm_loops; #[cfg(any(test, feature = "layout_eval"))] pub mod eval_stats; pub mod graph; +mod incremental; pub mod metadata; pub mod metrics; mod objective; mod orthogonal; pub mod placement; +mod polish; pub mod sfdp; +#[cfg(any(test, feature = "layout_eval"))] +pub mod taste; pub mod text; pub mod uid; @@ -41,6 +45,12 @@ use self::connector::{ }; use self::detect_ltm_loops::try_detect_ltm_loops; use self::graph::{ConstrainedGraphBuilder, Graph, GraphBuilder, Layout, Position}; +#[cfg(test)] +use self::incremental::{build_stock_flow_from_state, existing_bounding_box}; +pub use self::incremental::{ + compute_new_element_positions, diff_clouds, diff_connectors, incremental_layout, + resnap_flow_endpoints, settle_new_elements, +}; use self::metadata::{ComputedMetadata, StockFlowChain}; use self::objective::{ point_node_footprint_overlap, point_node_footprints, point_node_pileup_count, @@ -341,1191 +351,131 @@ impl LayoutState { } // Clean up cloud bookkeeping for the deleted flow - let canonical_str = canonical.into_owned(); - if let Some(cloud_idents) = self.flow_ident_to_clouds.remove(&canonical_str) { - for ci in &cloud_idents { - self.cloud_ident_to_uid.remove(ci); - self.cloud_ident_to_flow_ident.remove(ci); - } - } - self.display_names.remove(&canonical_str); - } - - /// Update a variable's identity in-place while preserving its - /// position and UID. Updates the element name, uid_manager - /// mapping, and display_names entry. - pub fn apply_rename(&mut self, old_ident: &str, new_ident: &str, new_display_name: &str) { - let old_canonical = canonicalize(old_ident).into_owned(); - let uid = match self.uid_manager.get_uid(&old_canonical) { - Some(uid) => uid, - None => return, - }; - - for elem in &mut self.elements { - if elem.get_uid() != uid { - continue; - } - let formatted = format_label_with_line_breaks(new_display_name); - match elem { - ViewElement::Aux(a) => a.name = formatted, - ViewElement::Stock(s) => s.name = formatted, - ViewElement::Flow(f) => f.name = formatted, - ViewElement::Module(m) => m.name = formatted, - _ => {} - } - break; - } - - let new_canonical = canonicalize(new_ident).into_owned(); - self.uid_manager.rename(&old_canonical, &new_canonical); - self.display_names.remove(&old_canonical); - self.display_names - .insert(new_canonical, new_display_name.to_string()); - } - - /// Walk model variables and identify which ones are not yet represented - /// in this layout state (either no UID mapping or no view element with - /// that UID), classifying each by variable type. - pub fn identify_new_elements(&self, model: &datamodel::Model) -> NewElements { - let existing_uids: HashSet = self.elements.iter().map(|e| e.get_uid()).collect(); - - let mut new_stocks = Vec::new(); - let mut new_flows = Vec::new(); - let mut new_auxes = Vec::new(); - let mut new_modules = Vec::new(); - - for var in &model.variables { - let canonical = canonicalize(var.get_ident()).into_owned(); - let is_new = match self.uid_manager.get_uid(&canonical) { - None => true, - Some(uid) => !existing_uids.contains(&uid), - }; - - if is_new { - match var { - datamodel::Variable::Stock(_) => new_stocks.push(canonical), - datamodel::Variable::Flow(_) => new_flows.push(canonical), - datamodel::Variable::Aux(_) => new_auxes.push(canonical), - datamodel::Variable::Module(_) => new_modules.push(canonical), - } - } - } - - NewElements { - new_stocks, - new_flows, - new_auxes, - new_modules, - } - } -} - -/// Variables in the model that have no corresponding view element in -/// the current layout state, classified by type. -pub struct NewElements { - pub new_stocks: Vec, - pub new_flows: Vec, - pub new_auxes: Vec, - pub new_modules: Vec, -} - -impl NewElements { - pub fn is_empty(&self) -> bool { - self.new_stocks.is_empty() - && self.new_flows.is_empty() - && self.new_auxes.is_empty() - && self.new_modules.is_empty() - } -} - -/// Compute initial positions for newly-added elements based on their -/// dependency connections to existing elements. -/// -/// Three placement strategies: -/// - Connected aux/module: centroid of connected existing elements with -/// ring spreading when multiple new elements share the same connections -/// - Connected chain element: near connected existing elements with offset -/// - Disconnected element: at the diagram periphery beyond existing bounds -pub fn compute_new_element_positions( - state: &LayoutState, - metadata: &ComputedMetadata, - new_elements: &NewElements, -) -> HashMap { - let mut result: HashMap = HashMap::new(); - - let new_set: HashSet<&str> = new_elements - .new_stocks - .iter() - .chain(&new_elements.new_flows) - .chain(&new_elements.new_auxes) - .chain(&new_elements.new_modules) - .map(|s| s.as_str()) - .collect(); - - // Compute bounding box of all existing positioned elements for periphery placement - let (bbox_min, bbox_max) = existing_bounding_box(state); - - // Place new auxes and modules near connected existing elements - place_new_point_elements( - state, - metadata, - &new_elements.new_auxes, - &new_set, - &bbox_min, - &bbox_max, - &mut result, - ); - place_new_point_elements( - state, - metadata, - &new_elements.new_modules, - &new_set, - &bbox_min, - &bbox_max, - &mut result, - ); - - // Place new stocks and flows (chain elements) - place_new_chain_elements( - state, - metadata, - new_elements, - &new_set, - &bbox_max, - &mut result, - ); - - result -} - -/// Bounding box of variable elements (stocks, flows, auxes, modules) only. -/// Excludes aliases, groups, and clouds so that outlier non-variable elements -/// don't push new variable placement far from the actual model graph. -/// Returns ((min_x, min_y), (max_x, max_y)). -/// When no variable elements exist, returns a default origin area. -fn existing_bounding_box(state: &LayoutState) -> (Position, Position) { - let variable_uids: HashSet = state - .elements - .iter() - .filter(|e| { - matches!( - e, - ViewElement::Stock(_) - | ViewElement::Flow(_) - | ViewElement::Aux(_) - | ViewElement::Module(_) - ) - }) - .map(|e| e.get_uid()) - .collect(); - - let mut min_x = f64::MAX; - let mut min_y = f64::MAX; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - let mut found = false; - for (&uid, pos) in &state.positions { - if !variable_uids.contains(&uid) { - continue; - } - found = true; - min_x = min_x.min(pos.x); - min_y = min_y.min(pos.y); - max_x = max_x.max(pos.x); - max_y = max_y.max(pos.y); - } - if !found { - return ( - Position::new(DIAGRAM_ORIGIN_MARGIN, DIAGRAM_ORIGIN_MARGIN), - Position::new(DIAGRAM_ORIGIN_MARGIN, DIAGRAM_ORIGIN_MARGIN), - ); - } - (Position::new(min_x, min_y), Position::new(max_x, max_y)) -} - -/// Collect (uid, position) pairs for existing elements connected to a given -/// ident via dep_graph (things `ident` depends on) and reverse_dep_graph -/// (things that depend on `ident`), excluding other new elements. -/// -/// Returning UIDs alongside positions lets callers build grouping keys -/// directly from stable identifiers rather than doing a position-based -/// reverse lookup. -fn connected_existing_positions( - state: &LayoutState, - metadata: &ComputedMetadata, - ident: &str, - new_set: &HashSet<&str>, -) -> Vec<(i32, Position)> { - let mut pairs = Vec::new(); - let mut seen = HashSet::new(); - - // Forward: things this element depends on - if let Some(deps) = metadata.dep_graph.get(ident) { - for dep in deps { - if new_set.contains(dep.as_str()) || !seen.insert(dep.as_str()) { - continue; - } - if let Some(uid) = state.uid_manager.get_uid(dep) - && let Some(&pos) = state.positions.get(&uid) - { - pairs.push((uid, pos)); - } - } - } - - // Reverse: things that depend on this element - if let Some(dependents) = metadata.reverse_dep_graph.get(ident) { - for dep in dependents { - if new_set.contains(dep.as_str()) || !seen.insert(dep.as_str()) { - continue; - } - if let Some(uid) = state.uid_manager.get_uid(dep) - && let Some(&pos) = state.positions.get(&uid) - { - pairs.push((uid, pos)); - } - } - } - - pairs -} - -/// Centroid of a non-empty set of positions. -fn centroid(positions: &[Position]) -> Position { - let n = positions.len() as f64; - let sum_x: f64 = positions.iter().map(|p| p.x).sum(); - let sum_y: f64 = positions.iter().map(|p| p.y).sum(); - Position::new(sum_x / n, sum_y / n) -} - -/// Place new aux or module elements near their connected existing elements, -/// spreading multiple elements that share the same connections into a ring. -fn place_new_point_elements( - state: &LayoutState, - metadata: &ComputedMetadata, - new_idents: &[String], - new_set: &HashSet<&str>, - bbox_min: &Position, - bbox_max: &Position, - result: &mut HashMap, -) { - if new_idents.is_empty() { - return; - } - - // Group new elements by their set of connected existing element UIDs - // so we can spread apart those that share the same connection set. - let mut connection_groups: HashMap, Vec> = HashMap::new(); - let mut ident_centroids: HashMap = HashMap::new(); - let mut disconnected_index: usize = 0; - - for ident in new_idents { - let connected = connected_existing_positions(state, metadata, ident, new_set); - if connected.is_empty() { - // No connections to existing elements: place at periphery, - // staggering vertically so multiple disconnected inserts don't overlap. - let periphery_x = bbox_max.x + 150.0; - let center_y = (bbox_min.y + bbox_max.y) / 2.0; - let offset_y = disconnected_index as f64 * 80.0; - disconnected_index += 1; - result.insert( - ident.clone(), - Position::new(periphery_x, center_y + offset_y), - ); - continue; - } - - let positions: Vec = connected.iter().map(|(_, p)| *p).collect(); - let center = centroid(&positions); - ident_centroids.insert(ident.clone(), center); - - // Build a sorted UID key for grouping elements that share the same - // connection set, so they can be spread into a ring rather than stacked. - let mut uid_key: Vec = connected.iter().map(|(uid, _)| *uid).collect(); - uid_key.sort(); - uid_key.dedup(); - - connection_groups - .entry(uid_key) - .or_default() - .push(ident.clone()); - } - - // Place each group, spreading elements in a ring when multiple share - // the same connection set (AC4.4). - for group in connection_groups.values() { - let group_count = group.len(); - for (i, ident) in group.iter().enumerate() { - let base = ident_centroids - .get(ident) - .copied() - .unwrap_or(Position::new(bbox_max.x + 150.0, bbox_min.y)); - - if group_count == 1 { - // Offset slightly from the centroid so SFDP has non-zero - // initial displacement. Without this, a new element seeded - // exactly on its only neighbor gets zero force and stays stacked. - result.insert(ident.clone(), Position::new(base.x + 50.0, base.y + 30.0)); - } else { - let angle = i as f64 * 2.0 * PI / group_count.max(8) as f64; - let radius = 50.0; - result.insert( - ident.clone(), - Position::new(base.x + radius * angle.cos(), base.y + radius * angle.sin()), - ); - } - } - } -} - -/// Place new stock and flow elements. When connected to existing -/// structure, place near the connected elements; when disconnected, -/// place at the diagram periphery. -fn place_new_chain_elements( - state: &LayoutState, - metadata: &ComputedMetadata, - new_elements: &NewElements, - new_set: &HashSet<&str>, - bbox_max: &Position, - result: &mut HashMap, -) { - let offset_x = 100.0; - let offset_y = 50.0; - - for stock_ident in &new_elements.new_stocks { - let connected = connected_existing_positions(state, metadata, stock_ident, new_set); - if connected.is_empty() { - // Periphery placement - let pos = Position::new(bbox_max.x + 150.0, bbox_max.y + offset_y); - result.insert(stock_ident.clone(), pos); - } else { - let positions: Vec = connected.iter().map(|(_, p)| *p).collect(); - let center = centroid(&positions); - result.insert( - stock_ident.clone(), - Position::new(center.x + offset_x, center.y + offset_y), - ); - } - } - - for flow_ident in &new_elements.new_flows { - // A flow between two EXISTING stocks belongs at their midpoint (the valve - // sits on the pipe between them), not offset to the side -- and it is - // pinned there during settle (see `settle_new_elements`), because as a - // free SFDP node with rest length k it would be pushed far from the - // midpoint whenever the two stocks are closer together than k. - if let Some(mid) = stock_to_stock_flow_midpoint(state, metadata, flow_ident, new_set) { - result.insert(flow_ident.clone(), mid); - continue; - } - let connected = connected_existing_positions(state, metadata, flow_ident, new_set); - if connected.is_empty() { - let pos = Position::new(bbox_max.x + 200.0, bbox_max.y + offset_y); - result.insert(flow_ident.clone(), pos); - } else { - let positions: Vec = connected.iter().map(|(_, p)| *p).collect(); - let center = centroid(&positions); - result.insert( - flow_ident.clone(), - Position::new(center.x + offset_x, center.y), - ); - } - } -} - -/// The midpoint of a flow's two stocks when BOTH already exist (are not new), or -/// `None` otherwise (a cloud flow, or a flow into/out of a new stock, which the -/// chain/rigid-group machinery positions instead). This is the canonical valve -/// position for an incrementally-added stock-to-stock flow. -fn stock_to_stock_flow_midpoint( - state: &LayoutState, - metadata: &ComputedMetadata, - flow_ident: &str, - new_set: &HashSet<&str>, -) -> Option { - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - let from_stock = from_stock?; - let to_stock = to_stock?; - if new_set.contains(from_stock) || new_set.contains(to_stock) { - return None; - } - let a = state - .uid_manager - .get_uid(from_stock) - .and_then(|uid| state.positions.get(&uid).copied())?; - let b = state - .uid_manager - .get_uid(to_stock) - .and_then(|uid| state.positions.get(&uid).copied())?; - Some(chain::stock_pair_valve_position(a, b, 0, 1)) -} - -/// Run SFDP + annealing with existing elements pinned and only new -/// elements free to move. This settles new elements into positions -/// that respect the force-directed layout while preserving all -/// existing element positions exactly. -pub fn settle_new_elements( - state: &mut LayoutState, - config: &LayoutConfig, - model: &datamodel::Model, - metadata: &ComputedMetadata, - new_elements: &NewElements, - chains_data: &[(Vec, Vec, Vec)], -) -> Result<(), String> { - if new_elements.is_empty() { - return Ok(()); - } - - let new_ident_set: HashSet<&str> = new_elements - .new_stocks - .iter() - .chain(&new_elements.new_flows) - .chain(&new_elements.new_auxes) - .chain(&new_elements.new_modules) - .map(|s| s.as_str()) - .collect(); - - // Isolated variables are excluded from the force graph (see - // `build_full_graph`), which on this incremental path means they simply - // stay where `compute_new_element_positions` placed them -- no parking - // pass, since incremental layout's contract is minimal disturbance. - let FullGraph { - graph: full_graph, - var_to_node, - isolated_vars: _, - } = build_full_graph(state, model, metadata)?; - - // Build constrained graph: pin existing elements, make new chains rigid groups - let mut constrained_builder = ConstrainedGraphBuilder::new(full_graph); - - // Pin all existing (non-new) nodes, plus any NEW flow that connects two - // existing stocks: its valve is fixed at the stock midpoint - // (`stock_to_stock_flow_midpoint`), so letting it float as an SFDP node - // (rest length k) would push it far off whenever the stocks are closer than - // k. The rest of a genuinely new chain still settles normally. - let mut pinned_node_ids: Vec = var_to_node - .iter() - .filter(|(ident, _)| !new_ident_set.contains(ident.as_str())) - .map(|(_, node_id)| node_id.clone()) - .collect(); - for flow_ident in &new_elements.new_flows { - if stock_to_stock_flow_midpoint(state, metadata, flow_ident, &new_ident_set).is_some() - && let Some(node_id) = var_to_node.get(flow_ident) - { - pinned_node_ids.push(node_id.clone()); - } - } - constrained_builder.pin(&pinned_node_ids); - - // Add rigid groups for new chain elements (same pattern as run_sfdp_with_rigid_chains) - for (_stocks, _flows, all_vars) in chains_data { - let mut group_members: Vec = Vec::new(); - let mut added: HashSet = HashSet::new(); - - for var_ident in all_vars { - if !new_ident_set.contains(var_ident.as_str()) { - continue; - } - if let Some(node_id) = var_to_node.get(var_ident) - && added.insert(node_id.clone()) - { - group_members.push(node_id.clone()); - - let canonical = canonicalize(var_ident); - if let Some(cloud_idents) = state.flow_ident_to_clouds.get(canonical.as_ref()) { - for cloud_ident in cloud_idents { - if let Some(cloud_node) = var_to_node.get(cloud_ident) - && added.insert(cloud_node.clone()) - { - group_members.push(cloud_node.clone()); - } - } - } - } - } - - if group_members.len() > 1 { - constrained_builder.add_rigid_group(group_members); - } - } - - let constrained_graph = constrained_builder.build(); - - // Seed initial positions: existing elements from state.positions, - // new elements from state.positions (which were set by compute_new_element_positions) - let mut initial_layout: Layout = BTreeMap::new(); - for (var_ident, node_id) in &var_to_node { - if let Some(uid) = state.uid_manager.get_uid(var_ident) - && let Some(&pos) = state.positions.get(&uid) - { - initial_layout.insert(node_id.clone(), pos); - continue; - } - if let Some(&cloud_uid) = state.cloud_ident_to_uid.get(var_ident) - && let Some(&pos) = state.positions.get(&cloud_uid) - { - initial_layout.insert(node_id.clone(), pos); - } - } - - let sfdp_config = SfdpConfig::for_aux_placement(); - - let node_to_ident: HashMap = var_to_node - .iter() - .map(|(ident, node_id)| (node_id.clone(), ident.clone())) - .collect(); - let stock_inflows: HashMap> = metadata - .stock_to_inflows - .iter() - .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) - .collect(); - let stock_outflows: HashMap> = metadata - .stock_to_outflows - .iter() - .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) - .collect(); - - let new_node_ids: HashSet = var_to_node - .iter() - .filter(|(ident, _)| new_ident_set.contains(ident.as_str())) - .map(|(_, node_id)| node_id.clone()) - .collect(); - - let build_segments = |candidate_layout: &Layout| -> Vec { - let mut segments = Vec::new(); - - for edge in constrained_graph.edges() { - let (Some(&from_pos), Some(&to_pos)) = ( - candidate_layout.get(&edge.from), - candidate_layout.get(&edge.to), - ) else { - continue; - }; - - if let (Some(from_ident), Some(to_ident)) = - (node_to_ident.get(&edge.from), node_to_ident.get(&edge.to)) - && is_structural_stock_flow(from_ident, to_ident, &stock_inflows, &stock_outflows) - { - continue; - } - - segments.push(LineSegment { - start: from_pos, - end: to_pos, - from_node: edge.from.clone(), - to_node: edge.to.clone(), - }); - } - - for (flow_ident, tmpl) in &state.flow_templates { - if tmpl.offsets.len() < 2 { - continue; - } - let Some(node_id) = var_to_node.get(flow_ident) else { - continue; - }; - let Some(¢er) = candidate_layout.get(node_id) else { - continue; - }; - - let points: Vec = tmpl - .offsets - .iter() - .map(|offset| Position::new(center.x + offset.x, center.y + offset.y)) - .collect(); - - for i in 0..points.len() - 1 { - segments.push(LineSegment { - start: points[i], - end: points[i + 1], - from_node: format!("{}#{}", flow_ident, i), - to_node: format!("{}#{}", flow_ident, i + 1), - }); - } - } - - segments - }; - - let mut adjacency: annealing::AdjacencyMap = HashMap::new(); - for edge in constrained_graph.edges() { - adjacency - .entry(edge.from.clone()) - .or_default() - .push((edge.to.clone(), edge.weight)); - adjacency - .entry(edge.to.clone()) - .or_default() - .push((edge.from.clone(), edge.weight)); - } - - let max_delta_aux = config.annealing_max_delta_aux; - let annealing_config = config.clone(); - let annealing_seed = config.annealing_random_seed; - - let mut annealing_round: usize = 0; - let mut last_annealing_iter: usize = 0; - let mut best_cost: f64 = f64::INFINITY; - let mut best_layout: Option> = None; - - let final_layout = compute_layout_from_initial_with_callback( - &constrained_graph, - &sfdp_config, - &initial_layout, - annealing_seed, - &mut |iter, layout| { - if !should_trigger_annealing( - iter, - annealing_config.annealing_interval, - last_annealing_iter, - annealing_round, - annealing_config.annealing_max_rounds, - ) { - return None; - } - - let result = run_annealing_with_filter( - layout, - build_segments, - // Incremental settling perturbs only the new elements around - // pinned existing ones; a new element must still not land on - // top of another node. - |layout: &Layout| point_node_pileup_count(layout, &new_node_ids) as f64, - &annealing_config, - annealing_seed.wrapping_add(annealing_round as u64), - |node_id: &String| new_node_ids.contains(node_id), - |node_id: &String| { - if new_node_ids.contains(node_id) { - max_delta_aux - } else { - 0.0 - } - }, - &adjacency, - ); - - last_annealing_iter = iter; - annealing_round += 1; - - if result.cost < best_cost { - best_cost = result.cost; - best_layout = Some(result.layout.clone()); - Some(result.layout) - } else { - None - } - }, - ); - - let settled_layout = if let Some(saved) = best_layout { - let final_crossings = annealing::count_crossings(&build_segments(&final_layout)); - if final_crossings as f64 > best_cost { - saved - } else { - final_layout - } - } else { - final_layout - }; - - // Only update positions for new elements; existing elements stay unchanged - for (var_ident, node_id) in &var_to_node { - if !new_ident_set.contains(var_ident.as_str()) { - continue; - } - if let Some(&pos) = settled_layout.get(node_id) - && let Some(uid) = state.uid_manager.get_uid(var_ident) - { - state.positions.insert(uid, pos); - } - } - - // Also update positions for clouds of new flows. SFDP moves cloud nodes in a rigid - // group together with their parent flow, but the loop above skips cloud idents since - // they are not model variables and therefore not in new_ident_set. Without recording - // the settled cloud positions here, the coordinate update loop in incremental_layout - // cannot apply the flow's displacement to the cloud element, leaving the cloud stranded - // at its creation position while the flow endpoint shifts. - for var_ident in var_to_node.keys() { - if !new_ident_set.contains(var_ident.as_str()) { - continue; - } - let canonical = canonicalize(var_ident); - if let Some(cloud_idents) = state.flow_ident_to_clouds.get(canonical.as_ref()) { - for cloud_ident in cloud_idents { - if let Some(&cloud_uid) = state.cloud_ident_to_uid.get(cloud_ident) - && let Some(cloud_node) = var_to_node.get(cloud_ident) - && let Some(&pos) = settled_layout.get(cloud_node) - { - state.positions.insert(cloud_uid, pos); - } - } - } - } - - Ok(()) -} - -/// Re-snap stock-attached flow endpoints to stock edges after SFDP settlement. -/// -/// SFDP may move flow valves while stocks stay pinned, causing the -/// proportional point translation to detach endpoints from their stocks. -/// This function restores each attached endpoint to the correct stock -/// edge, using the flow valve position to determine which face of the -/// stock rectangle the flow approaches from. -pub fn resnap_flow_endpoints(state: &mut LayoutState, config: &LayoutConfig) { - let stock_positions: HashMap = state - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Stock(s) => Some((s.uid, Position::new(s.x, s.y))), - _ => None, - }) - .collect(); - - let half_w = config.stock_width / 2.0; - let half_h = config.stock_height / 2.0; - - for elem in &mut state.elements { - if let ViewElement::Flow(f) = elem { - let valve = Position::new(f.x, f.y); - for pt in &mut f.points { - if let Some(attached_uid) = pt.attached_to_uid - && let Some(stock_pos) = stock_positions.get(&attached_uid) - { - let dx = valve.x - stock_pos.x; - let dy = valve.y - stock_pos.y; - - // Determine which face the flow approaches from using - // aspect-ratio-normalized comparison of dx vs dy. - if half_h * dx.abs() >= half_w * dy.abs() { - // Horizontal approach: snap to left or right edge. - // Preserve the y position (may be off-center for - // multi-flow sides), clamped to stock bounds. - pt.x = stock_pos.x + dx.signum() * half_w; - pt.y = pt.y.clamp(stock_pos.y - half_h, stock_pos.y + half_h); - } else { - // Vertical approach: snap to top or bottom edge. - // Preserve the x position (may be off-center for - // multi-flow sides), clamped to stock bounds. - pt.x = pt.x.clamp(stock_pos.x - half_w, stock_pos.x + half_w); - pt.y = stock_pos.y + dy.signum() * half_h; - } - } - } - } - } -} - -/// Perform three-way connector diff: compare old links in LayoutState -/// against edges derived from the current dep_graph, then preserve -/// unchanged links, remove stale ones, and create new links with -/// default shapes. -pub fn diff_connectors(state: &mut LayoutState, metadata: &ComputedMetadata) { - // Build HashMap<(from_uid, to_uid), ViewElement> for existing links - let mut old_links: HashMap<(i32, i32), ViewElement> = HashMap::new(); - for elem in &state.elements { - if let ViewElement::Link(l) = elem { - old_links.insert((l.from_uid, l.to_uid), elem.clone()); - } - } - - // Compute new dependency edges from dep_graph, skipping structural flow-stock edges - let stock_inflows: HashMap> = metadata - .stock_to_inflows - .iter() - .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) - .collect(); - let stock_outflows: HashMap> = metadata - .stock_to_outflows - .iter() - .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) - .collect(); - - let mut new_edges: HashSet<(i32, i32)> = HashSet::new(); - let mut new_edge_idents: HashMap<(i32, i32), (String, String)> = HashMap::new(); - - for (var, deps) in &metadata.dep_graph { - for dep in deps { - let from_ident = dep.as_str(); - let to_ident = var.as_str(); - - if is_structural_flow_stock(from_ident, to_ident, &stock_inflows, &stock_outflows) { - continue; - } - - let from_uid = match state.uid_manager.get_uid(from_ident) { - Some(uid) => uid, - None => continue, - }; - let to_uid = match state.uid_manager.get_uid(to_ident) { - Some(uid) => uid, - None => continue, - }; - - if from_uid != 0 && to_uid != 0 { - new_edges.insert((from_uid, to_uid)); - new_edge_idents.insert( - (from_uid, to_uid), - (from_ident.to_string(), to_ident.to_string()), - ); - } - } - } - - // Build alias UID -> primary variable UID mapping so that old links - // targeting aliases are recognized as semantically equivalent to the - // primary variable link. Without this, imported views with causal links - // terminating on aliases would lose those links after an incremental edit. - let alias_to_primary: HashMap = state - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Alias(a) => Some((a.uid, a.alias_of_uid)), - _ => None, - }) - .collect(); - - // Remove all old links from elements - state - .elements - .retain(|elem| !matches!(elem, ViewElement::Link(_))); - - // Track which old links have been consumed so each is used at most once. - let mut consumed_old_links: HashSet<(i32, i32)> = HashSet::new(); - - // Iterate edges in a deterministic order. `new_edges` is a HashSet, so its - // iteration order is per-process random; since each newly-created link both - // allocates a sequential `uid` and is appended to `state.elements` in this - // loop, hash order would otherwise assign different uids / element ordering - // to the same logical link run-to-run (the incremental analogue of #633). - let mut sorted_new_edges: Vec<(i32, i32)> = new_edges.iter().copied().collect(); - sorted_new_edges.sort_unstable(); - - // Add back preserved links (unchanged) and create new links - for (from_uid, to_uid) in sorted_new_edges { - if let Some(old_link) = old_links.get(&(from_uid, to_uid)) { - // Preserved: keep the old link exactly as-is - state.elements.push(old_link.clone()); - consumed_old_links.insert((from_uid, to_uid)); - } else if let Some(key) = old_links - .keys() - .copied() - .filter(|&(of, ot)| { - if consumed_old_links.contains(&(of, ot)) { - return false; - } - let rf = alias_to_primary.get(&of).copied().unwrap_or(of); - let rt = alias_to_primary.get(&ot).copied().unwrap_or(ot); - rf == from_uid && rt == to_uid - }) - // Pick the lowest matching key so the alias-match selection is - // deterministic; HashMap iteration order would otherwise vary. - .min() - { - // Preserved via alias: the old link targets an alias whose primary - // variable matches this dependency edge. Keep the alias link as-is. - state.elements.push(old_links[&key].clone()); - consumed_old_links.insert(key); - } else if let Some((from_ident, to_ident)) = new_edge_idents.get(&(from_uid, to_uid)) { - // Added: create new link with default shape - let link_uid = state.uid_manager.alloc(""); - let shape = if is_structural_stock_flow( - from_ident, - to_ident, - &stock_inflows, - &stock_outflows, - ) { - let arc_angle = if let (Some(&s_pos), Some(&f_pos)) = - (state.positions.get(&from_uid), state.positions.get(&to_uid)) - { - calc_stock_flow_arc_angle(s_pos, f_pos) - } else { - -45.0 - }; - LinkShape::Arc(arc_angle) - } else if metadata - .dep_graph - .get(from_ident) - .is_some_and(|deps| deps.contains(to_ident)) - { - let arc_angle = if let (Some(&from_pos), Some(&to_pos)) = - (state.positions.get(&from_uid), state.positions.get(&to_uid)) - { - calc_reciprocal_arc_angle(from_pos, to_pos) - } else { - -45.0 - }; - LinkShape::Arc(arc_angle) - } else { - LinkShape::Straight - }; - - state.elements.push(ViewElement::Link(view_element::Link { - uid: link_uid, - from_uid, - to_uid, - shape, - polarity: None, - })); - } - } - - // Preserve remaining alias-backed links whose alias-resolved endpoints - // match a valid dependency. Imported views may have multiple rendered - // connectors for the same dependency (e.g., links to two different - // aliases of the same variable). - // Iterate in a deterministic order for the same reason as the new-edge loop: - // the preserved links are appended to `state.elements`, so HashMap iteration - // order would otherwise perturb element ordering run-to-run. - let mut sorted_old_links: Vec<&(i32, i32)> = old_links.keys().collect(); - sorted_old_links.sort_unstable(); - for &(of, ot) in sorted_old_links { - if consumed_old_links.contains(&(of, ot)) { - continue; - } - let rf = alias_to_primary.get(&of).copied().unwrap_or(of); - let rt = alias_to_primary.get(&ot).copied().unwrap_or(ot); - if new_edges.contains(&(rf, rt)) { - state.elements.push(old_links[&(of, ot)].clone()); - } - } -} - -/// Diff clouds for all flows: preserve existing clouds that are still -/// needed, remove clouds whose flow endpoint is now connected to a -/// stock, and create new clouds for newly-unconnected flow endpoints. -pub fn diff_clouds(state: &mut LayoutState, metadata: &ComputedMetadata) { - // Index existing clouds by (flow_uid, is_source). - // A source cloud is at the first flow point, a sink at the last. - // We distinguish them by checking their position against the flow - // element's points when possible, but we can also use a simpler - // heuristic: group all clouds by flow_uid. - let mut old_clouds_by_flow: HashMap> = HashMap::new(); - for elem in &state.elements { - if let ViewElement::Cloud(c) = elem { - old_clouds_by_flow - .entry(c.flow_uid) - .or_default() - .push(elem.clone()); + let canonical_str = canonical.into_owned(); + if let Some(cloud_idents) = self.flow_ident_to_clouds.remove(&canonical_str) { + for ci in &cloud_idents { + self.cloud_ident_to_uid.remove(ci); + self.cloud_ident_to_flow_ident.remove(ci); + } } + self.display_names.remove(&canonical_str); } - // Determine which clouds should exist for each flow - let mut needed_flow_uids: HashSet = HashSet::new(); - // Track which flows need source/sink clouds - let mut need_source: HashSet = HashSet::new(); - let mut need_sink: HashSet = HashSet::new(); - - for (flow_ident, (from_stock, to_stock)) in &metadata.flow_to_stocks { - let flow_uid = match state.uid_manager.get_uid(flow_ident) { + /// Update a variable's identity in-place while preserving its + /// position and UID. Updates the element name, uid_manager + /// mapping, and display_names entry. + pub fn apply_rename(&mut self, old_ident: &str, new_ident: &str, new_display_name: &str) { + let old_canonical = canonicalize(old_ident).into_owned(); + let uid = match self.uid_manager.get_uid(&old_canonical) { Some(uid) => uid, - None => continue, + None => return, }; - needed_flow_uids.insert(flow_uid); - if from_stock.is_none() { - need_source.insert(flow_uid); - } - if to_stock.is_none() { - need_sink.insert(flow_uid); - } - } - // Snapshot flow endpoint positions before mutating state.elements - let flow_endpoints: HashMap = state - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Flow(f) if !f.points.is_empty() => { - let first = Position::new(f.points[0].x, f.points[0].y); - let last_idx = f.points.len() - 1; - let last = Position::new(f.points[last_idx].x, f.points[last_idx].y); - Some((f.uid, (first, last))) + for elem in &mut self.elements { + if elem.get_uid() != uid { + continue; } - _ => None, - }) - .collect(); - - // Remove all old clouds from elements - state - .elements - .retain(|elem| !matches!(elem, ViewElement::Cloud(_))); - - // For each flow, determine what to keep vs create - let all_flow_uids: HashSet = needed_flow_uids - .iter() - .chain(old_clouds_by_flow.keys()) - .copied() - .collect(); - - for flow_uid in all_flow_uids { - let old_clouds = old_clouds_by_flow - .get(&flow_uid) - .cloned() - .unwrap_or_default(); - let wants_source = need_source.contains(&flow_uid); - let wants_sink = need_sink.contains(&flow_uid); - - let needed_count = wants_source as usize + wants_sink as usize; - - if needed_count == 0 { - for c in &old_clouds { - if let ViewElement::Cloud(cloud) = c { - state.positions.remove(&cloud.uid); - } + let formatted = format_label_with_line_breaks(new_display_name); + match elem { + ViewElement::Aux(a) => a.name = formatted, + ViewElement::Stock(s) => s.name = formatted, + ViewElement::Flow(f) => f.name = formatted, + ViewElement::Module(m) => m.name = formatted, + _ => {} } - continue; + break; } - // Preserve existing clouds by matching to needed roles (source/sink) - // based on proximity to flow endpoints, rather than iteration order. - let endpoints = flow_endpoints.get(&flow_uid); - let mut preserved_source = false; - let mut preserved_sink = false; - let mut used_uids: HashSet = HashSet::new(); + let new_canonical = canonicalize(new_ident).into_owned(); + self.uid_manager.rename(&old_canonical, &new_canonical); + self.display_names.remove(&old_canonical); + self.display_names + .insert(new_canonical, new_display_name.to_string()); + } - let find_nearest = - |clouds: &[ViewElement], target: &Position, exclude: &HashSet| -> Option { - clouds - .iter() - .filter_map(|c| match c { - ViewElement::Cloud(cloud) if !exclude.contains(&cloud.uid) => { - let d = (cloud.x - target.x).powi(2) + (cloud.y - target.y).powi(2); - Some((cloud.uid, d)) - } - _ => None, - }) - .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(uid, _)| uid) - }; + /// Walk model variables and identify which ones are not yet represented + /// in this layout state (either no UID mapping or no view element with + /// that UID), classifying each by variable type. + pub fn identify_new_elements(&self, model: &datamodel::Model) -> NewElements { + let existing_uids: HashSet = self.elements.iter().map(|e| e.get_uid()).collect(); - if let Some((src_pos, snk_pos)) = endpoints { - if wants_source && let Some(uid) = find_nearest(&old_clouds, src_pos, &used_uids) { - used_uids.insert(uid); - preserved_source = true; - } - if wants_sink && let Some(uid) = find_nearest(&old_clouds, snk_pos, &used_uids) { - used_uids.insert(uid); - preserved_sink = true; - } - } else { - // No endpoint info: preserve in order as a fallback - for cloud in &old_clouds { - if let ViewElement::Cloud(c) = cloud { - if wants_source && !preserved_source { - used_uids.insert(c.uid); - preserved_source = true; - } else if wants_sink && !preserved_sink { - used_uids.insert(c.uid); - preserved_sink = true; - } - } - } - } + let mut new_stocks = Vec::new(); + let mut new_flows = Vec::new(); + let mut new_auxes = Vec::new(); + let mut new_modules = Vec::new(); - // Push preserved clouds and remove positions of discarded ones - for cloud in &old_clouds { - if let ViewElement::Cloud(c) = cloud { - if used_uids.contains(&c.uid) { - state.elements.push(cloud.clone()); - } else { - state.positions.remove(&c.uid); + for var in &model.variables { + let canonical = canonicalize(var.get_ident()).into_owned(); + let is_new = match self.uid_manager.get_uid(&canonical) { + None => true, + Some(uid) => !existing_uids.contains(&uid), + }; + + if is_new { + match var { + datamodel::Variable::Stock(_) => new_stocks.push(canonical), + datamodel::Variable::Flow(_) => new_flows.push(canonical), + datamodel::Variable::Aux(_) => new_auxes.push(canonical), + datamodel::Variable::Module(_) => new_modules.push(canonical), } } } - // Create new clouds for roles that couldn't be filled from old clouds - if wants_source && !preserved_source { - let pos = endpoints.map(|(src, _)| *src); - let (cx, cy) = pos.map_or((0.0, 0.0), |p| (p.x, p.y)); - let cloud_uid = state.uid_manager.alloc(""); - state.elements.push(ViewElement::Cloud(view_element::Cloud { - uid: cloud_uid, - flow_uid, - x: cx, - y: cy, - compat: None, - })); - state.positions.insert(cloud_uid, Position::new(cx, cy)); - } - if wants_sink && !preserved_sink { - let pos = endpoints.map(|(_, sink)| *sink); - let (cx, cy) = pos.map_or((0.0, 0.0), |p| (p.x, p.y)); - let cloud_uid = state.uid_manager.alloc(""); - state.elements.push(ViewElement::Cloud(view_element::Cloud { - uid: cloud_uid, - flow_uid, - x: cx, - y: cy, - compat: None, - })); - state.positions.insert(cloud_uid, Position::new(cx, cy)); - } - } - - // Repair pass: for XMILE-imported views a cloud element may exist but the - // corresponding flow point's attached_to_uid may be None. Wire up any - // unattached flow endpoints to their matching cloud. - // - // Build a map from flow_uid to the clouds that now exist for it. - let mut clouds_by_flow: HashMap> = HashMap::new(); - for elem in &state.elements { - if let ViewElement::Cloud(c) = elem { - clouds_by_flow - .entry(c.flow_uid) - .or_default() - .push((c.uid, c.x, c.y)); + NewElements { + new_stocks, + new_flows, + new_auxes, + new_modules, } } +} - for elem in &mut state.elements { - let flow = match elem { - ViewElement::Flow(f) => f, - _ => continue, - }; - let Some(clouds) = clouds_by_flow.get(&flow.uid) else { - continue; - }; - if flow.points.len() < 2 { - continue; - } +/// Variables in the model that have no corresponding view element in +/// the current layout state, classified by type. +pub struct NewElements { + pub new_stocks: Vec, + pub new_flows: Vec, + pub new_auxes: Vec, + pub new_modules: Vec, +} - // For each flow endpoint (source=0, sink=last) that is unattached, - // assign the nearest cloud. We use a simple squared-distance heuristic - // which is correct for both single-cloud and two-cloud cases. - let last = flow.points.len() - 1; - for pt_idx in [0, last] { - if flow.points[pt_idx].attached_to_uid.is_some() { - continue; - } - let px = flow.points[pt_idx].x; - let py = flow.points[pt_idx].y; - let nearest = clouds.iter().min_by(|(_, ax, ay), (_, bx, by)| { - let da = (ax - px).powi(2) + (ay - py).powi(2); - let db = (bx - px).powi(2) + (by - py).powi(2); - da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) - }); - if let Some(&(cloud_uid, _, _)) = nearest { - flow.points[pt_idx].attached_to_uid = Some(cloud_uid); - } - } +impl NewElements { + pub fn is_empty(&self) -> bool { + self.new_stocks.is_empty() + && self.new_flows.is_empty() + && self.new_auxes.is_empty() + && self.new_modules.is_empty() } } -/// Classify which stock edge each flow should attach to and compute -/// even spacing offsets. +/// Classify which stock face each flow attaches to and where along it. +/// +/// Chain flows (stock-to-stock) run along the chain: outflows leave the right +/// face, inflows enter the left face, and several on one face are spread with +/// the `(i+1)/(n+1)` formula (matching the TS editor's `computeFlowOffsets`) -- +/// their valves sit between different stock pairs, so they do not collide. /// -/// When a stock has a chain flow (stock-to-stock) going right, non-chain -/// outflows (stock-to-cloud) exit from the bottom. Symmetrically, when a -/// stock has a chain inflow from the left, non-chain inflows enter from -/// the top. Multiple flows on the same side are distributed using the -/// `(i+1)/(n+1)` formula (matching the TS editor's `computeFlowOffsets`). +/// Side flows (to or from a cloud) each get a face of their OWN whenever one is +/// free. A face is only 35-45px long and a valve is drawn 18px across, so two +/// side flows sharing a face at 1/3 and 2/3 put their valves, clouds, and names +/// on top of each other. Outflows prefer right, then bottom, then top; inflows +/// prefer left, then top, then bottom -- the way modelers draw births in from +/// the left and deaths out to the right, with a second outflow dropping below. +/// A face holding a chain flow is never offered to a side flow, and only when +/// every remaining allowed face is taken do side flows share one. +/// +/// `existing` gives the faces of side flows already drawn on this stock (the +/// incremental path): each keeps its face -- shared or not, hand-placed or +/// not -- unless it sits on a chain face, or its preferred face has come free +/// (the chain flow that pushed it below was deleted). Adding a sibling +/// therefore never moves a flow to another face. /// /// Returns a map from flow ident to its attachment info for all flows /// connected to this stock. fn classify_flow_sides( stock_ident: &str, metadata: &ComputedMetadata, + existing: &HashMap, ) -> HashMap { let mut result = HashMap::new(); @@ -1564,68 +514,87 @@ fn classify_flow_sides( } } - // Outflow placement: if chain outflows exist, side outflows go to Bottom - let side_outflow_side = if !chain_outflows.is_empty() { - StockAttachSide::Bottom - } else { - StockAttachSide::Right - }; - - // Inflow placement: if chain inflows exist, side inflows go to Top - let side_inflow_side = if !chain_inflows.is_empty() { - StockAttachSide::Top - } else { - StockAttachSide::Left - }; - - // Group all outflows by their assigned side - let mut right_flows: Vec = Vec::new(); - let mut bottom_flows: Vec = Vec::new(); - for flow in &chain_outflows { - right_flows.push(flow.clone()); + // A face holding a chain flow belongs to the chain: a side flow there would + // run its pipe along the chain's, so side flows never take one. + let mut chain_faces: HashSet = HashSet::new(); + let mut faces: HashMap> = HashMap::new(); + if !chain_outflows.is_empty() { + chain_faces.insert(StockAttachSide::Right); + faces.insert(StockAttachSide::Right, chain_outflows); } - for flow in &side_outflows { - match side_outflow_side { - StockAttachSide::Bottom => bottom_flows.push(flow.clone()), - StockAttachSide::Right => right_flows.push(flow.clone()), - StockAttachSide::Top | StockAttachSide::Left => right_flows.push(flow.clone()), - } + if !chain_inflows.is_empty() { + chain_faces.insert(StockAttachSide::Left); + faces.insert(StockAttachSide::Left, chain_inflows); } - // Group all inflows by their assigned side - let mut left_flows: Vec = Vec::new(); - let mut top_flows: Vec = Vec::new(); - for flow in &chain_inflows { - left_flows.push(flow.clone()); - } - for flow in &side_inflows { - match side_inflow_side { - StockAttachSide::Top => top_flows.push(flow.clone()), - StockAttachSide::Left => left_flows.push(flow.clone()), - StockAttachSide::Bottom | StockAttachSide::Right => left_flows.push(flow.clone()), - } + const OUTFLOW_FACES: [StockAttachSide; 3] = [ + StockAttachSide::Right, + StockAttachSide::Bottom, + StockAttachSide::Top, + ]; + const INFLOW_FACES: [StockAttachSide; 3] = [ + StockAttachSide::Left, + StockAttachSide::Top, + StockAttachSide::Bottom, + ]; + + side_outflows.sort(); + side_inflows.sort(); + let side_flows: Vec<(String, &[StockAttachSide; 3])> = side_outflows + .into_iter() + .map(|f| (f, &OUTFLOW_FACES)) + .chain(side_inflows.into_iter().map(|f| (f, &INFLOW_FACES))) + .collect(); + let load = |faces: &HashMap>, side: StockAttachSide| { + faces.get(&side).map_or(0, Vec::len) + }; + + // Already-drawn side flows keep their faces. Pass one seats the flows + // already on their preferred face; pass two moves a flow off a secondary + // face only onto its preferred face, and only if that has come free (the + // chain that pushed it below was deleted); a flow sitting on a chain face + // is re-placed like a new one. + let mut pending: Vec<(String, &[StockAttachSide; 3])> = Vec::new(); + let mut secondary: Vec<(String, &[StockAttachSide; 3], StockAttachSide)> = Vec::new(); + for (flow, allowed) in side_flows { + match existing.get(&flow) { + Some(&side) if chain_faces.contains(&side) => pending.push((flow, allowed)), + Some(&side) if side == allowed[0] => faces.entry(side).or_default().push(flow), + Some(&side) => secondary.push((flow, allowed, side)), + None => pending.push((flow, allowed)), + } + } + for (flow, allowed, side) in secondary { + let preferred_free = !chain_faces.contains(&allowed[0]) && load(&faces, allowed[0]) == 0; + let target = if preferred_free { allowed[0] } else { side }; + faces.entry(target).or_default().push(flow); + } + + // New side flows take the least-loaded allowed face, in preference order. + for (flow, allowed) in pending { + let side = *allowed + .iter() + .enumerate() + .filter(|(_, side)| !chain_faces.contains(*side)) + .min_by_key(|(rank, side)| (load(&faces, **side), *rank)) + .map(|(_, side)| side) + .expect("a flow's allowed faces include two that no chain can hold"); + faces.entry(side).or_default().push(flow); } - // Distribute flows within each side group using (i+1)/(n+1) - let assign_side = |flows: &mut [String], - side: StockAttachSide, - result: &mut HashMap| { + // Distribute flows within each face using (i+1)/(n+1) + for (side, mut flows) in faces { flows.sort(); // deterministic ordering by ident let n = flows.len(); - for (i, flow) in flows.iter().enumerate() { + for (i, flow) in flows.into_iter().enumerate() { let offset = if n == 1 { 0.5 } else { (i as f64 + 1.0) / (n as f64 + 1.0) }; - result.insert(flow.clone(), FlowAttachment { side, offset }); + result.insert(flow, FlowAttachment { side, offset }); } - }; - - assign_side(&mut right_flows, StockAttachSide::Right, &mut result); - assign_side(&mut bottom_flows, StockAttachSide::Bottom, &mut result); - assign_side(&mut left_flows, StockAttachSide::Left, &mut result); - assign_side(&mut top_flows, StockAttachSide::Top, &mut result); + } result } @@ -1718,141 +687,62 @@ fn record_flow_template(state: &mut LayoutState, flow_ident: &str, flow_elem: &v .insert(flow_ident.to_string(), FlowTemplate { offsets }); } -/// Re-sort flows on each affected stock's sides by their existing -/// attachment position rather than alphabetical ident. This preserves -/// the visual left-to-right (or top-to-bottom) ordering of imported or -/// manually-edited flows when a sibling is added or removed. -/// -/// Only affects flows that already have view elements in `state`; -/// new flows without positions are placed last (sorted by ident among -/// themselves). -fn reorder_attachments_by_position( - attachments: &mut HashMap, - state: &LayoutState, - affected_stocks: &HashSet, - metadata: &ComputedMetadata, -) { - for stock_ident in affected_stocks { - let stock_uid = match state.uid_manager.get_uid(stock_ident) { - Some(uid) => uid, - None => continue, - }; - - // Group flows on this stock by side, recording each flow's - // existing attachment position (x for Top/Bottom, y for Left/Right). - let mut by_side: HashMap> = HashMap::new(); - - for (flow_ident, att) in attachments.iter() { - let (from, to) = metadata.connected_stocks(flow_ident); - // Skip stock-to-stock flows: their attachment side depends on - // which stock classified them last, so including them would - // count them on the wrong side of one stock. - if from.is_some() && to.is_some() { - continue; - } - let connected = - from.is_some_and(|s| s == stock_ident) || to.is_some_and(|s| s == stock_ident); - if !connected { - continue; - } - - let pos_key = state - .uid_manager - .get_uid(flow_ident) - .and_then(|uid| { - state.elements.iter().find_map(|e| match e { - ViewElement::Flow(f) if f.uid == uid => f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| match att.side { - StockAttachSide::Bottom | StockAttachSide::Top => pt.x, - StockAttachSide::Left | StockAttachSide::Right => pt.y, - }), - _ => None, - }) - }) - .unwrap_or(f64::MAX); // new flows sort last - - by_side - .entry(att.side) - .or_default() - .push((flow_ident.clone(), pos_key)); - } - - // Re-sort each side group by position and reassign offsets - for flows in by_side.values_mut() { - if flows.len() <= 1 { - continue; - } - flows.sort_by(|a, b| { - a.1.partial_cmp(&b.1) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.cmp(&b.0)) - }); - let n = flows.len(); - for (i, (flow_ident, _)) in flows.iter().enumerate() { - let offset = if n == 1 { - 0.5 - } else { - (i as f64 + 1.0) / (n as f64 + 1.0) - }; - if let Some(att) = attachments.get_mut(flow_ident) { - att.offset = offset; - } - } - } - } -} - -/// Compute the valve position for a flow based on its attachment info and -/// connected stock position. Returns `None` if the flow has no attachment -/// or the stock position is unknown, in which case the caller should fall -/// back to `initial_positions`. -fn attachment_based_flow_position( - state: &LayoutState, +/// The valve position of a side flow attached to `stock_pos` at `attachment`: +/// a half `horizontal_spacing` off the assigned face, slid along the face to +/// the attachment offset, so the valve sits on the pipe leaving that point. +fn side_flow_valve_position( + stock_pos: Position, + attachment: FlowAttachment, config: &LayoutConfig, - metadata: &ComputedMetadata, - flow_ident: &str, - flow_attachments: &HashMap, -) -> Option { - let attachment = flow_attachments.get(flow_ident)?; - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - let stock_name = from_stock.or(to_stock)?; - let stock_uid = state.uid_manager.get_uid(stock_name)?; - let stock_pos = state.positions.get(&stock_uid)?; - Some(match attachment.side { - StockAttachSide::Bottom => { - let x = stock_pos.x - config.stock_width / 2.0 + config.stock_width * attachment.offset; - Position::new( - x, - stock_pos.y + config.stock_height / 2.0 + config.horizontal_spacing / 2.0, - ) - } - StockAttachSide::Top => { - let x = stock_pos.x - config.stock_width / 2.0 + config.stock_width * attachment.offset; - Position::new( - x, - stock_pos.y - config.stock_height / 2.0 - config.horizontal_spacing / 2.0, - ) - } - StockAttachSide::Right => { - let y = - stock_pos.y - config.stock_height / 2.0 + config.stock_height * attachment.offset; - Position::new( - stock_pos.x + config.stock_width / 2.0 + config.horizontal_spacing / 2.0, - y, - ) - } - StockAttachSide::Left => { - let y = - stock_pos.y - config.stock_height / 2.0 + config.stock_height * attachment.offset; - Position::new( - stock_pos.x - config.stock_width / 2.0 - config.horizontal_spacing / 2.0, - y, - ) - } - }) +) -> Position { + let along_x = stock_pos.x - config.stock_width / 2.0 + config.stock_width * attachment.offset; + let along_y = stock_pos.y - config.stock_height / 2.0 + config.stock_height * attachment.offset; + let off_x = config.stock_width / 2.0 + config.horizontal_spacing / 2.0; + let off_y = config.stock_height / 2.0 + config.horizontal_spacing / 2.0; + match attachment.side { + StockAttachSide::Bottom => Position::new(along_x, stock_pos.y + off_y), + StockAttachSide::Top => Position::new(along_x, stock_pos.y - off_y), + StockAttachSide::Right => Position::new(stock_pos.x + off_x, along_y), + StockAttachSide::Left => Position::new(stock_pos.x - off_x, along_y), + } +} + +/// The two points of a side flow's pipe: one on `stock_pos`'s assigned face at +/// the attachment offset, one a free end past the valve at `pos` (where the +/// cloud goes), ordered source to sink. +fn side_flow_points( + stock_pos: Position, + stock_uid: i32, + attachment: FlowAttachment, + pos: Position, + is_outflow: bool, + config: &LayoutConfig, +) -> Vec { + let half_w = config.stock_width / 2.0; + let half_h = config.stock_height / 2.0; + let along_x = stock_pos.x - half_w + config.stock_width * attachment.offset; + let along_y = stock_pos.y - half_h + config.stock_height * attachment.offset; + let (on_face, free_end) = match attachment.side { + StockAttachSide::Bottom => ((along_x, stock_pos.y + half_h), (along_x, pos.y + 50.0)), + StockAttachSide::Top => ((along_x, stock_pos.y - half_h), (along_x, pos.y - 50.0)), + StockAttachSide::Right => ((stock_pos.x + half_w, along_y), (pos.x + 50.0, along_y)), + StockAttachSide::Left => ((stock_pos.x - half_w, along_y), (pos.x - 50.0, along_y)), + }; + let stock_point = FlowPoint { + x: on_face.0, + y: on_face.1, + attached_to_uid: Some(stock_uid), + }; + let free_point = FlowPoint { + x: free_end.0, + y: free_end.1, + attached_to_uid: None, + }; + if is_outflow { + vec![stock_point, free_point] + } else { + vec![free_point, stock_point] + } } /// Create a single flow view element with its flow points and clouds. @@ -1921,64 +811,11 @@ fn create_flow_view_element( .get(&from_uid) .copied() .unwrap_or(Position::new(pos.x - 50.0, pos.y)); - match attachment { - Some(FlowAttachment { - side: StockAttachSide::Bottom, - offset, - }) => { - // Vertical flow exiting from the bottom of the stock - let attach_x = - from_pos.x - config.stock_width / 2.0 + config.stock_width * offset; - vec![ - FlowPoint { - x: attach_x, - y: from_pos.y + config.stock_height / 2.0, - attached_to_uid: Some(from_uid), - }, - FlowPoint { - x: attach_x, - y: pos.y + 50.0, - attached_to_uid: None, - }, - ] - } - Some(FlowAttachment { - side: StockAttachSide::Right, - offset, - }) => { - // Horizontal flow exiting to the right, offset along - // the right edge for multi-flow distribution - let attach_y = - from_pos.y - config.stock_height / 2.0 + config.stock_height * offset; - vec![ - FlowPoint { - x: from_pos.x + config.stock_width / 2.0, - y: attach_y, - attached_to_uid: Some(from_uid), - }, - FlowPoint { - x: pos.x + 50.0, - y: pos.y, - attached_to_uid: None, - }, - ] - } - _ => { - // Default: horizontal flow exiting to the right - vec![ - FlowPoint { - x: from_pos.x + config.stock_width / 2.0, - y: pos.y, - attached_to_uid: Some(from_uid), - }, - FlowPoint { - x: pos.x + 50.0, - y: pos.y, - attached_to_uid: None, - }, - ] - } - } + let attachment = attachment.unwrap_or(FlowAttachment { + side: StockAttachSide::Right, + offset: 0.5, + }); + side_flow_points(from_pos, from_uid, attachment, pos, true, config) } (None, Some(to)) => { let to_uid = state.get_or_alloc_uid(to); @@ -1987,64 +824,11 @@ fn create_flow_view_element( .get(&to_uid) .copied() .unwrap_or(Position::new(pos.x + 50.0, pos.y)); - match attachment { - Some(FlowAttachment { - side: StockAttachSide::Top, - offset, - }) => { - // Vertical flow entering from the top of the stock - let attach_x = - to_pos.x - config.stock_width / 2.0 + config.stock_width * offset; - vec![ - FlowPoint { - x: attach_x, - y: pos.y - 50.0, - attached_to_uid: None, - }, - FlowPoint { - x: attach_x, - y: to_pos.y - config.stock_height / 2.0, - attached_to_uid: Some(to_uid), - }, - ] - } - Some(FlowAttachment { - side: StockAttachSide::Left, - offset, - }) => { - // Horizontal flow entering from the left, offset along - // the left edge for multi-flow distribution - let attach_y = - to_pos.y - config.stock_height / 2.0 + config.stock_height * offset; - vec![ - FlowPoint { - x: pos.x - 50.0, - y: pos.y, - attached_to_uid: None, - }, - FlowPoint { - x: to_pos.x - config.stock_width / 2.0, - y: attach_y, - attached_to_uid: Some(to_uid), - }, - ] - } - _ => { - // Default: horizontal flow entering from the left - vec![ - FlowPoint { - x: pos.x - 50.0, - y: pos.y, - attached_to_uid: None, - }, - FlowPoint { - x: to_pos.x - config.stock_width / 2.0, - y: pos.y, - attached_to_uid: Some(to_uid), - }, - ] - } - } + let attachment = attachment.unwrap_or(FlowAttachment { + side: StockAttachSide::Left, + offset: 0.5, + }); + side_flow_points(to_pos, to_uid, attachment, pos, false, config) } (None, None) => { vec![ @@ -2177,7 +961,7 @@ fn layout_chain( // This avoids redundant calls to classify_flow_sides during BFS. let mut flow_attachments: HashMap = HashMap::new(); for stock_ident in stocks { - let sides = classify_flow_sides(stock_ident, metadata); + let sides = classify_flow_sides(stock_ident, metadata, &HashMap::new()); flow_attachments.extend(sides); } @@ -2355,79 +1139,24 @@ fn layout_chain( chain::stock_pair_valve_position(a_pos, b_pos, idx, count) } } - (Some(_), None) => { - // Outflow to cloud: check if it should go downward - match flow_attachments.get(&item.id).copied() { - Some(FlowAttachment { - side: StockAttachSide::Bottom, - offset, - }) => { - let x_offset = item.position.x - config.stock_width / 2.0 - + config.stock_width * offset; - Position::new( - x_offset, - item.position.y - + config.stock_height / 2.0 - + config.horizontal_spacing / 2.0, - ) - } - Some(FlowAttachment { - side: StockAttachSide::Right, - offset, - }) => { - let y_offset = item.position.y - config.stock_height / 2.0 - + config.stock_height * offset; - Position::new( - item.position.x - + config.stock_width / 2.0 - + config.horizontal_spacing / 2.0, - y_offset, - ) - } - _ => Position::new( - item.position.x - + config.stock_width / 2.0 - + config.horizontal_spacing / 2.0, - item.position.y, - ), - } - } - (None, Some(_)) => { - // Inflow from cloud: check if it should come from above - match flow_attachments.get(&item.id).copied() { - Some(FlowAttachment { - side: StockAttachSide::Top, - offset, - }) => { - let x_offset = item.position.x - config.stock_width / 2.0 - + config.stock_width * offset; - Position::new( - x_offset, - item.position.y - - config.stock_height / 2.0 - - config.horizontal_spacing / 2.0, - ) - } - Some(FlowAttachment { - side: StockAttachSide::Left, - offset, - }) => { - let y_offset = item.position.y - config.stock_height / 2.0 - + config.stock_height * offset; - Position::new( - item.position.x - - config.stock_width / 2.0 - - config.horizontal_spacing / 2.0, - y_offset, - ) - } - _ => Position::new( - item.position.x - - config.stock_width / 2.0 - - config.horizontal_spacing / 2.0, - item.position.y, - ), - } + (Some(_), None) | (None, Some(_)) => { + // A side flow to or from a cloud: its valve sits a + // half spacing off the face `classify_flow_sides` + // assigned it. + let is_outflow = from_stock.is_some(); + let attachment = + flow_attachments + .get(&item.id) + .copied() + .unwrap_or(FlowAttachment { + side: if is_outflow { + StockAttachSide::Right + } else { + StockAttachSide::Left + }, + offset: 0.5, + }); + side_flow_valve_position(item.position, attachment, config) } (None, None) => { // Cloud-to-cloud @@ -3643,22 +2372,9 @@ fn calculate_allowed_label_sides_for_stock( } /// Apply optimal label placement based on connector angles to every named -/// element. This is the full-layout pass; incremental layout uses -/// [`optimize_labels_for`] so it never rewrites a pre-existing element's side. +/// element: the fresh layout's first guess, which the declutter pass then +/// refines against the metric. fn optimize_labels(state: &mut LayoutState, model: &datamodel::Model, metadata: &ComputedMetadata) { - optimize_labels_for(state, model, metadata, |_| true); -} - -/// Apply optimal label placement to the named elements whose UID satisfies -/// `should_place`. Elements it rejects keep their current `label_side` -/// untouched, even though their positions still inform the placement of the -/// elements it accepts (connector angles are computed from all positions). -fn optimize_labels_for( - state: &mut LayoutState, - model: &datamodel::Model, - metadata: &ComputedMetadata, - should_place: impl Fn(i32) -> bool, -) { let uid_to_ident: HashMap = model .variables .iter() @@ -3681,7 +2397,6 @@ fn optimize_labels_for( .elements .iter() .enumerate() - .filter(|(_, elem)| should_place(elem.get_uid())) .filter_map(|(i, elem)| match elem { ViewElement::Stock(stock) => { let ident = uid_to_ident.get(&stock.uid)?; @@ -3744,25 +2459,6 @@ fn optimize_labels_for( } } -/// Write `sides` back onto the named elements that carry those UIDs. Used by -/// incremental layout after a flow is rebuilt with unchanged orientation -/// (`create_flow_view_element` picks a default side) to reinstate the side -/// the element had before the rebuild. -fn restore_label_sides(state: &mut LayoutState, sides: &HashMap) { - for elem in &mut state.elements { - let Some(&side) = sides.get(&elem.get_uid()) else { - continue; - }; - match elem { - ViewElement::Stock(s) => s.label_side = side, - ViewElement::Flow(f) => f.label_side = side, - ViewElement::Aux(a) => a.label_side = side, - ViewElement::Module(m) => m.label_side = side, - _ => {} - } - } -} - /// Apply arc curvature to connectors involved in feedback loops. fn apply_loop_curvature( state: &mut LayoutState, @@ -4179,6 +2875,12 @@ pub fn fresh_layout( // deterministically. if config.declutter { declutter::declutter_view(&mut state.elements); + // Phase 4c: Move free nodes off crossings where a nearby spot charges + // less, on the settled geometry and label sides, then choose the sides + // again around wherever they went. + polish::polish_crossings(&mut state.elements); + declutter::declutter_part(&mut state.elements, |_| true, |_| false); + sync_free_node_positions(&mut state); } // Phase 5: Normalize coordinates @@ -4225,6 +2927,22 @@ pub fn fresh_layout( }) } +/// Copy free nodes' element coordinates back into `state.positions` after a +/// pass that moved the elements directly. +fn sync_free_node_positions(state: &mut LayoutState) { + for elem in &state.elements { + let (uid, x, y) = match elem { + ViewElement::Aux(a) => (a.uid, a.x, a.y), + ViewElement::Module(m) => (m.uid, m.x, m.y), + ViewElement::Alias(a) => (a.uid, a.x, a.y), + _ => continue, + }; + if let Some(pos) = state.positions.get_mut(&uid) { + *pos = Position::new(x, y); + } + } +} + /// Check if a dependency edge is a structural flow->stock connection (already /// visually represented by the flow pipe). fn is_structural_flow_stock( @@ -4402,8 +3120,56 @@ fn rendered_dependency_ident( { mapped = Some(prefix.to_string()); } - - mapped.filter(|ident| ident != dependent) + + mapped.filter(|ident| ident != dependent) +} + +/// The names a variable's diagram links come from: the head of every read (a +/// module read is drawn to the module box), with each read of a helper the +/// parse synthesized -- a builtin module instance such as `SMTH1`'s, a hoisted +/// call argument, a captured `PREVIOUS`/`INIT` argument -- replaced by what +/// that helper reads, plus every lookup table the variable or its helpers call. +/// A modeler writes `SMTH1(input, delay)` or `LOOKUP(table, x)` and expects an +/// arrow from each name they typed; the synthesized helpers are invisible, and +/// a table is a layout reference (not a data-flow read), so neither shows up +/// among the plain heads (#650). Sorted and deduplicated. +fn drawn_reads(var_deps: &crate::db::VariableDeps) -> Vec { + let helpers: HashMap<&str, _> = var_deps + .implicit_vars + .iter() + .map(|helper| (helper.name.as_str(), helper)) + .collect(); + let mut reads: BTreeSet = var_deps + .referenced_tables + .iter() + .map(|table| table.as_str().to_string()) + .collect(); + let mut expanded: HashSet<&str> = HashSet::new(); + let mut pending = vec![&var_deps.deps]; + while let Some(deps) = pending.pop() { + for head in deps.heads() { + let name = head.as_str(); + match helpers.get(name) { + Some(helper) => { + // A helper can be read by several siblings (an instance + // reads the argument hoisted for it); expand it once. + if expanded.insert(name) { + pending.push(&helper.deps); + reads.extend( + helper + .referenced_tables + .iter() + .map(|table| table.as_str().to_string()), + ); + } + } + None => { + reads.insert(name.to_string()); + } + } + } + } + reads.into_iter().collect() } /// Build feedback loops from the persisted model loop_metadata (UIDs only, @@ -4583,15 +3349,7 @@ pub fn compute_metadata( let empty_inputs = crate::db::ModuleInputSet::empty(db); let var_deps = crate::db::variable_direct_dependencies(db, sv, source_project, empty_inputs); - // A module read is drawn to the module box: the read's head. - let mut combined: Vec = var_deps - .deps - .heads() - .into_iter() - .map(|head| head.as_str().to_string()) - .collect(); - combined.sort(); - combined.dedup(); + let combined = drawn_reads(var_deps); if combined.is_empty() { // Check whether the equation actually parsed. If the AST // is None, the equation has syntax errors and we fall back @@ -4911,25 +3669,36 @@ fn build_view_segments(view: &datamodel::StockFlow) -> Vec { // Resolve every element by uid so a link can find its endpoints regardless // of the endpoint's kind (Module/Alias included). - let mut uid_elements: HashMap = HashMap::new(); - for elem in &view.elements { - uid_elements.insert(elem.get_uid(), elem); - } + let uid_elements: HashMap = + view.elements.iter().map(|e| (e.get_uid(), e)).collect(); + view.elements + .iter() + .flat_map(|elem| element_segments(elem, &uid_elements)) + .collect() +} +/// The crossing segments one connector draws -- a link's polyline or a flow's +/// pipe -- with vertices named so connectors sharing an element never count as +/// crossing there; empty for every other element. `uid_elements` resolves a +/// link's endpoints. +pub(crate) fn element_segments( + elem: &ViewElement, + uid_elements: &HashMap, +) -> Vec { // Crossing detection is center-based and deterministic; no element is // treated as arrayed (matching the historic behavior). let not_arrayed = |_: &str| false; let mut segments: Vec = Vec::new(); - for elem in &view.elements { + { match elem { ViewElement::Link(link) => { let (Some(&from), Some(&to)) = ( uid_elements.get(&link.from_uid), uid_elements.get(&link.to_uid), ) else { - continue; // an endpoint is genuinely missing + return segments; // an endpoint is genuinely missing }; let polyline = crate::diagram::connector::connector_polyline( @@ -4940,7 +3709,7 @@ fn build_view_segments(view: &datamodel::StockFlow) -> Vec { crate::diagram::connector::ARC_POLYLINE_SAMPLES, ); if polyline.len() < 2 { - continue; // MultiPoint / degenerate: nothing drawn + return segments; // MultiPoint / degenerate: nothing drawn } let last_idx = polyline.len() - 1; @@ -4971,7 +3740,7 @@ fn build_view_segments(view: &datamodel::StockFlow) -> Vec { } ViewElement::Flow(flow) => { if flow.points.len() < 2 { - continue; + return segments; } // Build the pipe as a sequence of named vertices. A point @@ -5053,38 +3822,6 @@ pub fn count_view_crossings(view: &datamodel::StockFlow) -> usize { annealing::count_crossings(&build_view_segments(view)) } -/// Assemble a [`datamodel::StockFlow`] from finalized layout state, copying -/// metadata (name, view box, zoom, font, sketch_compat) from `template`. -/// -/// The view box is copied verbatim, never recomputed from the elements: the -/// editor stores its viewport there (the pan offset and the canvas size it -/// was last shown at, `Canvas.tsx` `getCanvasOffset`), and an incremental -/// pass runs after every kernel/MCP edit of a diagram someone is looking -/// at -- re-boxing it to the content bounds snaps their pan back and, when -/// the size no longer matches the canvas, triggers the editor's -/// proportional refit, so the diagram jumps on every "Updated from Python". -/// Content that ends up outside the viewport is the editor's business (it -/// re-centres an offscreen diagram on mount). Only a from-scratch layout -/// synthesises a box. -fn build_stock_flow_from_state( - state: LayoutState, - template: &datamodel::StockFlow, -) -> datamodel::StockFlow { - datamodel::StockFlow { - name: template.name.clone(), - elements: state.elements, - view_box: template.view_box.clone(), - zoom: if template.zoom > 0.0 { - template.zoom - } else { - 1.0 - }, - use_lettered_polarity: template.use_lettered_polarity, - font: template.font.clone(), - sketch_compat: template.sketch_compat.clone(), - } -} - /// Seeds for parallel layout generation. Each seed produces a different SFDP /// layout; the one with fewest connector crossings is selected. /// @@ -5094,664 +3831,6 @@ fn build_stock_flow_from_state( /// so it is exposed publicly. The value and behavior are unchanged. pub const LAYOUT_SEEDS: [u64; 4] = [42, 123, 456, 789]; -/// Apply a model patch incrementally to an existing diagram view, -/// preserving existing element positions and only placing new or -/// modified elements. -/// -/// The `project` must already reflect the post-patch model state -/// (i.e., `apply_patch` has been called). The `patch` is taken by -/// reference so callers can inspect the operations. -/// -/// Contract for elements the patch did not touch: position AND -/// `label_side` are returned byte-for-byte. A label side is chosen only -/// for elements created in this pass -- new variables, kind-changed or -/// endpoint-changed rebuilds, and flows whose pipe orientation flipped. -/// A flow rebuilt merely to slide along the same stock face keeps its -/// side. The optimizer never revisits an existing side, even when a -/// connector added by this patch now crosses the label: hand placement -/// wins, and the human (or a full relayout) can move it. -/// -/// Composition: -/// 1. Compute metadata for the post-patch model -/// 2. Seed LayoutState from old view -/// 3. Process deletions and renames from the patch -/// 4. Identify new elements, compute initial positions -/// 5. Create view elements and settle via pinned SFDP -/// 6. Diff connectors/clouds, place labels for this pass's elements, -/// apply loop curvature -/// 7. Build StockFlow from final state -pub fn incremental_layout( - old_view: &datamodel::StockFlow, - project: &datamodel::Project, - model_name: &str, - patch: &crate::patch::ModelPatch, - db_state: Option<(&crate::db::SimlinDb, crate::db::SourceProject)>, -) -> Result { - if old_view.elements.is_empty() { - return generate_best_layout(project, model_name, db_state); - } - - // View-only patches (UpsertView/DeleteView) don't affect model variables, - // so the diagram should be returned unchanged. Without this guard, the - // diff_connectors and optimize_labels passes would rewrite connectors and - // labels even though nothing structurally changed. - let has_variable_ops = patch.ops.iter().any(|op| { - !matches!( - op, - crate::patch::ModelOperation::UpsertView { .. } - | crate::patch::ModelOperation::DeleteView { .. } - ) - }); - if !has_variable_ops { - return Ok(old_view.clone()); - } - - let config = LayoutConfig::default(); - - let not_found = || format!("model '{}' not found in project", model_name); - let model = project.get_model(model_name).ok_or_else(not_found)?; - let metadata = compute_metadata(project, model_name, db_state).ok_or_else(not_found)?; - - // Step 2: Seed state from old view - let mut state = LayoutState::from_existing_view(old_view, model); - - // Step 3: Process deletions and renames - for op in &patch.ops { - match op { - crate::patch::ModelOperation::DeleteVariable { ident } => { - state.apply_deletion(ident); - } - crate::patch::ModelOperation::RenameVariable { from, to } => { - let new_display = state - .display_names - .get(&canonicalize(to).into_owned()) - .cloned() - .unwrap_or_else(|| to.clone()); - state.apply_rename(from, to, &new_display); - } - _ => {} - } - } - - // Between steps 3 and 4a: detect variables whose type changed (e.g., Aux -> Stock). - // When a caller issues UpsertStock for a variable that was previously an Aux, there - // is no DeleteVariable in the patch and the old Aux element is still in state. - // identify_new_elements only checks for UID presence, not element type, so the - // stale element would survive. We detect type mismatches here and remove the - // old element so it is rebuilt with the correct type. - { - let kind_changed: Vec = model - .variables - .iter() - .filter_map(|var| { - let canonical = canonicalize(var.get_ident()).into_owned(); - let uid = state.uid_manager.get_uid(&canonical)?; - // Find the view element for this UID - let elem = state.elements.iter().find(|e| e.get_uid() == uid)?; - // Check for a type mismatch - let mismatch = !matches!( - (var, elem), - (datamodel::Variable::Stock(_), ViewElement::Stock(_)) - | (datamodel::Variable::Flow(_), ViewElement::Flow(_)) - | (datamodel::Variable::Aux(_), ViewElement::Aux(_)) - | (datamodel::Variable::Module(_), ViewElement::Module(_)) - ); - if mismatch { Some(canonical) } else { None } - }) - .collect(); - for ident in kind_changed { - // Save the display name before apply_deletion removes it from display_names, - // so the rebuilt element can recover the original casing (e.g. "Growth Rate" - // instead of "growth_rate"). - let saved_display = state.display_names.get(&ident).cloned(); - state.apply_deletion(&ident); - // Restore: use the saved original display name when available, otherwise - // fall back to the canonical ident so the entry is always present. - let display = saved_display.unwrap_or_else(|| ident.clone()); - state.display_names.insert(ident, display); - } - } - - // Between steps 3 and 4: detect flows whose stock connections changed. - // A flow element keeps its old attached_to_uid values when preserved in state, - // so a flow that moved from one stock to another would keep stale endpoints. - // Remove such flows (and their clouds) so identify_new_elements picks them - // up as new and they get rebuilt with correct endpoints. - // - // This also handles transitions between stock and cloud endpoints: if the - // model now expects a cloud source (from_stock == None) but the preserved - // flow's source point is still attached to a stock UID, the flow is stale. - { - let uid_to_ident: HashMap = model - .variables - .iter() - .filter_map(|var| { - let ident = canonicalize(var.get_ident()).into_owned(); - state.uid_manager.get_uid(&ident).map(|uid| (uid, ident)) - }) - .collect(); - - // Build the set of cloud UIDs so we can validate cloud-endpoint assignments. - // When a cloud is expected (expected_from/to == None), the flow endpoint must - // be either unattached or attached to a cloud. Checking against cloud_uids - // (rather than just "not in stock_uids") catches the case where a stock was - // kind-changed to an aux: the old UID is reused by the new non-stock element, - // so the flow must be rebuilt with a proper cloud endpoint. - let cloud_uids: HashSet = state - .elements - .iter() - .filter_map(|elem| match elem { - ViewElement::Cloud(c) => Some(c.uid), - _ => None, - }) - .collect(); - - let flows_to_reset: Vec = state - .elements - .iter() - .filter_map(|elem| { - let flow = match elem { - ViewElement::Flow(f) => f, - _ => return None, - }; - if flow.points.len() < 2 { - return None; - } - let flow_ident = uid_to_ident.get(&flow.uid)?; - let (expected_from, expected_to) = metadata.flow_to_stocks.get(flow_ident)?; - - let expected_from_uid = expected_from - .as_deref() - .and_then(|s| state.uid_manager.get_uid(s)); - let expected_to_uid = expected_to - .as_deref() - .and_then(|s| state.uid_manager.get_uid(s)); - - // Check the source endpoint (points[0]): - // - None expected (cloud): endpoint must be unattached or attached to a cloud - // - Some(uid) expected: the source must be attached to exactly that stock - let source_uid = flow.points[0].attached_to_uid; - let from_matches = match expected_from_uid { - None => { - source_uid.is_none() || source_uid.is_some_and(|u| cloud_uids.contains(&u)) - } - Some(uid) => source_uid == Some(uid), - }; - - // Check the sink endpoint (points[last]): - // - None expected (cloud): endpoint must be unattached or attached to a cloud - // - Some(uid) expected: the sink must be attached to exactly that stock - let last = flow.points.len() - 1; - let sink_uid = flow.points[last].attached_to_uid; - let to_matches = match expected_to_uid { - None => sink_uid.is_none() || sink_uid.is_some_and(|u| cloud_uids.contains(&u)), - Some(uid) => sink_uid == Some(uid), - }; - - if from_matches && to_matches { - None - } else { - Some(flow_ident.clone()) - } - }) - .collect(); - - for flow_ident in flows_to_reset { - // apply_deletion removes the element from state.elements but leaves - // the UID in uid_manager. identify_new_elements will see a UID with - // no corresponding element and classify the flow as new, causing - // create_flow_view_element to rebuild it with correct endpoints. - let canonical = canonicalize(&flow_ident).into_owned(); - // Save the display name before apply_deletion removes it so the - // rebuilt element recovers the original casing. - let saved_display = state.display_names.get(&canonical).cloned(); - state.apply_deletion(&flow_ident); - let display = saved_display.unwrap_or_else(|| flow_ident.clone()); - state.display_names.insert(canonical, display); - } - } - - // Step 4: Identify new elements and compute initial positions - let new_elements = state.identify_new_elements(model); - - // Compute flow attachments for flows on stocks that are affected by - // flow additions, deletions, or connection changes. This ensures - // preserved flows get reclassified when a sibling chain flow is - // added or removed. - let mut incr_flow_attachments: HashMap = HashMap::new(); - let mut affected_stocks: HashSet = HashSet::new(); - - for flow_ident in &new_elements.new_flows { - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - if let Some(stock) = from_stock { - affected_stocks.insert(stock.to_string()); - } - if let Some(stock) = to_stock { - affected_stocks.insert(stock.to_string()); - } - } - - // Also mark stocks whose flow connections changed via the patch - // (e.g. when a chain flow is deleted, the stock loses a flow and - // remaining cloud flows may need reclassification from Bottom/Top - // back to Right/Left). - for op in &patch.ops { - if let crate::patch::ModelOperation::UpdateStockFlows { ident, .. } = op { - let canonical = canonicalize(ident).into_owned(); - affected_stocks.insert(canonical); - } - } - - // For deleted flows, find which stocks they were connected to in the - // old view. This handles patches that only emit DeleteVariable without - // UpdateStockFlows -- the remaining sibling flows still need to be - // reclassified. - // Build UID-to-ident map from the model's stock variables rather than - // from view element labels, since labels go through - // format_label_with_line_breaks and may not round-trip through - // canonicalize for quoted names like "a.b". - let stock_uid_to_ident: HashMap = model - .variables - .iter() - .filter_map(|v| { - if !matches!(v, datamodel::Variable::Stock(_)) { - return None; - } - let canonical = canonicalize(v.get_ident()).into_owned(); - state - .uid_manager - .get_uid(&canonical) - .map(|uid| (uid, canonical)) - }) - .collect(); - for op in &patch.ops { - if let crate::patch::ModelOperation::DeleteVariable { ident } = op { - let canonical = canonicalize(ident).into_owned(); - // Match by UID rather than display name: labels go through - // format_label_with_line_breaks which strips quoting, so - // canonicalizing the label back can produce a different ident - // for names like "a.b". - let deleted_uid = match state.uid_manager.get_uid(&canonical) { - Some(uid) => uid, - None => continue, - }; - for elem in &old_view.elements { - if let ViewElement::Flow(f) = elem - && f.uid == deleted_uid - { - for pt in &f.points { - if let Some(uid) = pt.attached_to_uid - && let Some(stock_ident) = stock_uid_to_ident.get(&uid) - { - affected_stocks.insert(stock_ident.clone()); - } - } - } - } - } - } - - for stock in &affected_stocks { - let sides = classify_flow_sides(stock, &metadata); - incr_flow_attachments.extend(sides); - } - - // Re-sort flows within each side group by existing position rather - // than alphabetical ident, so imported or manually-edited ordering - // is preserved when a sibling is added or removed. - reorder_attachments_by_position( - &mut incr_flow_attachments, - &state, - &affected_stocks, - &metadata, - ); - - // Check if any existing (preserved) flows need to change sides. - // If classify_flow_sides assigns Bottom/Top to a flow that is - // currently horizontal (or Right/Left to one that is vertical), - // delete and rebuild it so its geometry matches. - let mut flows_to_rebuild: Vec = Vec::new(); - // Label sides of flows rebuilt only to move along the same stock face: - // their pipe keeps its orientation, so the existing (possibly hand-placed) - // side stays valid and is restored after the rebuild. Flows whose - // orientation flips are rebuilt with a freshly chosen side instead. - let mut offset_rebuilt_label_sides: HashMap = HashMap::new(); - for (flow_ident, attachment) in &incr_flow_attachments { - // Skip flows that are new (they'll be created below) - if new_elements.new_flows.contains(flow_ident) { - continue; - } - // Skip stock-to-stock (chain) flows entirely: their pipe geometry - // is determined by both stock positions and ignores the attachment - // offset. Rebuilding them via attachment_based_flow_position (which - // only knows one stock) would place the valve beside one stock - // instead of between the pair. - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - if from_stock.is_some() && to_stock.is_some() { - continue; - } - // Check if this flow exists and has mismatched orientation or offset - if let Some(uid) = state.uid_manager.get_uid(flow_ident) { - let existing = state.elements.iter().find(|e| { - if let ViewElement::Flow(f) = e { - f.uid == uid - } else { - false - } - }); - if let Some(ViewElement::Flow(f)) = existing { - let orientation = compute_flow_orientation(&f.points); - let needs_vertical = matches!( - attachment.side, - StockAttachSide::Bottom | StockAttachSide::Top - ); - let is_vertical = matches!(orientation, FlowOrientation::Vertical); - if needs_vertical != is_vertical { - flows_to_rebuild.push(flow_ident.clone()); - } else { - // Orientation matches but the offset may have changed - // (e.g. a sibling was added/removed on the same face). - let stock_name = from_stock.or(to_stock); - if let Some(sn) = stock_name - && let Some(stock_uid) = state.uid_manager.get_uid(sn) - && let Some(&stock_pos) = state.positions.get(&stock_uid) - { - let (expected, current) = if needs_vertical { - let exp = stock_pos.x - config.stock_width / 2.0 - + config.stock_width * attachment.offset; - let cur = f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| pt.x); - (exp, cur) - } else { - let exp = stock_pos.y - config.stock_height / 2.0 - + config.stock_height * attachment.offset; - let cur = f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| pt.y); - (exp, cur) - }; - if let Some(c) = current - && (c - expected).abs() > 0.5 - { - flows_to_rebuild.push(flow_ident.clone()); - offset_rebuilt_label_sides.insert(flow_ident.clone(), f.label_side); - } - } - } - } - } - } - - // Save old positions before deletion so we have a fallback if - // attachment_based_flow_position can't resolve the stock UID - // (e.g. imported views with quoted identifiers). - let old_flow_positions: HashMap = flows_to_rebuild - .iter() - .filter_map(|ident| { - let uid = state.uid_manager.get_uid(ident)?; - state.positions.get(&uid).map(|&pos| (ident.clone(), pos)) - }) - .collect(); - - // Delete and rebuild flows that need to change orientation or offset - for flow_ident in &flows_to_rebuild { - let saved_display = state - .display_names - .get(&canonicalize(flow_ident).into_owned()) - .cloned(); - state.apply_deletion(flow_ident); - if let Some(display) = saved_display { - state - .display_names - .insert(canonicalize(flow_ident).into_owned(), display); - } - } - - // Every named element still standing at this point survived the patch - // untouched (or was merely renamed): its label side is pinned for the rest - // of the pass. Whatever gets created from here on -- new variables, - // kind-changed or endpoint-changed rebuilds, orientation-flipped flows -- - // is absent from this snapshot and has its side chosen by - // `optimize_labels_for` below. Offset-only rebuilt flows are added back - // explicitly because they were just deleted but keep their orientation. - let mut pinned_label_sides: HashMap = state - .elements - .iter() - .filter_map(|elem| match elem { - ViewElement::Stock(s) => Some((s.uid, s.label_side)), - ViewElement::Flow(f) => Some((f.uid, f.label_side)), - ViewElement::Aux(a) => Some((a.uid, a.label_side)), - ViewElement::Module(m) => Some((m.uid, m.label_side)), - _ => None, - }) - .collect(); - for (flow_ident, side) in &offset_rebuilt_label_sides { - if let Some(uid) = state.uid_manager.get_uid(flow_ident) { - pinned_label_sides.insert(uid, *side); - } - } - - // Compute positions for rebuilt flows based on their attachment info, - // falling back to the old position if the stock UID lookup fails. - for flow_ident in &flows_to_rebuild { - let pos = attachment_based_flow_position( - &state, - &config, - &metadata, - flow_ident, - &incr_flow_attachments, - ) - .or_else(|| old_flow_positions.get(flow_ident).copied()); - if let Some(pos) = pos { - let uid = state.get_or_alloc_uid(flow_ident); - create_flow_view_element( - &mut state, - &config, - &metadata, - flow_ident, - uid, - pos, - &incr_flow_attachments, - )?; - } - } - - restore_label_sides(&mut state, &pinned_label_sides); - let needs_label_placement = |uid: i32| !pinned_label_sides.contains_key(&uid); - - if new_elements.is_empty() { - // No new elements and no settlement step, so rebuilt flows - // already have correct geometry from create_flow_view_element. - // Skip resnap entirely to avoid rewriting unrelated manual or - // imported flow endpoints elsewhere in the diagram. - diff_connectors(&mut state, &metadata); - diff_clouds(&mut state, &metadata); - optimize_labels_for(&mut state, model, &metadata, needs_label_placement); - apply_loop_curvature(&mut state, &config, model, &metadata); - validate_view_completeness(&state, model)?; - return Ok(build_stock_flow_from_state(state, old_view)); - } - - let initial_positions = compute_new_element_positions(&state, &metadata, &new_elements); - - // Step 5: Create view elements for new variables and insert their - // initial positions into state so settlement can find them. - for stock_ident in &new_elements.new_stocks { - if let Some(&pos) = initial_positions.get(stock_ident) { - let uid = state.get_or_alloc_uid(stock_ident); - let name = state.display_name(stock_ident); - let formatted = format_label_with_line_breaks(&name); - state.elements.push(ViewElement::Stock(view_element::Stock { - name: formatted, - uid, - x: pos.x, - y: pos.y, - label_side: LabelSide::Bottom, - compat: None, - })); - state.positions.insert(uid, pos); - } - } - - for flow_ident in &new_elements.new_flows { - // For stock-to-stock (chain) flows, use the generic seed position - // which places the valve between the two stocks. For cloud flows - // (one unattached end), use attachment-based position so top/bottom - // flows get their valve on the correct vertical pipe. - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - let is_stock_to_stock = from_stock.is_some() && to_stock.is_some(); - let pos = if is_stock_to_stock { - initial_positions.get(flow_ident).copied() - } else { - attachment_based_flow_position( - &state, - &config, - &metadata, - flow_ident, - &incr_flow_attachments, - ) - .or_else(|| initial_positions.get(flow_ident).copied()) - }; - if let Some(pos) = pos { - let uid = state.get_or_alloc_uid(flow_ident); - create_flow_view_element( - &mut state, - &config, - &metadata, - flow_ident, - uid, - pos, - &incr_flow_attachments, - )?; - } - } - - // create_flow_view_element calls build_clouds_for_flow which pushes Cloud elements into - // state.elements but does not add their positions to state.positions. Record those - // positions now so that settle_new_elements can seed proper initial positions for cloud - // nodes in SFDP and later update them after settling. - for elem in &state.elements { - if let ViewElement::Cloud(c) = elem { - state - .positions - .entry(c.uid) - .or_insert_with(|| Position::new(c.x, c.y)); - } - } - - for aux_ident in &new_elements.new_auxes { - if let Some(&pos) = initial_positions.get(aux_ident) { - let uid = state.get_or_alloc_uid(aux_ident); - let name = state.display_name(aux_ident); - let formatted = format_label_with_line_breaks(&name); - state.elements.push(ViewElement::Aux(view_element::Aux { - name: formatted, - uid, - x: pos.x, - y: pos.y, - label_side: LabelSide::Bottom, - compat: None, - })); - state.positions.insert(uid, pos); - } - } - - for module_ident in &new_elements.new_modules { - if let Some(&pos) = initial_positions.get(module_ident) { - let uid = state.get_or_alloc_uid(module_ident); - let name = state.display_name(module_ident); - let formatted = format_label_with_line_breaks(&name); - state - .elements - .push(ViewElement::Module(view_element::Module { - name: formatted, - uid, - x: pos.x, - y: pos.y, - label_side: LabelSide::Bottom, - })); - state.positions.insert(uid, pos); - } - } - - // Step 6: Settle new elements with existing elements pinned - let chains_data: Vec<_> = metadata - .chains - .iter() - .map(|c| (c.stocks.clone(), c.flows.clone(), c.all_vars.clone())) - .collect(); - settle_new_elements( - &mut state, - &config, - model, - &metadata, - &new_elements, - &chains_data, - )?; - - // Update view element coordinates from settled positions - for elem in &mut state.elements { - let uid = elem.get_uid(); - if let Some(&pos) = state.positions.get(&uid) { - match elem { - ViewElement::Stock(s) => { - s.x = pos.x; - s.y = pos.y; - } - ViewElement::Flow(f) => { - let dx = pos.x - f.x; - let dy = pos.y - f.y; - f.x = pos.x; - f.y = pos.y; - for pt in &mut f.points { - pt.x += dx; - pt.y += dy; - } - } - ViewElement::Aux(a) => { - a.x = pos.x; - a.y = pos.y; - } - ViewElement::Module(m) => { - m.x = pos.x; - m.y = pos.y; - } - ViewElement::Cloud(c) => { - c.x = pos.x; - c.y = pos.y; - } - _ => {} - } - } - } - - resnap_flow_endpoints(&mut state, &config); - - // Step 7: Diff connectors and clouds - diff_connectors(&mut state, &metadata); - diff_clouds(&mut state, &metadata); - - // Step 8: Polish. Only elements created in this pass get a label side - // chosen; pinned elements keep theirs even if a new connector now runs - // through the label (hand placement wins; the human can move it). - optimize_labels_for(&mut state, model, &metadata, needs_label_placement); - apply_loop_curvature(&mut state, &config, model, &metadata); - // Guarantee flows stay orthogonal after re-snapping endpoints to moved - // stocks (only rewrites pipes that actually went diagonal; hand-routed - // orthogonal flows are left untouched). - orthogonal::orthogonalize_flow_pipes(&mut state.elements); - - validate_view_completeness(&state, model)?; - - // Step 9: Build StockFlow - Ok(build_stock_flow_from_state(state, old_view)) -} - /// Generate a complete stock-flow diagram layout for a model using a single /// seed. This is the fast path; for higher-quality results use /// [`generate_best_layout`] which tries multiple seeds in parallel. diff --git a/src/simlin-engine/src/layout/polish.rs b/src/simlin-engine/src/layout/polish.rs new file mode 100644 index 000000000..a63af2f53 --- /dev/null +++ b/src/simlin-engine/src/layout/polish.rs @@ -0,0 +1,416 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// Crossing polish on drawn geometry. The force pass reduces crossings with +// annealing over straight chords between point nodes, and what it leaves is +// what the final diagram shows: the declutter pass moves free nodes only as +// far as overlaps demand, so it neither adds nor removes crossings. Hand-drawn +// diagrams in the corpus almost never cross links, while generated ones cross +// on a tenth to two fifths of their connectors, typically a parameter drawn on +// the far side of a chain or cluster from its consumer. +// +// This pass visits each free node (auxiliary, module, ghost) that sits on a +// crossing and tries it at a ring of spots around its neighbors. A spot is +// charged what the metric charges for the node locally -- its own connectors' +// crossings, and the names struck by its connectors or through its own name -- +// and must keep the node's shape and label clear of every other shape, label, +// and pipe. A node's move changes only those charges, so keeping a spot only +// where they fall never raises the metric's crossing and strike terms. + +use super::*; +use crate::diagram::common::{ + Point, Rect as Bounds, rect_overlap_area, segment_clip_interval_in_rect, +}; +use crate::diagram::label::label_bounds; +use crate::layout::metrics::{ + COMFORTABLE_CLEARANCE, LABEL_INSET, MetricWeights, OWN_LINK_STRIKE_FACTOR, + alias_label_props_for, alias_source_names, element_label_props_for, node_shape_box, pipe_rects, +}; + +/// Passes over the free nodes; each pass that moves nothing ends the polish. +const POLISH_ROUNDS: usize = 3; + +/// Spots tried on the ring around a node's neighbors. +const RING_SPOTS: usize = 16; + +/// The ring's radius is the node's current distance to its neighbors' +/// centroid, held within these bounds so a node drawn on top of its neighbors +/// still gets room and a far-flung one is brought in. +const MIN_RING_RADIUS: f64 = 60.0; +const MAX_RING_RADIUS: f64 = 180.0; + +/// Move free nodes off crossings where a nearby spot uncrosses their +/// connectors (see the module docs). Deterministic: nodes are visited in uid +/// order and ties keep the earliest spot, starting from where the node is. +pub(crate) fn polish_crossings(elements: &mut [ViewElement]) { + polish_crossings_for(elements, |_| true); +} + +/// [`polish_crossings`] moving only the free nodes whose uid `moves` accepts; +/// everything else is fixed, as the incremental layout needs for what was +/// already drawn. +pub(crate) fn polish_crossings_for(elements: &mut [ViewElement], moves: impl Fn(i32) -> bool) { + let free: Vec = { + let mut free: Vec<(i32, usize)> = elements + .iter() + .enumerate() + .filter(|(_, e)| { + matches!( + e, + ViewElement::Aux(_) | ViewElement::Module(_) | ViewElement::Alias(_) + ) && moves(e.get_uid()) + }) + .map(|(i, e)| (e.get_uid(), i)) + .collect(); + free.sort_unstable(); + free.into_iter().map(|(_, i)| i).collect() + }; + let links: Vec<(usize, i32, i32)> = elements + .iter() + .enumerate() + .filter_map(|(i, e)| match e { + ViewElement::Link(l) => Some((i, l.from_uid, l.to_uid)), + _ => None, + }) + .collect(); + + let mut scene = PolishScene::new(elements); + for _ in 0..POLISH_ROUNDS { + let mut moved = false; + for &node in &free { + moved |= polish_node(elements, &mut scene, node, &links); + } + if !moved { + break; + } + } +} + +/// What the polish charges spots against, by element index: each connector's +/// segments, each element's shape and pipe boxes, and its label box. Only the +/// entries of a node that moved and of its connectors change, so they are +/// refreshed after each move rather than rebuilt per spot. +struct PolishScene { + segments: Vec>, + shapes: Vec>, + labels: Vec>, + alias_names: HashMap, + connector_count: usize, + label_count: usize, +} + +impl PolishScene { + fn new(elements: &[ViewElement]) -> Self { + let alias_names = alias_source_names(elements); + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); + let segments: Vec> = elements + .iter() + .map(|e| element_segments(e, &uid_elements)) + .collect(); + let shapes = elements.iter().map(shape_boxes).collect(); + let labels: Vec> = elements + .iter() + .map(|e| label_box(e, &alias_names)) + .collect(); + PolishScene { + connector_count: segments.iter().filter(|s| !s.is_empty()).count().max(1), + label_count: labels.iter().flatten().count().max(1), + segments, + shapes, + labels, + alias_names, + } + } + + /// Recompute the entries of `node` and of the connectors in `connectors`. + fn refresh(&mut self, elements: &[ViewElement], node: usize, connectors: &[usize]) { + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); + for &i in connectors { + self.segments[i] = element_segments(&elements[i], &uid_elements); + } + self.shapes[node] = shape_boxes(&elements[node]); + self.labels[node] = label_box(&elements[node], &self.alias_names); + } +} + +fn shape_boxes(e: &ViewElement) -> Vec { + let mut rects: Vec = node_shape_box(e).into_iter().collect(); + if let ViewElement::Flow(f) = e { + rects.extend(pipe_rects(f)); + } + rects +} + +/// Try `elements[node]` at the spots around its neighbors; returns whether it +/// moved. +/// +/// A spot is charged what the metric charges the node's move locally: the +/// crossings of its own connectors, and the names struck -- by its connectors +/// through other labels and by any connector through its own -- each in the +/// metric's per-connector and per-label units. The node's shape and label must +/// land at least the crowding clearance from every other shape, label, and +/// pipe. A move is kept only where the charge falls. +fn polish_node( + elements: &mut [ViewElement], + scene: &mut PolishScene, + node: usize, + links: &[(usize, i32, i32)], +) -> bool { + let uid = elements[node].get_uid(); + // (connector index, the link's other endpoint) + let incident: Vec<(usize, i32)> = links + .iter() + .filter_map(|&(i, from, to)| { + if from == uid { + Some((i, to)) + } else if to == uid { + Some((i, from)) + } else { + None + } + }) + .collect(); + if incident.is_empty() { + return false; + } + let is_incident = |i: usize| incident.iter().any(|&(j, _)| j == i); + let others = || { + scene + .segments + .iter() + .enumerate() + .filter(move |(i, _)| !is_incident(*i)) + .flat_map(|(_, segs)| segs.iter()) + }; + + // Cheap gate: a node none of whose connectors cross anything stays put. + let crossing_now = incident.iter().any(|&(i, _)| { + scene.segments[i] + .iter() + .any(|seg| others().any(|other| annealing::do_segments_intersect(seg, other))) + }); + if !crossing_now { + return false; + } + + let weights = MetricWeights::default(); + let Some(start) = element_center(&elements[node]) else { + return false; + }; + let neighbors: Vec = incident + .iter() + .filter_map(|&(_, other)| { + elements + .iter() + .find(|e| e.get_uid() == other) + .and_then(element_center) + }) + .collect(); + if neighbors.is_empty() { + return false; + } + + // The node's local charge with it at `p`, and its connectors' length. + let charge = |elements: &[ViewElement], moved: &ViewElement| -> (f64, f64) { + let mut uid_elements: HashMap = incident + .iter() + .filter_map(|&(_, other)| elements.iter().find(|e| e.get_uid() == other)) + .map(|e| (e.get_uid(), e)) + .collect(); + uid_elements.insert(uid, moved); + let mut crossings = 0usize; + let mut length = 0.0; + let mut own: Vec<(LineSegment, i32)> = Vec::new(); + for &(i, other_end) in &incident { + for seg in element_segments(&elements[i], &uid_elements) { + length += (seg.end - seg.start).length(); + crossings += others() + .filter(|other| annealing::do_segments_intersect(&seg, other)) + .count(); + own.push((seg, other_end)); + } + } + let mut struck = 0.0; + for (j, label) in scene.labels.iter().enumerate() { + let Some(label) = label else { continue }; + if j == node { + continue; + } + let owner = elements[j].get_uid(); + let through: f64 = own + .iter() + .map(|(seg, other_end)| { + let factor = if *other_end == owner { + OWN_LINK_STRIKE_FACTOR + } else { + 1.0 + }; + factor * run_inside(seg, label) + }) + .sum(); + struck += strike_fraction(through, label); + } + if let Some(label) = label_box(moved, &scene.alias_names) { + let through: f64 = others().map(|seg| run_inside(seg, &label)).sum::() + + own + .iter() + .map(|(seg, _)| OWN_LINK_STRIKE_FACTOR * run_inside(seg, &label)) + .sum::(); + struck += strike_fraction(through, &label); + } + let cost = weights.crossings * crossings as f64 / scene.connector_count as f64 + + weights.label_connector_overlap * struck / scene.label_count as f64; + (cost, length) + }; + + let (now_cost, now_length) = charge(elements, &elements[node]); + + let n = neighbors.len() as f64; + let centroid = Position::new( + neighbors.iter().map(|p| p.x).sum::() / n, + neighbors.iter().map(|p| p.y).sum::() / n, + ); + let radius = (start - centroid) + .length() + .clamp(MIN_RING_RADIUS, MAX_RING_RADIUS); + let mut best: Option<(f64, f64, Position)> = None; + let mut candidate = elements[node].clone(); + for k in 0..RING_SPOTS { + let angle = k as f64 * 2.0 * PI / RING_SPOTS as f64; + let spot = Position::new( + centroid.x + radius * angle.cos(), + centroid.y + radius * angle.sin(), + ); + set_center(&mut candidate, spot); + let footprint: Vec = node_shape_box(&candidate) + .into_iter() + .chain(label_box(&candidate, &scene.alias_names)) + .map(|r| grown(&r, COMFORTABLE_CLEARANCE)) + .collect(); + let blocked = scene + .shapes + .iter() + .zip(&scene.labels) + .enumerate() + .filter(|(j, _)| *j != node) + .flat_map(|(_, (shapes, label))| shapes.iter().chain(label.iter())) + .any(|obstacle| { + footprint + .iter() + .any(|clear| rect_overlap_area(clear, obstacle) > 0.0) + }); + if blocked { + continue; + } + let (cost, length) = charge(elements, &candidate); + let (best_cost, best_length) = best.map_or((now_cost, now_length), |(c, l, _)| (c, l)); + if cost < best_cost - 1e-12 || (cost <= best_cost + 1e-12 && length < best_length - 1e-9) { + best = Some((cost, length, spot)); + } + } + match best { + Some((cost, _, spot)) if cost < now_cost - 1e-12 => { + set_center(&mut elements[node], spot); + let connectors: Vec = incident.iter().map(|&(i, _)| i).collect(); + scene.refresh(elements, node, &connectors); + true + } + _ => false, + } +} + +/// The metric's strike fraction for `through` px of line in `label`'s text. +fn strike_fraction(through: f64, label: &Bounds) -> f64 { + let side = (label.right - label.left).min(label.bottom - label.top) - 2.0 * LABEL_INSET; + if side <= 0.0 { + return 0.0; + } + (through / side).min(1.0) +} + +/// How far a segment runs inside `label`'s (inset) text box. +fn run_inside(seg: &LineSegment, label: &Bounds) -> f64 { + let text = Bounds { + left: label.left + LABEL_INSET, + top: label.top + LABEL_INSET, + right: label.right - LABEL_INSET, + bottom: label.bottom - LABEL_INSET, + }; + let (p0, p1) = ( + Point { + x: seg.start.x, + y: seg.start.y, + }, + Point { + x: seg.end.x, + y: seg.end.y, + }, + ); + segment_clip_interval_in_rect(&p0, &p1, &text) + .map_or(0.0, |(t0, t1)| (t1 - t0) * (seg.end - seg.start).length()) +} + +/// The label box an element draws at its current side. +fn label_box(e: &ViewElement, alias_names: &HashMap) -> Option { + if let ViewElement::Alias(a) = e { + let name = alias_names.get(&a.uid)?; + return Some(label_bounds(&alias_label_props_for(a, name, a.label_side))); + } + let side = match e { + ViewElement::Aux(a) => a.label_side, + ViewElement::Stock(s) => s.label_side, + ViewElement::Flow(f) => f.label_side, + ViewElement::Module(m) => m.label_side, + _ => return None, + }; + element_label_props_for(e, side).map(|props| label_bounds(&props)) +} + +fn element_center(e: &ViewElement) -> Option { + match e { + ViewElement::Aux(a) => Some(Position::new(a.x, a.y)), + ViewElement::Module(m) => Some(Position::new(m.x, m.y)), + ViewElement::Alias(a) => Some(Position::new(a.x, a.y)), + ViewElement::Stock(s) => Some(Position::new(s.x, s.y)), + ViewElement::Flow(f) => Some(Position::new(f.x, f.y)), + ViewElement::Cloud(c) => Some(Position::new(c.x, c.y)), + ViewElement::Link(_) | ViewElement::Group(_) => None, + } +} + +/// Place a free node's center at `p`. +fn set_center(e: &mut ViewElement, p: Position) { + match e { + ViewElement::Aux(a) => { + a.x = p.x; + a.y = p.y; + } + ViewElement::Module(m) => { + m.x = p.x; + m.y = p.y; + } + ViewElement::Alias(a) => { + a.x = p.x; + a.y = p.y; + } + _ => {} + } +} + +fn grown(r: &Bounds, d: f64) -> Bounds { + Bounds { + left: r.left - d, + top: r.top - d, + right: r.right + d, + bottom: r.bottom + d, + } +} + +#[cfg(test)] +#[path = "polish_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/layout/polish_tests.rs b/src/simlin-engine/src/layout/polish_tests.rs new file mode 100644 index 000000000..351990e5b --- /dev/null +++ b/src/simlin-engine/src/layout/polish_tests.rs @@ -0,0 +1,155 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +use super::*; +use crate::datamodel::view_element::{self, LabelSide, LinkShape}; + +fn aux(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { + ViewElement::Aux(view_element::Aux { + name: name.to_string(), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +fn stock(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { + ViewElement::Stock(view_element::Stock { + name: name.to_string(), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +fn link(uid: i32, from_uid: i32, to_uid: i32) -> ViewElement { + ViewElement::Link(view_element::Link { + uid, + from_uid, + to_uid, + shape: LinkShape::Straight, + polarity: None, + }) +} + +fn view_of(elements: Vec) -> datamodel::StockFlow { + datamodel::StockFlow { + name: None, + elements, + view_box: datamodel::Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + } +} + +fn position(elements: &[ViewElement], uid: i32) -> (f64, f64) { + elements + .iter() + .find_map(|e| match e { + ViewElement::Aux(a) if a.uid == uid => Some((a.x, a.y)), + ViewElement::Stock(s) if s.uid == uid => Some((s.x, s.y)), + _ => None, + }) + .expect("node drawn") +} + +#[test] +fn a_parameter_on_the_wrong_side_steps_off_the_crossing() { + // Two stocks side by side, each read by a parameter drawn below the OTHER + // stock, so the two links cross in an X. Moving either parameter around + // its stock uncrosses them; the stocks never move. + let elements = vec![ + stock(1, "left stock", 0.0, 0.0), + stock(2, "right stock", 200.0, 0.0), + aux(3, "left parameter", 200.0, 120.0), + aux(4, "right parameter", 0.0, 120.0), + link(10, 3, 1), + link(11, 4, 2), + ]; + let before = count_view_crossings(&view_of(elements.clone())); + assert_eq!(before, 1, "fixture: the two links cross"); + + let mut polished = elements.clone(); + polish_crossings(&mut polished); + + assert_eq!(count_view_crossings(&view_of(polished.clone())), 0); + assert_eq!(position(&polished, 1), position(&elements, 1)); + assert_eq!(position(&polished, 2), position(&elements, 2)); +} + +#[test] +fn a_crossing_free_diagram_is_left_alone() { + let elements = vec![ + stock(1, "left stock", 0.0, 0.0), + stock(2, "right stock", 200.0, 0.0), + aux(3, "left parameter", 0.0, 120.0), + aux(4, "right parameter", 200.0, 120.0), + link(10, 3, 1), + link(11, 4, 2), + ]; + let mut polished = elements.clone(); + polish_crossings(&mut polished); + assert!(polished == elements, "nothing crosses, so nothing moves"); +} + +#[test] +fn a_parameter_never_steps_onto_another_shape() { + // A parameter reads down into its stock across a long link between two + // far auxes. Every spot on its ring above that link would uncross it, and + // every one of them is taken by a module; the polish must keep the + // crossing rather than drop the parameter onto a module. + let mut elements = vec![ + stock(1, "consumer", 0.0, 0.0), + aux(2, "crossed parameter", 0.0, 100.0), + aux(3, "a", -400.0, 50.0), + aux(4, "b", 400.0, 50.0), + link(10, 2, 1), + link(11, 3, 4), + ]; + for k in 0..16 { + let angle = k as f64 * 2.0 * PI / 16.0; + let (x, y) = (100.0 * angle.cos(), 100.0 * angle.sin()); + if y < 50.0 { + elements.push(ViewElement::Module(view_element::Module { + name: format!("module {k}"), + uid: 100 + k, + x, + y, + label_side: LabelSide::Bottom, + })); + } + } + let before = elements.clone(); + polish_crossings(&mut elements); + assert_eq!( + position(&elements, 2), + position(&before, 2), + "the parameter stays put" + ); +} + +#[test] +fn only_the_nodes_it_may_move_move() { + // The crossed X of the first test, with only the left parameter free to + // move: it uncrosses the links alone, and the right parameter stays put. + let elements = vec![ + stock(1, "left stock", 0.0, 0.0), + stock(2, "right stock", 200.0, 0.0), + aux(3, "left parameter", 200.0, 120.0), + aux(4, "right parameter", 0.0, 120.0), + link(10, 3, 1), + link(11, 4, 2), + ]; + let mut polished = elements.clone(); + polish_crossings_for(&mut polished, |uid| uid == 3); + assert_eq!(count_view_crossings(&view_of(polished.clone())), 0); + assert_ne!(position(&polished, 3), position(&elements, 3)); + assert_eq!(position(&polished, 4), position(&elements, 4)); +} diff --git a/src/simlin-engine/src/layout/taste.rs b/src/simlin-engine/src/layout/taste.rs new file mode 100644 index 000000000..23edee0f4 --- /dev/null +++ b/src/simlin-engine/src/layout/taste.rs @@ -0,0 +1,361 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// Metamorphic taste checks for the layout-quality metric. +// +// A metric that is supposed to capture diagram taste must, at minimum, get +// WORSE when a diagram is visibly degraded. Each `Degradation` below is an edit +// every modeler would call a regression -- crowd the diagram, scatter its +// parameters, drop a node on another -- applied to a view that was fine. A +// degradation the metric does not penalize is a measured blind spot: an +// optimizer driving the metric is free to produce exactly that defect. +// +// The edits move only what a person would move by hand: free-floating nodes +// (auxiliaries, modules, aliases) are displaced individually; the stock-flow +// backbone moves only under a uniform transform, with flow endpoints re-snapped +// to the fixed-size stocks. A moved connector keeps its bow relative to its +// chord, so the edit changes WHERE nodes sit, not how curved their links are. +// +// PURE: every function takes a view and returns a new view; no I/O, and the +// seeded randomness is deterministic. + +use std::collections::{HashMap, HashSet}; + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +use crate::datamodel::view_element::LinkShape; +use crate::datamodel::{StockFlow, ViewElement}; +use crate::diagram::connector::get_visual_center; + +use super::declutter::resnap_flow_endpoints_to_stocks; + +/// A visibly-worse edit to a diagram. Every variant is expected to RAISE the +/// weighted cost of a view that was not already degraded in that way. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Degradation { + /// Shrink every position toward the view centroid by `factor` (< 1): labels + /// crowd each other and connectors shorten toward invisibility. + Cramp(f64), + /// Spread every position away from the view centroid by `factor` (> 1): the + /// diagram sprawls and every connector lengthens. + Inflate(f64), + /// Displace each free-floating node by a seeded random offset of up to + /// `amplitude` on each axis: alignment breaks and neighbors collide. + Jitter { amplitude: f64, seed: u64 }, + /// Permute the positions of the free-floating nodes: every node lands away + /// from what it connects to. + Shuffle { seed: u64 }, + /// Move the most-used parameter (the free node with the most outgoing + /// links) far outside the diagram: its connectors cross everything. + Exile, + /// Drop one free node exactly onto another free node. + Stack, + /// Draw every curved connector straight: feedback loops read as zig-zags. + StraightenLinks, +} + +impl Degradation { + /// A short stable name for reports. + pub fn name(&self) -> &'static str { + match self { + Degradation::Cramp(_) => "cramp", + Degradation::Inflate(_) => "inflate", + Degradation::Jitter { .. } => "jitter", + Degradation::Shuffle { .. } => "shuffle", + Degradation::Exile => "exile", + Degradation::Stack => "stack", + Degradation::StraightenLinks => "straighten", + } + } + + /// The standard battery the eval harness runs, in report order. + pub fn battery() -> [Degradation; 7] { + [ + Degradation::Cramp(0.6), + Degradation::Inflate(2.0), + Degradation::Jitter { + amplitude: 40.0, + seed: 7, + }, + Degradation::Shuffle { seed: 11 }, + Degradation::Exile, + Degradation::Stack, + Degradation::StraightenLinks, + ] + } +} + +/// Whether an element is a free-floating node a person drags individually. +fn is_free_node(e: &ViewElement) -> bool { + matches!( + e, + ViewElement::Aux(_) | ViewElement::Module(_) | ViewElement::Alias(_) + ) +} + +fn position(e: &ViewElement) -> Option<(f64, f64)> { + match e { + ViewElement::Aux(a) => Some((a.x, a.y)), + ViewElement::Stock(s) => Some((s.x, s.y)), + ViewElement::Flow(f) => Some((f.x, f.y)), + ViewElement::Module(m) => Some((m.x, m.y)), + ViewElement::Alias(a) => Some((a.x, a.y)), + ViewElement::Cloud(c) => Some((c.x, c.y)), + ViewElement::Link(_) | ViewElement::Group(_) => None, + } +} + +fn set_position(e: &mut ViewElement, x: f64, y: f64) { + match e { + ViewElement::Aux(a) => (a.x, a.y) = (x, y), + ViewElement::Module(m) => (m.x, m.y) = (x, y), + ViewElement::Alias(a) => (a.x, a.y) = (x, y), + ViewElement::Stock(s) => (s.x, s.y) = (x, y), + ViewElement::Cloud(c) => (c.x, c.y) = (x, y), + ViewElement::Flow(f) => { + let (dx, dy) = (x - f.x, y - f.y); + f.x = x; + f.y = y; + for p in &mut f.points { + p.x += dx; + p.y += dy; + } + } + ViewElement::Link(_) | ViewElement::Group(_) => {} + } +} + +/// The takeoff angle an Arc link has RELATIVE to its chord (degrees), keyed by +/// link uid, so a moved connector can be redrawn with the same bow. +fn arc_offsets(view: &StockFlow) -> HashMap { + let by_uid: HashMap = + view.elements.iter().map(|e| (e.get_uid(), e)).collect(); + let not_arrayed = |_: &str| false; + view.elements + .iter() + .filter_map(|e| match e { + ViewElement::Link(link) => { + let LinkShape::Arc(takeoff) = link.shape else { + return None; + }; + let from = by_uid.get(&link.from_uid)?; + let to = by_uid.get(&link.to_uid)?; + let (fx, fy) = get_visual_center(from, ¬_arrayed); + let (tx, ty) = get_visual_center(to, ¬_arrayed); + let chord = (ty - fy).atan2(tx - fx).to_degrees(); + Some((link.uid, takeoff - chord)) + } + _ => None, + }) + .collect() +} + +/// Re-apply each Arc link's pre-edit chord-relative takeoff to the post-edit +/// chord, so an edit that moves nodes does not also re-curve their links. +fn restore_arc_offsets(view: &mut StockFlow, offsets: &HashMap) { + let centers: HashMap = view + .elements + .iter() + .filter(|e| !matches!(e, ViewElement::Link(_) | ViewElement::Group(_))) + .map(|e| (e.get_uid(), get_visual_center(e, &|_: &str| false))) + .collect(); + for e in &mut view.elements { + let ViewElement::Link(link) = e else { continue }; + let Some(offset) = offsets.get(&link.uid) else { + continue; + }; + let (Some(&(fx, fy)), Some(&(tx, ty))) = + (centers.get(&link.from_uid), centers.get(&link.to_uid)) + else { + continue; + }; + let chord = (ty - fy).atan2(tx - fx).to_degrees(); + link.shape = LinkShape::Arc(chord + offset); + } +} + +/// Mean position of every positioned element. +fn centroid(view: &StockFlow) -> Option<(f64, f64)> { + let pts: Vec<(f64, f64)> = view.elements.iter().filter_map(position).collect(); + if pts.is_empty() { + return None; + } + let n = pts.len() as f64; + Some(( + pts.iter().map(|p| p.0).sum::() / n, + pts.iter().map(|p| p.1).sum::() / n, + )) +} + +/// Scale every position (flow pipe points included) about `center` by `s`, +/// then re-snap flow endpoints to the fixed-size stocks. +fn scale_about(view: &mut StockFlow, center: (f64, f64), s: f64) { + for e in &mut view.elements { + match e { + ViewElement::Flow(f) => { + f.x = center.0 + (f.x - center.0) * s; + f.y = center.1 + (f.y - center.1) * s; + for p in &mut f.points { + p.x = center.0 + (p.x - center.0) * s; + p.y = center.1 + (p.y - center.1) * s; + } + } + _ => { + if let Some((x, y)) = position(e) { + set_position( + e, + center.0 + (x - center.0) * s, + center.1 + (y - center.1) * s, + ); + } + } + } + } + resnap_flow_endpoints_to_stocks(&mut view.elements); +} + +/// Indices of free-floating nodes, in uid order (deterministic). +fn free_node_indices(view: &StockFlow) -> Vec { + let mut idx: Vec = view + .elements + .iter() + .enumerate() + .filter(|(_, e)| is_free_node(e)) + .map(|(i, _)| i) + .collect(); + idx.sort_by_key(|&i| view.elements[i].get_uid()); + idx +} + +/// Apply `degradation` to `view`. Returns `None` when the edit does not apply +/// (no free-floating nodes to move, fewer than two to stack or shuffle, no +/// curved link to straighten, an empty view), so a report can say "n/a" +/// instead of scoring an unchanged copy. +pub fn degrade(view: &StockFlow, degradation: Degradation) -> Option { + let offsets = arc_offsets(view); + let mut out = view.clone(); + let free = free_node_indices(view); + match degradation { + Degradation::Cramp(factor) | Degradation::Inflate(factor) => { + let center = centroid(view)?; + scale_about(&mut out, center, factor); + } + Degradation::Jitter { amplitude, seed } => { + if free.is_empty() { + return None; + } + let mut rng = StdRng::seed_from_u64(seed); + for &i in &free { + let (x, y) = position(&out.elements[i])?; + let dx = rng.random_range(-amplitude..=amplitude); + let dy = rng.random_range(-amplitude..=amplitude); + set_position(&mut out.elements[i], x + dx, y + dy); + } + } + Degradation::Shuffle { seed } => { + if free.len() < 2 { + return None; + } + let positions: Vec<(f64, f64)> = free + .iter() + .filter_map(|&i| position(&view.elements[i])) + .collect(); + // Fisher-Yates, then rotate if the permutation left everything in + // place so the edit always moves something. + let mut order: Vec = (0..positions.len()).collect(); + let mut rng = StdRng::seed_from_u64(seed); + for k in (1..order.len()).rev() { + let j = rng.random_range(0..=k); + order.swap(k, j); + } + if order.iter().enumerate().all(|(k, &j)| k == j) { + order.rotate_left(1); + } + for (k, &i) in free.iter().enumerate() { + let (x, y) = positions[order[k]]; + set_position(&mut out.elements[i], x, y); + } + } + Degradation::Exile => { + let outgoing = |uid: i32| { + view.elements + .iter() + .filter(|e| matches!(e, ViewElement::Link(l) if l.from_uid == uid)) + .count() + }; + let &target = free + .iter() + .filter(|&&i| outgoing(view.elements[i].get_uid()) > 0) + .max_by_key(|&&i| { + ( + outgoing(view.elements[i].get_uid()), + -view.elements[i].get_uid(), + ) + })?; + let (minx, miny, maxx, maxy) = view.elements.iter().filter_map(position).fold( + ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ), + |(a, b, c, d), (x, y)| (a.min(x), b.min(y), c.max(x), d.max(y)), + ); + let diag = ((maxx - minx).powi(2) + (maxy - miny).powi(2)) + .sqrt() + .max(200.0); + set_position(&mut out.elements[target], maxx + diag, maxy + diag); + } + Degradation::Stack => { + if free.len() < 2 { + return None; + } + let (x, y) = position(&view.elements[free[0]])?; + set_position(&mut out.elements[free[1]], x, y); + } + Degradation::StraightenLinks => { + let mut any = false; + for e in &mut out.elements { + if let ViewElement::Link(link) = e + && matches!(link.shape, LinkShape::Arc(_)) + { + link.shape = LinkShape::Straight; + any = true; + } + } + if !any { + return None; + } + return Some(out); + } + } + restore_arc_offsets(&mut out, &offsets); + Some(out) +} + +/// The uids a degradation moved, for tests and reports: every positioned +/// element whose position changed. +pub fn moved_uids(before: &StockFlow, after: &StockFlow) -> HashSet { + let old: HashMap = before + .elements + .iter() + .filter_map(|e| position(e).map(|p| (e.get_uid(), p))) + .collect(); + after + .elements + .iter() + .filter_map(|e| { + let p = position(e)?; + let q = old.get(&e.get_uid())?; + ((p.0 - q.0).abs() > 1e-9 || (p.1 - q.1).abs() > 1e-9).then_some(e.get_uid()) + }) + .collect() +} + +#[cfg(test)] +#[path = "taste_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/layout/taste_tests.rs b/src/simlin-engine/src/layout/taste_tests.rs new file mode 100644 index 000000000..7cf036db4 --- /dev/null +++ b/src/simlin-engine/src/layout/taste_tests.rs @@ -0,0 +1,230 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +use super::*; +use crate::datamodel; +use crate::diagram::constants::{STOCK_HEIGHT, STOCK_WIDTH}; + +/// A shipped, hand-authored default project's main view -- the realistic input +/// the eval harness degrades (the as-loaded view production would score). +fn default_project_view(dir: &str) -> StockFlow { + let path = format!( + "{}/../../default_projects/{}/model.xmile", + env!("CARGO_MANIFEST_DIR"), + dir + ); + let file = std::fs::File::open(&path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(file)) + .unwrap_or_else(|e| panic!("parse {path}: {e:?}")); + match project.get_model("main").and_then(|m| m.views.first()) { + Some(datamodel::View::StockFlow(sf)) => sf.clone(), + _ => panic!("{dir} ships no main view"), + } +} + +fn free_uids(view: &StockFlow) -> HashSet { + view.elements + .iter() + .filter(|e| is_free_node(e)) + .map(|e| e.get_uid()) + .collect() +} + +/// Every flow endpoint attached to a stock lies on that stock's boundary. +fn assert_flows_attached(view: &StockFlow) { + let stocks: HashMap = view + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y))), + _ => None, + }) + .collect(); + for e in &view.elements { + let ViewElement::Flow(f) = e else { continue }; + for p in &f.points { + let Some(&(sx, sy)) = p.attached_to_uid.and_then(|u| stocks.get(&u)) else { + continue; + }; + let on_vertical = ((p.x - sx).abs() - STOCK_WIDTH / 2.0).abs() < 1e-6 + && (p.y - sy).abs() <= STOCK_HEIGHT / 2.0 + 1e-6; + let on_horizontal = ((p.y - sy).abs() - STOCK_HEIGHT / 2.0).abs() < 1e-6 + && (p.x - sx).abs() <= STOCK_WIDTH / 2.0 + 1e-6; + assert!( + on_vertical || on_horizontal, + "flow {} endpoint ({}, {}) detached from stock at ({sx}, {sy})", + f.uid, + p.x, + p.y + ); + } + } +} + +#[test] +fn cramp_and_inflate_scale_about_the_centroid_and_keep_flows_attached() { + let view = default_project_view("fishbanks"); + let center = centroid(&view).unwrap(); + for (degradation, factor) in [ + (Degradation::Cramp(0.6), 0.6), + (Degradation::Inflate(2.0), 2.0), + ] { + let out = degrade(&view, degradation).expect("applies to any non-empty view"); + for (before, after) in view.elements.iter().zip(&out.elements) { + let (Some(p), Some(q)) = (position(before), position(after)) else { + continue; + }; + assert!( + ((q.0 - center.0) - (p.0 - center.0) * factor).abs() < 1e-6 + && ((q.1 - center.1) - (p.1 - center.1) * factor).abs() < 1e-6, + "{} moved {:?} -> {:?}, not a {factor}x scale about {center:?}", + degradation.name(), + p, + q + ); + } + assert_flows_attached(&out); + } +} + +#[test] +fn jitter_moves_only_free_nodes() { + let view = default_project_view("reliability"); + let out = degrade( + &view, + Degradation::Jitter { + amplitude: 40.0, + seed: 7, + }, + ) + .unwrap(); + let moved = moved_uids(&view, &out); + assert!(!moved.is_empty(), "jitter must move something"); + assert!( + moved.is_subset(&free_uids(&view)), + "jitter moved a backbone element: {:?}", + moved.difference(&free_uids(&view)).collect::>() + ); + assert_flows_attached(&out); +} + +#[test] +fn shuffle_permutes_free_node_positions() { + let view = default_project_view("reliability"); + let out = degrade(&view, Degradation::Shuffle { seed: 11 }).unwrap(); + let positions = |v: &StockFlow| { + let mut ps: Vec<(i64, i64)> = v + .elements + .iter() + .filter(|e| is_free_node(e)) + .filter_map(position) + .map(|(x, y)| ((x * 1000.0).round() as i64, (y * 1000.0).round() as i64)) + .collect(); + ps.sort(); + ps + }; + assert_eq!( + positions(&view), + positions(&out), + "a shuffle keeps the same set of free-node positions" + ); + assert!(!moved_uids(&view, &out).is_empty()); + assert!(moved_uids(&view, &out).is_subset(&free_uids(&view))); +} + +#[test] +fn exile_moves_one_parameter_outside_the_diagram() { + let view = default_project_view("population"); + let out = degrade(&view, Degradation::Exile).unwrap(); + let moved = moved_uids(&view, &out); + assert_eq!(moved.len(), 1, "exile moves exactly one node"); + let uid = *moved.iter().next().unwrap(); + assert!(free_uids(&view).contains(&uid)); + let maxx = view + .elements + .iter() + .filter_map(position) + .map(|p| p.0) + .fold(f64::NEG_INFINITY, f64::max); + let exiled = out.elements.iter().find(|e| e.get_uid() == uid).unwrap(); + assert!(position(exiled).unwrap().0 > maxx + 100.0); +} + +#[test] +fn stack_drops_one_free_node_on_another() { + let view = default_project_view("population"); + let out = degrade(&view, Degradation::Stack).unwrap(); + let free: Vec<(f64, f64)> = out + .elements + .iter() + .filter(|e| is_free_node(e)) + .filter_map(position) + .collect(); + let coincident = free.iter().enumerate().any(|(i, p)| { + free[i + 1..] + .iter() + .any(|q| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) + }); + assert!(coincident, "two free nodes must coincide after stacking"); + assert_eq!(moved_uids(&view, &out).len(), 1); +} + +#[test] +fn moving_nodes_preserves_each_arc_links_bow() { + let view = default_project_view("logistic-growth"); + let before = arc_offsets(&view); + assert!(!before.is_empty(), "fixture needs curved links"); + let out = degrade( + &view, + Degradation::Jitter { + amplitude: 40.0, + seed: 3, + }, + ) + .unwrap(); + let after = arc_offsets(&out); + for (uid, offset) in &before { + let new = after[uid]; + let diff = (new - offset).rem_euclid(360.0); + assert!( + diff < 1e-6 || (360.0 - diff) < 1e-6, + "link {uid} bow changed: {offset} -> {new}" + ); + } +} + +#[test] +fn straighten_turns_every_arc_straight() { + let view = default_project_view("logistic-growth"); + let out = degrade(&view, Degradation::StraightenLinks).unwrap(); + assert!( + out.elements + .iter() + .all(|e| !matches!(e, ViewElement::Link(l) if matches!(l.shape, LinkShape::Arc(_)))) + ); + assert!( + moved_uids(&view, &out).is_empty(), + "straightening moves no node" + ); +} + +#[test] +fn inapplicable_degradations_report_none() { + let empty = StockFlow { + name: None, + elements: vec![], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + }; + for degradation in Degradation::battery() { + assert!( + degrade(&empty, degradation).is_none(), + "{} applied to an empty view", + degradation.name() + ); + } +}