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,
- "{name} {value:.4} "
- );
- }
- let _ = write!(
- html,
- "weighted_cost {:.4} ",
- render.weighted_cost
- );
- html.push_str("
");
-}
-
-/// 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, "
");
- 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(
- "model baseline \
- candidate delta p significance ",
- );
- for m in &cmp.per_model {
- let (cls, verdict) = if m.significant {
- ("sig", "significant")
- } else {
- ("nonsig", "—")
- };
- let _ = write!(
- html,
- "{} {:.4} {:.4} \
- {} {:.4} \
- {verdict} ",
- html_escape(&m.model),
- m.baseline_median,
- m.candidate_median,
- fmt_delta_pct(m.delta_ratio),
- m.p_value,
- );
- }
- html.push_str("
\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("weights ");
- for (name, value) in weight_rows {
- let _ = write!(
- &mut html,
- "{name} {value:.4} "
- );
- }
- html.push_str("
\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, "{name} {value:.4} ");
+ 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, "{old:.4} ");
+ }
+ html.push_str(" ");
+ }
+ let _ = write!(
+ html,
+ "weighted_cost {:.4} ",
+ r.weighted_cost
+ );
+ if let Some(b) = before {
+ let _ = write!(html, "{:.4} ", b.weighted_cost);
+ }
+ html.push_str("
");
+}
+
+/// 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?) \
+
degradation references production ",
+ );
+ 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,
+ "{} {rn}/{ra} {pn}/{pa} ",
+ check.degradation,
+ class(rn, ra),
+ class(pn, pa),
+ );
+ }
+ html.push_str("
\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(
+ "
model before after \
+ delta p verdict ",
+ );
+ 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,
+ "{} {:.4} {:.4} \
+ {} {:.4} {verdict} ",
+ html_escape(&m.model),
+ m.baseline_median,
+ m.candidate_median,
+ fmt_delta_pct(m.delta_ratio),
+ m.p_value,
+ );
+ }
+ html.push_str("
\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("weights ");
+ for (name, value) in report.weights.terms() {
+ let _ = write!(
+ &mut html,
+ "{name} {value:.4} "
+ );
+ }
+ html.push_str("
\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