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

{}

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

seed {seed}

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

(not rendered)

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

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

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

Baseline diff

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

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

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

(no models matched the baseline)

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

Layout quality eval

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

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

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

{name}

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

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

", - model.median_cost, - model.spread.0, - model.spread.1, - model.best_of_k_cost, - model.m, - model.best_seed, - model.median_seed, - model.worst_seed, - ); - html.push_str("
"); - write_render_cell(&mut html, "reference", model.reference.as_ref()); - write_render_cell(&mut html, "best", model.best.as_ref()); - write_render_cell(&mut html, "median", model.median.as_ref()); - write_render_cell(&mut html, "worst", model.worst.as_ref()); - html.push_str("
\n"); - } - - html.push_str("\n\n"); - html -} - -// ── Baseline diff (imperative shell) ───────────────────────────────────────── - -/// Write `candidate` to the committed baseline JSON, replacing any existing -/// file. The full `CorpusReport` -- including each model's per-seed `samples` -- -/// is serialized so a later run can re-run Mann-Whitney U over the seed-sample -/// cost sets. On a serialize or write failure WARN to stderr (the run still -/// emits its `target/` artifacts; only the baseline re-seed failed). -fn write_baseline(candidate: &CorpusReport) { - let path = baseline_path(); - match serde_json::to_string_pretty(candidate) { - Ok(json) => match std::fs::write(&path, json) { - Ok(()) => println!( - "wrote baseline {path}\n\ - note: re-seed this baseline after the metric weights change." - ), - Err(err) => eprintln!("WARN: failed to write baseline {path}: {err}"), - }, - Err(err) => eprintln!("WARN: failed to serialize baseline: {err}"), - } -} - -/// Read and deserialize the committed baseline `CorpusReport`, if present. -/// -/// Returns `None` (with a one-line note) when the file does not exist -- the -/// expected state before a baseline has been seeded. A file that exists but -/// fails to read or parse is a real error: WARN with the cause and return `None` -/// so the run still emits its artifacts without a diff. -fn read_baseline() -> Option { - let path = baseline_path(); - let json = match std::fs::read_to_string(&path) { - Ok(json) => json, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - println!("no baseline; run with LAYOUT_EVAL_WRITE_BASELINE=1 to seed one."); - return None; - } - Err(err) => { - eprintln!("WARN: failed to read baseline {path}: {err}"); - return None; - } - }; - match serde_json::from_str::(&json) { - Ok(report) => Some(report), - Err(err) => { - eprintln!("WARN: failed to parse baseline {path}: {err}"); - None - } - } -} - -/// Print the baseline-vs-candidate diff to stdout: one line per matched model -/// (delta + p-value + significance) and an aggregate line. PURE-ish: reads -/// `cmp` and prints; kept in the shell because it does I/O (stdout). -fn print_comparison(cmp: &Comparison) { - println!("baseline diff (candidate vs baseline):"); - for m in &cmp.per_model { - let verdict = if m.significant { - "significant" - } else { - "not significant" - }; - println!( - " {}: delta={} p={:.4} ({verdict})", - m.model, - fmt_delta_pct(m.delta_ratio), - m.p_value, - ); - } - if cmp.per_model.is_empty() { - println!(" (no models matched the baseline)"); - } - let agg_verdict = if cmp.aggregate_significant { - "significant" - } else { - "not significant" - }; - println!( - " aggregate: delta={} p={:.4} ({agg_verdict})", - fmt_delta_pct(cmp.aggregate_delta_ratio), - cmp.aggregate_p_value, - ); -} - -/// Resolve the baseline diff for this run. -/// -/// When `LAYOUT_EVAL_WRITE_BASELINE` is set, (re)seed the committed baseline -/// from `candidate` and return `None` (a seeding run reports no diff -- there is -/// nothing yet to diff against). Otherwise read the committed baseline (if any), -/// run `compare(baseline, candidate)`, print the diff, and return it for -/// embedding in the artifacts. Absent baseline -> `None`. -fn resolve_baseline_diff(candidate: &CorpusReport) -> Option { - if write_baseline_requested() { - write_baseline(candidate); - return None; - } - let baseline = read_baseline()?; - let cmp = compare(&baseline, candidate); - print_comparison(&cmp); - Some(cmp) -} - -fn main() { - let keys = selected_keys(); - let m = seed_count(); - let seeds = seed_set(m); - let out = out_dir(); - - std::fs::create_dir_all(&out) - .unwrap_or_else(|e| panic!("failed to create output dir {out}: {e}")); - - let n_sampled = seeds.len(); - println!( - "layout_eval: {} model(s), M={m} seeds (sampling {n_sampled} unique), out={out}", - keys.len(), - ); - - // Per-model skip-on-failure (AC3.6): each model's full pipeline (load -> - // sweep -> render) is wrapped in `process_model`. ANY failure -- a load - // error, a layout that fails on every seed, etc. -- is WARN-logged and the - // sweep CONTINUES to the next model; the failed model is omitted from - // `per_model`/`renders` (and therefore from every artifact). The harness - // always reaches the end and exits 0, even if every model was skipped. - // - // `per_model` and `renders` stay positionally paired: both are pushed - // exactly once per surviving model, so the Task-4 report builder can zip - // them. - let mut per_model: Vec = Vec::new(); - let mut renders: Vec = Vec::new(); - let mut skipped = 0usize; - for spec in CORPUS.iter().filter(|s| keys.contains(&s.key)) { - match process_model(spec, &seeds, &out) { - Ok((stats, model_renders)) => { - per_model.push(stats); - renders.push(model_renders); - } - Err(err) => { - eprintln!("WARN: skipping {}: {err}", spec.key); - skipped += 1; - } - } - } - if skipped > 0 { - println!("skipped {skipped} model(s) (see WARN lines above)"); - } - - let corpus = CorpusReport::from_model_stats(per_model); - println!( - "corpus: aggregate_cost={:.4} ({} model(s) scored)", - corpus.aggregate_cost, - corpus.per_model.len(), - ); - - let with_reference = renders.iter().filter(|r| r.reference.is_some()).count(); - println!( - "corpus: {with_reference}/{} model(s) shipped a hand-authored reference view", - renders.len(), - ); - - // Either (re)seed the committed baseline from this run, or diff this run's - // report against the committed baseline (printing the per-model + aggregate - // deltas with Mann-Whitney p-values). The returned `Comparison` (if any) is - // embedded into both artifacts below. - let baseline_comparison = resolve_baseline_diff(&corpus); - - // Build the serializable report from the in-memory stats + renders, then - // emit both artifacts under the out dir (which defaults under the gitignored - // repo-root `target/`). `corpus.per_model` and `renders` are positionally - // paired -- both are pushed once per surviving model in the loop above. - let report = build_report( - &corpus.per_model, - &renders, - corpus.aggregate_cost, - &MetricWeights::default(), - baseline_comparison, - ); - - let metrics_path = format!("{out}/metrics.json"); - match serde_json::to_string_pretty(&report) { - Ok(json) => match std::fs::write(&metrics_path, json) { - Ok(()) => println!("wrote {metrics_path}"), - Err(err) => eprintln!("WARN: failed to write {metrics_path}: {err}"), - }, - Err(err) => eprintln!("WARN: failed to serialize metrics.json: {err}"), - } - - let index_path = format!("{out}/index.html"); - let html = render_index_html(&report); - match std::fs::write(&index_path, html) { - Ok(()) => println!("wrote {index_path}"), - Err(err) => eprintln!("WARN: failed to write {index_path}: {err}"), - } -} diff --git a/src/simlin-engine/examples/layout_eval/corpus.rs b/src/simlin-engine/examples/layout_eval/corpus.rs new file mode 100644 index 000000000..d5f70f409 --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/corpus.rs @@ -0,0 +1,393 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! The curated corpus: which models the sweep lays out, how far each model's +//! shipped diagram can be trusted as a taste anchor, and how models load. + +use std::io::BufReader; + +use simlin_engine::{datamodel, open_vensim, open_xmile}; + +/// The model name the layout pipeline and renderer operate on. `Project::get_model` +/// maps "main" to the single/main model (matching `tests/integration/layout.rs`). +pub const MAIN_MODEL: &str = "main"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Xmile, + Vensim, +} + +/// How far a model's shipped view can be trusted as a quality exemplar. +/// +/// A hand-drawn diagram is ground truth for arrangement, but the metric scores +/// the geometry OUR renderer draws. That matches the author's picture only when +/// the authoring tool draws the way we do, so the anchors are graded rather than +/// treated alike. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Reference { + /// One view authored in Stella or Simlin, whose conventions our renderer + /// reproduces (a named circle/box with a side label): its score is directly + /// comparable to a generated layout's. + Curated, + /// One view authored in Vensim. Its arrangement is a trustworthy exemplar, + /// but Vensim draws a variable AS its wrapped name (no circle, no side + /// label), so the label geometry our renderer imposes on it is not what the + /// author saw; label-dependent terms over it are not comparable. + Imported, + /// Several Vensim views the importer stacks into one diagram with group + /// boxes: an exemplar of decomposing a large model, not one comparable + /// diagram. + MultiView, + /// No shipped diagram: the model is only ever laid out by the generator. + None, +} + +/// Size class, for reading results and for running a cheap subset. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Tier { + /// Up to ~20 variables: a textbook model. + Small, + /// ~20-100 variables: what an agent or a student builds. + Medium, + /// Over ~100 variables: a published research model. + Large, +} + +impl Tier { + pub fn parse(s: &str) -> Option { + match s { + "small" => Some(Tier::Small), + "medium" => Some(Tier::Medium), + "large" => Some(Tier::Large), + _ => None, + } + } +} + +/// One corpus entry. Paths are relative to `CARGO_MANIFEST_DIR` +/// (`src/simlin-engine`) unless absolute. +#[derive(Clone, Debug)] +pub struct ModelSpec { + pub key: String, + pub path: String, + pub format: Format, + pub tier: Tier, + pub reference: Reference, +} + +struct Entry { + key: &'static str, + rel_path: &'static str, + format: Format, + tier: Tier, + reference: Reference, +} + +use Format::{Vensim, Xmile}; +use Reference::{Curated, Imported, MultiView}; +use Tier::{Large, Medium, Small}; + +/// The curated corpus. Every entry is verified to exist on disk and load; each +/// earns its place by exercising something the others do not. +const CORPUS: &[Entry] = &[ + // Textbook small models. + Entry { + key: "teacup", + rel_path: "../../test/test-models/samples/teacup/teacup.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "sir", + rel_path: "../../test/test-models/samples/SIR/SIR.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // default_projects: the app's curated, hand-laid-out built-in projects, the + // primary taste anchors (and the reference-pair tests' fixtures). + Entry { + key: "logistic_growth", + rel_path: "../../default_projects/logistic-growth/model.xmile", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "population", + rel_path: "../../default_projects/population/model.xmile", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "fishbanks", + rel_path: "../../default_projects/fishbanks/model.xmile", + format: Xmile, + tier: Medium, + reference: Curated, + }, + Entry { + key: "reliability", + rel_path: "../../default_projects/reliability/model.xmile", + format: Xmile, + tier: Medium, + reference: Curated, + }, + // Small and medium single-view Vensim models: the hand arrangement is the + // exemplar (chains in rows, parameters beside their consumers). + Entry { + key: "lotka_volterra", + rel_path: "../../test/test-models/samples/Lotka_Volterra/Lotka_Volterra.mdl", + format: Vensim, + tier: Small, + reference: Imported, + }, + Entry { + key: "workforce", + rel_path: "../../test/test-models/samples/Workforce/workforce.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + Entry { + key: "bathtub", + rel_path: "../../test/metasd/bathtub-statistics/integration3.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + Entry { + key: "catastrophe", + rel_path: "../../test/metasd/early-warnings-catastrophe/catastropeWarning2.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + Entry { + key: "groupon", + rel_path: "../../test/metasd/social-network-valuation/groupon 1.mdl", + format: Vensim, + tier: Medium, + reference: Imported, + }, + // Delay and smooth structures (implicit modules behind builtins). + Entry { + key: "delays", + rel_path: "../../test/delays/model.xmile", + format: Xmile, + tier: Small, + reference: Curated, + }, + // No shipped diagram: laid out only by the generator, like a model an agent + // builds from equations. + Entry { + key: "arms_race", + rel_path: "../../test/arms_race_3party/arms_race.stmx", + format: Xmile, + tier: Small, + reference: Reference::None, + }, + // Modules. + Entry { + key: "hares_and_foxes", + rel_path: "../../test/modules_hares_and_foxes/modules_hares_and_foxes.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "ai_modules_arrays", + rel_path: "../../test/ai-information/WithModulesAndArrays.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // Aliases. + Entry { + key: "alias1", + rel_path: "../../test/alias1/alias1.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // Arrays and several flows into one stock. + Entry { + key: "cross_element", + rel_path: "../../test/cross_element_ltm/cross_element.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + Entry { + key: "arrayed_pop", + rel_path: "../../test/arrayed_population_ltm/arrayed_population.stmx", + format: Xmile, + tier: Small, + reference: Curated, + }, + // An AI-generated model a human then edited: the kind of model the MCP + // server lays out. + Entry { + key: "ai_edited", + rel_path: "../../test/ai-information/GeneratedByAIThenEdited.stmx", + format: Xmile, + tier: Large, + reference: Curated, + }, + // Large published Vensim models. + Entry { + key: "wrld3_03", + rel_path: "../../test/metasd/WRLD3-03/wrld3-03.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "beer_game", + rel_path: "../../test/metasd/beer-game/RealBeer4-Sterman13.mdl", + format: Vensim, + tier: Medium, + reference: MultiView, + }, + Entry { + key: "wonderland", + rel_path: "../../test/metasd/wonderland/Wonderland3.mdl", + format: Vensim, + tier: Medium, + reference: MultiView, + }, + Entry { + key: "mortgage_econ", + rel_path: "../../test/bobby/vdf/econ/mark2.mdl", + format: Vensim, + tier: Medium, + reference: MultiView, + }, + Entry { + key: "land_use", + rel_path: "../../test/land_model/land_model.stmx", + format: Xmile, + tier: Large, + reference: MultiView, + }, + // Multi-view Vensim: hand-authored references that decompose a big model + // into ~25-50-variable views connected by ghost variables -- the exemplars + // of what a readable layout of a large model looks like. + Entry { + key: "scirev", + rel_path: "../../test/metasd/scientific-revolution/scirev8.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "thyroid", + rel_path: "../../test/metasd/thyroid-dynamics/thyroid-2008-d.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "covid19", + rel_path: "../../test/metasd/covid19-us-homer/homer v8/Covid19US v8.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, + Entry { + key: "industrial_dynamics", + rel_path: "../../test/metasd/industrial-dynamics/IDch15/IDch15d.mdl", + format: Vensim, + tier: Large, + reference: MultiView, + }, +]; + +/// The corpus as owned specs, followed by any ad-hoc `extra` entries +/// (`(key, path)` pairs from `LAYOUT_EVAL_EXTRA`). An extra's format comes from +/// its extension; it is filed as a medium model whose reference (if it ships +/// one) is graded by format, since nothing more is known about it. +pub fn all_specs(extra: &[(String, String)]) -> Vec { + let mut specs: Vec = CORPUS + .iter() + .map(|e| ModelSpec { + key: e.key.to_string(), + path: e.rel_path.to_string(), + format: e.format, + tier: e.tier, + reference: e.reference, + }) + .collect(); + for (key, path) in extra { + let format = if path.to_ascii_lowercase().ends_with(".mdl") { + Format::Vensim + } else { + Format::Xmile + }; + specs.push(ModelSpec { + key: key.clone(), + path: path.clone(), + format, + tier: Tier::Medium, + reference: match format { + Format::Xmile => Reference::Curated, + Format::Vensim => Reference::Imported, + }, + }); + } + specs +} + +/// Resolve a spec path: absolute paths as given, relative ones against the +/// crate manifest dir. +fn abs_path(path: &str) -> String { + if std::path::Path::new(path).is_absolute() { + path.to_string() + } else { + format!("{}/{}", env!("CARGO_MANIFEST_DIR"), path) + } +} + +/// Load one corpus model, dispatching on its declared format. Returns a +/// human-readable error on any I/O or parse failure so the caller can +/// WARN-and-skip. +pub fn load_model(spec: &ModelSpec) -> Result { + let path = abs_path(&spec.path); + match spec.format { + Format::Xmile => { + let file = + std::fs::File::open(&path).map_err(|e| format!("failed to open {path}: {e}"))?; + let mut reader = BufReader::new(file); + open_xmile(&mut reader).map_err(|e| format!("failed to parse {path}: {e:?}")) + } + Format::Vensim => { + let contents = std::fs::read_to_string(&path) + .map_err(|e| format!("failed to read {path}: {e}"))?; + open_vensim(&contents).map_err(|e| format!("failed to parse {path}: {e:?}")) + } + } +} + +/// Borrow the model's as-loaded main `StockFlow` view if it is a non-empty +/// hand-authored diagram; `None` when the model ships no diagram. +pub fn reference_view(project: &datamodel::Project) -> Option<&datamodel::StockFlow> { + let model = project.get_model(MAIN_MODEL)?; + match model.views.first() { + Some(datamodel::View::StockFlow(sf)) if !sf.elements.is_empty() => Some(sf), + _ => None, + } +} + +/// Number of model variables, the size the tiers describe. +pub fn variable_count(project: &datamodel::Project) -> usize { + project + .get_model(MAIN_MODEL) + .map(|m| m.variables.len()) + .unwrap_or(0) +} diff --git a/src/simlin-engine/examples/layout_eval/knobs.rs b/src/simlin-engine/examples/layout_eval/knobs.rs new file mode 100644 index 000000000..b60bf5c0c --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/knobs.rs @@ -0,0 +1,104 @@ +// 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; + +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, +} + +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" + ), + } + } +} 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..0c1f28c1a --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/main.rs @@ -0,0 +1,306 @@ +// 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), +//! and render the hand-authored reference, the production layout, 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 +//! +//! 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 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 renders = ModelRenders { + reference, + production: production_render, + 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(), + ); + + // 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), + 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/report.rs b/src/simlin-engine/examples/layout_eval/report.rs new file mode 100644 index 000000000..3265ba87d --- /dev/null +++ b/src/simlin-engine/examples/layout_eval/report.rs @@ -0,0 +1,558 @@ +// 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 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, + /// 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 taste_reference: Vec, + #[serde(default)] + pub taste_production: Vec, + pub reference: Option, + pub production: 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, + 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), + median: render.median.as_ref().map(render_report), + worst: render.worst.as_ref().map(render_report), + }) + .collect(); + models.sort_by(|a, b| { + b.median_cost + .partial_cmp(&a.median_cost) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.model.cmp(&b.model)) + }); + EvalReport { + models, + aggregate_cost, + weights: *weights, + baseline_comparison, + run_comparison, + } +} + +/// HTML-escape the five characters special in element text or attribute +/// values. Model keys and filenames are static, so this is defense in depth. +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn fmt_delta_pct(ratio: f64) -> String { + format!("{:+.2}%", ratio * 100.0) +} + +/// One render's cell: heading, a thumbnail linking to the full-size PNG, and +/// the per-term breakdown. `before` is the same render kind from the compared +/// run, when present, whose terms are shown alongside. PURE. +fn write_render_cell( + html: &mut String, + kind: &str, + render: Option<&RenderReport>, + before: Option<&RenderReport>, +) { + html.push_str("
"); + let _ = write!(html, "

{}

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

(not rendered)

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

seed {seed}

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

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

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

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

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

{}

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

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

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

Layout quality eval

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

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

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

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

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

{}

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

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

", + html_escape(&model.tier), + model.variables, + html_escape(&model.reference_kind), + model.median_cost, + model.spread.0, + model.spread.1, + model.best_of_k_cost, + model.m, + ); + html.push_str("
"); + write_render_cell(&mut html, "reference", model.reference.as_ref(), None); + write_render_cell( + &mut html, + "production", + model.production.as_ref(), + prior.and_then(|p| p.production.as_ref()), + ); + write_render_cell( + &mut html, + "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..5ec73f6cc 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.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 1, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 2, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 3, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 4, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 5, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 6, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 7, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 42, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 123, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 456, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 }, { "seed": 789, "metrics": { - "node_overlap": 0.03901734104046243, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.15391387036044574, "crossings": 0.0, - "sprawl": 0.7932025869461911, + "crowding": 0.09453125, + "sprawl": 0.8448978029221074, + "long_connectors": 0.0, "edge_length_cv": 0.2610911829947331, "aspect_penalty": 0.13544536271809005, - "chain_straightness": 0.0, + "misalignment": 0.4, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19765785842970066 + "weighted_cost": 0.5766265062711955 } ], - "median_cost": 0.19765785842970066, + "median_cost": 0.5766265062711955, "spread": [ - 0.19765785842970066, - 0.19765785842970066 + 0.5766265062711955, + 0.5766265062711955 ], - "best_of_k_cost": 0.19765785842970066, + "best_of_k_cost": 0.5766265062711955, "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.2948298473219633, "crossings": 0.0, - "sprawl": 0.8836152259425175, + "crowding": 0.1215850476744519, + "sprawl": 0.9403348818139852, + "long_connectors": 0.0, "edge_length_cv": 0.37369814882506985, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4444444444444444, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.1767230451885035 + "weighted_cost": 0.8433579835553376 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.033944624958484985, + "node_connector_overlap": 0.02520424416376052, "label_overlap": 0.0, - "crossings": 0.2222222222222222, - "sprawl": 0.6209866914411803, - "edge_length_cv": 0.5730547821116815, - "aspect_penalty": 2.5436507936507935, - "chain_straightness": 0.0, + "label_connector_overlap": 0.1935026950692871, + "crossings": 0.0, + "crowding": 0.07275131307248045, + "sprawl": 1.0062128462450983, + "long_connectors": 0.0, + "edge_length_cv": 0.5121229757061013, + "aspect_penalty": 1.8270609318996418, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3803641854689433 + "weighted_cost": 0.7024670555652067 }, { "seed": 2, @@ -261,16 +303,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.07111960153152644, "crossings": 0.0, - "sprawl": 0.760183886538782, + "crowding": 0.13037109375, + "sprawl": 0.8141553731978302, + "long_connectors": 0.0, "edge_length_cv": 0.2921899080548419, "aspect_penalty": 1.3826699834162524, - "chain_straightness": 0.0, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.15203677730775642 + "weighted_cost": 0.4530893393467472 }, { "seed": 3, @@ -278,16 +323,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.25, "crossings": 0.0, - "sprawl": 0.8620670400074891, + "crowding": 0.13037109375, + "sprawl": 0.9231935277246567, + "long_connectors": 0.0, "edge_length_cv": 0.29840579737450973, "aspect_penalty": 1.1242090651822654, - "chain_straightness": 0.0, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.17241340800149785 + "weighted_cost": 0.7486694756811642 }, { "seed": 4, @@ -295,16 +343,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.0691303619201131, "crossings": 0.2222222222222222, - "sprawl": 0.8419573080245638, + "crowding": 0.13037109375, + "sprawl": 0.9020133742274014, + "long_connectors": 0.0, "edge_length_cv": 0.2070469549586528, "aspect_penalty": 1.2046165884194053, - "chain_straightness": 0.0, + "misalignment": 0.125, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.390613683827135 + "weighted_cost": 0.6942922024092422 }, { "seed": 5, @@ -312,16 +363,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.009298152814814695, "label_overlap": 0.0, + "label_connector_overlap": 0.125, "crossings": 0.0, - "sprawl": 0.9463824608199494, + "crowding": 0.13862875846791572, + "sprawl": 1.0132963898058205, + "long_connectors": 0.0, "edge_length_cv": 0.356464037268458, "aspect_penalty": 0.6148775894538607, - "chain_straightness": 0.0, + "misalignment": 0.25, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.1985746449788046 + "weighted_cost": 0.6230491615490003 }, { "seed": 6, @@ -329,16 +383,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1907454395911714, "crossings": 0.0, - "sprawl": 0.8821366289829145, + "crowding": 0.07520194644223477, + "sprawl": 0.9501600542589955, + "long_connectors": 0.0, "edge_length_cv": 0.3078393138268285, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.1764273257965829 + "weighted_cost": 0.6363601193937407 }, { "seed": 7, @@ -346,33 +403,39 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.2739916736727163, "crossings": 0.0, - "sprawl": 0.9629366509286451, + "crowding": 0.13037109375, + "sprawl": 1.0334338602539141, + "long_connectors": 0.0, "edge_length_cv": 0.16736351313364584, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.19258733018572904 + "weighted_cost": 0.837217069322553 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0034061861475130606, + "node_connector_overlap": 0.031265080610215934, "label_overlap": 0.0, - "crossings": 0.1111111111111111, - "sprawl": 0.6712318321999149, - "edge_length_cv": 0.46807026723224493, + "label_connector_overlap": 0.06521559058989596, + "crossings": 0.0, + "crowding": 0.20286226336123286, + "sprawl": 0.7223902724571235, + "long_connectors": 0.0, + "edge_length_cv": 0.4824379787075562, "aspect_penalty": 1.81120527306968, - "chain_straightness": 0.0, + "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.24876366369860714 + "weighted_cost": 0.5813133785807895 }, { "seed": 123, @@ -380,16 +443,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.01952424960676582, "label_overlap": 0.0, + "label_connector_overlap": 0.2333398399384502, "crossings": 0.0, - "sprawl": 1.0385011725559112, + "crowding": 0.1228298611111111, + "sprawl": 1.1043525279334245, + "long_connectors": 0.0, "edge_length_cv": 0.42562431304846177, "aspect_penalty": 0.252476506004256, - "chain_straightness": 0.0, + "misalignment": 0.4444444444444444, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22722448411794807 + "weighted_cost": 0.8324206966601185 }, { "seed": 456, @@ -397,16 +463,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.12916253333703215, "crossings": 0.0, - "sprawl": 0.9963435841418137, + "crowding": 0.13037109375, + "sprawl": 1.0681071015556278, + "long_connectors": 0.0, "edge_length_cv": 0.4106297937365929, "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.6161416691444552 }, { "seed": 789, @@ -414,27 +483,30 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.35969284630365944, "crossings": 0.0, - "sprawl": 1.397541275395428, - "edge_length_cv": 0.6092927845061417, - "aspect_penalty": 0.16173620863749694, - "chain_straightness": 0.0, + "crowding": 0.007432519743273804, + "sprawl": 1.596148146962461, + "long_connectors": 0.02062705020281728, + "edge_length_cv": 0.6115506444639912, + "aspect_penalty": 0.16218853680218803, + "misalignment": 0.4444444444444444, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2795082550790856 + "weighted_cost": 1.0007667954852313 } ], - "median_cost": 0.19892168090358367, + "median_cost": 0.6983796289872244, "spread": [ - 0.17664911534052335, - 0.25644981154372676 + 0.621322288447864, + 0.8336197898257272 ], - "best_of_k_cost": 0.19926871682836275, + "best_of_k_cost": 0.5813133785807895, "best_seed": 2, - "median_seed": 5, - "worst_seed": 4 + "median_seed": 4, + "worst_seed": 789 }, { "model": "logistic_growth", @@ -442,2182 +514,2796 @@ { "seed": 0, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.38245730181224497, "crossings": 0.0, - "sprawl": 1.0695290083991442, + "crowding": 0.013950892857142858, + "sprawl": 1.1178229316949528, + "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": 1.078259086826967 }, { "seed": 1, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.25521157043525533, "crossings": 0.0, - "sprawl": 1.0395652790471699, + "crowding": 0.022879464285714284, + "sprawl": 1.0877982593089472, + "long_connectors": 0.0, "edge_length_cv": 0.09779512391099372, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2857142857142857, "loop_compactness": 0.24567843839715708, "flow_bends": 0.0, "loop_straightness": 0.25920663156109086 }, - "weighted_cost": 0.3658550943244059 + "weighted_cost": 0.8304098518522346 }, { "seed": 2, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.3876424186792966, "crossings": 0.0, - "sprawl": 1.1092486763864682, + "crowding": 0.013950892857142858, + "sprawl": 1.1607292210088769, + "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": 1.1110471582368588 }, { "seed": 3, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.32564761879314696, "crossings": 0.0, - "sprawl": 1.2132131759638447, + "crowding": 0.014274598037490703, + "sprawl": 1.269465583427676, + "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": 1.0097107686522917 }, { "seed": 4, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.2555691731188538, "crossings": 0.0, - "sprawl": 1.0808038959563016, + "crowding": 0.013950892857142858, + "sprawl": 1.1298081089123353, + "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.8838290419233146 }, { "seed": 5, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.3850476579985305, "crossings": 0.14285714285714285, - "sprawl": 1.1935142282151967, + "crowding": 0.013950892857142858, + "sprawl": 1.2489029774889713, + "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": 1.3498798034914392 }, { "seed": 6, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.3498340153770081, "crossings": 0.0, - "sprawl": 1.207703269013744, + "crowding": 0.013950892857142858, + "sprawl": 1.2637262777866718, + "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": 1.0989482258041408 }, { "seed": 7, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.3203350053786094, "crossings": 0.0, - "sprawl": 1.2206273000422716, + "crowding": 0.013950892857142858, + "sprawl": 1.2772085690905233, + "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": 1.0145335529051012 }, { "seed": 42, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.32469743123799194, "crossings": 0.0, - "sprawl": 1.0977413618095229, + "crowding": 0.013950892857142858, + "sprawl": 1.1486919182742106, + "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.9852862890019948 }, { "seed": 123, "metrics": { - "node_overlap": 0.033750000000000016, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.32936012992236524, "crossings": 0.0, - "sprawl": 1.165857662264709, + "crowding": 0.013950892857142858, + "sprawl": 1.2198374741517137, + "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": 1.0396984774095617 }, { "seed": 456, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.30542344094981405, "crossings": 0.0, - "sprawl": 1.001103334507119, + "crowding": 0.022879464285714284, + "sprawl": 1.0457329201817067, + "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": 1.0296547389156119 }, { "seed": 789, "metrics": { - "node_overlap": 0.03375, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.41388461715977015, "crossings": 0.0, - "sprawl": 1.009579521376098, + "crowding": 0.035521352868755664, + "sprawl": 1.056463100037717, + "long_connectors": 0.0, "edge_length_cv": 0.26640944699663793, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2857142857142857, "loop_compactness": 0.32260826169216406, "flow_bends": 0.0, "loop_straightness": 0.24558496226773144 }, - "weighted_cost": 0.3892677051788584 + "weighted_cost": 1.1026372830929074 } ], - "median_cost": 0.4224884762067875, + "median_cost": 1.0346766081625867, "spread": [ - 0.4004696607005702, - 0.4430106566044477 + 1.0036046487397174, + 1.0998704901263325 ], - "best_of_k_cost": 0.3892677051788584, + "best_of_k_cost": 0.9852862890019948, "best_seed": 1, - "median_seed": 3, + "median_seed": 456, "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.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.19330921253666428, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.306357982190828, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927968, + "aspect_penalty": 0.3511273855119561, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.634408856435263 + } + ], + "median_cost": 0.634408856435263, + "spread": [ + 0.634408856435263, + 0.634408856435263 + ], + "best_of_k_cost": 0.634408856435263, + "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, - "crossings": 0.3333333333333333, - "sprawl": 1.6143573398683797, - "edge_length_cv": 0.43495266699420937, + "node_overlap": 0.0, + "node_connector_overlap": 0.03997195553746291, + "label_overlap": 0.0, + "label_connector_overlap": 0.2297491595217757, + "crossings": 0.2, + "crowding": 0.054327058469548176, + "sprawl": 2.245355916163957, + "long_connectors": 0.0, + "edge_length_cv": 0.4249382812737304, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8452303428015734, + "misalignment": 0.4117647058823529, + "loop_compactness": 0.9369490344714397, "flow_bends": 0.0, - "loop_straightness": 0.4630819207904695 + "loop_straightness": 0.5668313546673985 }, - "weighted_cost": 1.2021583573915746 + "weighted_cost": 1.712872907711678 }, { "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.28722220990213276, "crossings": 0.13333333333333333, - "sprawl": 1.3590826636926427, + "crowding": 0.17647058823529413, + "sprawl": 1.4217194848058616, + "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.5231829860848687 }, { "seed": 2, "metrics": { - "node_overlap": 0.034763948497854066, + "node_overlap": 0.0, "node_connector_overlap": 0.006884632504338363, - "label_overlap": 0.04110504804875194, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.13618306151946574, "crossings": 0.06666666666666667, - "sprawl": 1.4104035925158442, + "crowding": 0.17647058823529413, + "sprawl": 1.474972224139633, + "long_connectors": 0.0, "edge_length_cv": 0.39531305968667735, "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": 1.1659570583319872 }, { "seed": 3, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.005547168048489169, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.24916127495893364, "crossings": 0.13333333333333333, - "sprawl": 1.6164780320802155, + "crowding": 0.1803147611697808, + "sprawl": 1.6904032611073978, + "long_connectors": 0.0, "edge_length_cv": 0.6184917294273167, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.3529411764705882, "loop_compactness": 0.38449846215069705, "flow_bends": 0.0, "loop_straightness": 0.3205882211495155 }, - "weighted_cost": 0.723903311319702 + "weighted_cost": 1.3367122425572828 }, { "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.28722220990213276, "crossings": 0.13333333333333333, - "sprawl": 1.3590826636926427, + "crowding": 0.17647058823529413, + "sprawl": 1.4217194848058616, + "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.5231829860848687 }, { "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.2338678534851391, "crossings": 0.13333333333333333, - "sprawl": 1.3246103376847838, - "edge_length_cv": 0.42943805128756574, + "crowding": 0.17647058823529413, + "sprawl": 1.4087922337425303, + "long_connectors": 0.0, + "edge_length_cv": 0.40037381115878795, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.3594978082239031, + "misalignment": 0.23529411764705888, + "loop_compactness": 0.37192154364966035, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 0.6074582966259526 }, - "weighted_cost": 0.6845901873731239 + "weighted_cost": 1.2514167148357636 }, { "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.28543964821675927, "crossings": 0.2, - "sprawl": 1.6575940164596439, + "crowding": 0.17647058823529413, + "sprawl": 1.7336729895308924, + "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.60334693308102 }, { "seed": 7, "metrics": { - "node_overlap": 0.03476394849785408, + "node_overlap": 0.0, "node_connector_overlap": 0.01586118306043599, - "label_overlap": 0.04110504804875194, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.32345482875933973, "crossings": 0.06666666666666667, - "sprawl": 1.5989740024432533, + "crowding": 0.17914302463001489, + "sprawl": 1.67267387675816, + "long_connectors": 0.0, "edge_length_cv": 0.45338724107118894, "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.4893439898204528 }, { "seed": 42, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.01896177280711285, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.01896310646224659, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.28110790015332676, "crossings": 0.13333333333333333, - "sprawl": 1.4147746629675255, - "edge_length_cv": 0.3704016124212521, + "crowding": 0.21218486357093913, + "sprawl": 1.479629582520823, + "long_connectors": 0.0, + "edge_length_cv": 0.36996658552386863, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7149476673872288, + "misalignment": 0.4117647058823529, + "loop_compactness": 0.709864853181061, "flow_bends": 0.0, - "loop_straightness": 0.4478158538908568 + "loop_straightness": 0.4437347416119741 }, - "weighted_cost": 0.8418796876245346 + "weighted_cost": 1.5500786374274482 }, { "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.016996776375112654, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.18406504429001286, + "crossings": 0.13333333333333333, + "crowding": 0.18382352941176472, + "sprawl": 1.6666592240736395, + "long_connectors": 0.0, + "edge_length_cv": 0.5179964575979691, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8652906278835424, + "misalignment": 0.3529411764705882, + "loop_compactness": 0.8709647629948463, "flow_bends": 0.0, - "loop_straightness": 0.3184356721684644 + "loop_straightness": 0.3561572024324124 }, - "weighted_cost": 0.8552429030482476 + "weighted_cost": 1.4687776267536208 }, { "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.28722220990213276, "crossings": 0.13333333333333333, - "sprawl": 1.3590826636926427, + "crowding": 0.17647058823529413, + "sprawl": 1.4217194848058616, + "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.5231829860848687 }, { "seed": 789, "metrics": { - "node_overlap": 0.03476394849785408, - "node_connector_overlap": 0.011711803570043349, - "label_overlap": 0.04110504804875194, + "node_overlap": 0.0, + "node_connector_overlap": 0.00896593212003968, + "label_overlap": 0.001591170204751304, + "label_connector_overlap": 0.186137609443811, "crossings": 0.2, - "sprawl": 1.6603987448123414, + "crowding": 0.17647058823529413, + "sprawl": 1.7365983068126392, + "long_connectors": 0.0, "edge_length_cv": 0.4317694697031315, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2941176470588235, "loop_compactness": 0.7334237377417374, "flow_bends": 0.0, "loop_straightness": 0.2477220589807632 }, - "weighted_cost": 0.9378022500738888 + "weighted_cost": 1.460881004761533 } ], - "median_cost": 0.8631186313138057, + "median_cost": 1.5062634879526606, "spread": [ - 0.7454216775517315, - 0.8829835131859658 + 1.4298388142104705, + 1.5299068989205136 ], - "best_of_k_cost": 0.8418796876245346, - "best_seed": 5, - "median_seed": 1, + "best_of_k_cost": 1.460881004761533, + "best_seed": 2, + "median_seed": 7, "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.008279104282179942, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0695290083991442, - "edge_length_cv": 0.17217974049680632, + "label_connector_overlap": 0.45522548426241727, + "crossings": 0.13043478260869565, + "crowding": 0.03409152100595501, + "sprawl": 1.3514239876520624, + "long_connectors": 0.0, + "edge_length_cv": 0.35823152843349637, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.31744430207475827, + "misalignment": 0.25, + "loop_compactness": 0.5672715847487961, "flow_bends": 0.0, - "loop_straightness": 0.2704593035495808 + "loop_straightness": 0.28562289053676365 }, - "weighted_cost": 0.4016794528646902 + "weighted_cost": 1.4822496584388467 }, { "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.47523878287413235, + "crossings": 0.08695652173913043, + "crowding": 0.033705510904926114, + "sprawl": 1.3536996175384342, + "long_connectors": 0.0, + "edge_length_cv": 0.41197926925448225, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.24567843839715708, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.5099253512543144, "flow_bends": 0.0, - "loop_straightness": 0.25920663156109086 + "loop_straightness": 0.2763249943391913 }, - "weighted_cost": 0.3658550943244059 + "weighted_cost": 1.4452144179421753 }, { "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.5103000856958216, + "crossings": 0.13043478260869565, + "crowding": 0.038248697916666664, + "sprawl": 1.242537298018221, + "long_connectors": 0.0, + "edge_length_cv": 0.34985659343977915, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.34827343583463755, + "misalignment": 0.375, + "loop_compactness": 0.5880644701143496, "flow_bends": 0.0, - "loop_straightness": 0.28998100631839646 + "loop_straightness": 0.323909822740439 }, - "weighted_cost": 0.42390721024298833 + "weighted_cost": 1.5498847038934338 }, { "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.4839538756492726, + "crossings": 0.13043478260869565, + "crowding": 0.030436197916666668, + "sprawl": 1.2625746708820178, + "long_connectors": 0.0, + "edge_length_cv": 0.39247217657047684, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.29443278938398476, + "misalignment": 0.375, + "loop_compactness": 0.5879669671422784, "flow_bends": 0.0, - "loop_straightness": 0.28968087957424843 + "loop_straightness": 0.3814647471849522 }, - "weighted_cost": 0.4231338389037877 + "weighted_cost": 1.5132787232951823 }, { "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.45609373650081714, + "crossings": 0.17391304347826086, + "crowding": 0.03520593869346284, + "sprawl": 1.4414867201375652, + "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.6165226003112538 }, { "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.4478260993701819, + "crossings": 0.13043478260869565, + "crowding": 0.03564453124999981, + "sprawl": 1.227002426202059, + "long_connectors": 0.0, + "edge_length_cv": 0.3367031327479818, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6135303257902378, + "misalignment": 0.375, + "loop_compactness": 0.5874886731720569, "flow_bends": 0.0, - "loop_straightness": 0.292909775195911 + "loop_straightness": 0.3353784035130565 }, - "weighted_cost": 0.6900130963358684 + "weighted_cost": 1.4506023790846114 }, { "seed": 6, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.006441756573366715, "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.45049457310186675, + "crossings": 0.13043478260869565, + "crowding": 0.031087239583333332, + "sprawl": 1.4251170459695213, + "long_connectors": 0.0, + "edge_length_cv": 0.49904304449548154, + "aspect_penalty": 0.004879319457522735, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.6530513344789997, "flow_bends": 0.0, - "loop_straightness": 0.29753287145471186 + "loop_straightness": 0.4506421126685112 }, - "weighted_cost": 0.4624625370947095 + "weighted_cost": 1.5543780682090604 }, { "seed": 7, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.00767625389432729, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2206273000422716, - "edge_length_cv": 0.1631996331385811, + "label_connector_overlap": 0.4816322285980764, + "crossings": 0.21739130434782608, + "crowding": 0.027959192374642543, + "sprawl": 1.426696876584905, + "long_connectors": 0.0, + "edge_length_cv": 0.4040249217858767, "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.6626693823626897 }, { "seed": 42, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.017777328969124467, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0977413618095229, - "edge_length_cv": 0.1935296097191283, + "label_connector_overlap": 0.3984603017501767, + "crossings": 0.21739130434782608, + "crowding": 0.0365604125328968, + "sprawl": 1.3446045008003582, + "long_connectors": 0.005678356215624773, + "edge_length_cv": 0.5260018856550409, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.35644245450619727, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.6338290885840142, "flow_bends": 0.0, - "loop_straightness": 0.2596785934540375 + "loop_straightness": 0.34112832371805724 }, - "weighted_cost": 0.42184311350978726 + "weighted_cost": 1.5471649318908836 }, { "seed": 123, "metrics": { - "node_overlap": 0.033750000000000016, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.003099928440121134, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.165857662264709, - "edge_length_cv": 0.15792800546333421, + "label_connector_overlap": 0.474945226869136, + "crossings": 0.13043478260869565, + "crowding": 0.032027043502712536, + "sprawl": 1.2762442105738219, + "long_connectors": 0.0, + "edge_length_cv": 0.4623845645928669, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.35449169623420484, + "misalignment": 0.29166666666666663, + "loop_compactness": 0.5334016591520779, "flow_bends": 0.0, - "loop_straightness": 0.2780848549440337 + "loop_straightness": 0.3520393790164842 }, - "weighted_cost": 0.4365266964410271 + "weighted_cost": 1.4778718441679561 }, { "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.4478260993701819, + "crossings": 0.13043478260869565, + "crowding": 0.03564453124999981, + "sprawl": 1.227002426202059, + "long_connectors": 0.0, + "edge_length_cv": 0.3367031327479818, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5224671471393787, + "misalignment": 0.375, + "loop_compactness": 0.5874886731720569, "flow_bends": 0.0, - "loop_straightness": 0.21077167161141264 + "loop_straightness": 0.3353784035130565 }, - "weighted_cost": 0.46403469291831656 + "weighted_cost": 1.4506023790846114 }, { "seed": 789, "metrics": { - "node_overlap": 0.03375, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.018119308363776246, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.009579521376098, - "edge_length_cv": 0.26640944699663793, + "label_connector_overlap": 0.49772083698144276, + "crossings": 0.17391304347826086, + "crowding": 0.027863607870441662, + "sprawl": 1.5674991792532653, + "long_connectors": 0.0, + "edge_length_cv": 0.5612590549553415, "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.720680525255204 } ], - "median_cost": 0.4224884762067875, + "median_cost": 1.5302218275930328, "spread": [ - 0.4004696607005702, - 0.4430106566044477 + 1.47105447789712, + 1.5699142012346088 ], - "best_of_k_cost": 0.3892677051788584, + "best_of_k_cost": 1.4506023790846114, "best_seed": 1, "median_seed": 3, - "worst_seed": 5 + "worst_seed": 789 }, { - "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.023437544936108005, "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.30923899201764815, + "crossings": 0.1, + "crowding": 0.0005486896484388827, + "sprawl": 1.3688801775401178, + "long_connectors": 0.0, + "edge_length_cv": 0.3330810559825201, + "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.9985023119321567 }, { "seed": 1, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.022921332894019143, "label_overlap": 0.0, + "label_connector_overlap": 0.34857898128243503, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3916468939981192, + "long_connectors": 0.0, + "edge_length_cv": 0.3647254692177101, + "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.9616228612112206 }, { "seed": 2, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.023749214382369373, "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.35932343575343917, + "crossings": 0.05, + "crowding": 0.0, + "sprawl": 1.3516042938761283, + "long_connectors": 0.0, + "edge_length_cv": 0.35850350782526264, + "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": 1.0143846558639296 }, { "seed": 3, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.022944846834058216, "label_overlap": 0.0, + "label_connector_overlap": 0.3265886602348098, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3903049591920005, + "long_connectors": 0.0, + "edge_length_cv": 0.35907392781953873, + "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.9283489238183313 }, { "seed": 4, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.01946054669788179, "label_overlap": 0.0, + "label_connector_overlap": 0.3263538122233368, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.003125, + "sprawl": 1.3670930117552724, + "long_connectors": 0.0, + "edge_length_cv": 0.3843983289426198, + "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.918350064669587 }, { "seed": 5, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.012167927274499071, "label_overlap": 0.0, + "label_connector_overlap": 0.2550702528352603, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0031249999999998224, + "sprawl": 1.4582946858287442, + "long_connectors": 0.0, + "edge_length_cv": 0.3328034682006411, + "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.8196399052590745 }, { "seed": 6, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.022154939341986424, "label_overlap": 0.0, + "label_connector_overlap": 0.279623255489416, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.4018710259365192, + "long_connectors": 0.0, + "edge_length_cv": 0.33320477642808993, + "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.8642125184022267 }, { "seed": 7, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.036268897911063705, "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.23922613058531195, + "crossings": 0.05, + "crowding": 0.008395267017583653, + "sprawl": 1.3388590712050135, + "long_connectors": 0.0, + "edge_length_cv": 0.384545597004169, + "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.8594870265189324 }, { "seed": 42, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.01671014307667795, "label_overlap": 0.0, + "label_connector_overlap": 0.2709549272500089, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.4604111735887648, + "long_connectors": 0.0, + "edge_length_cv": 0.4305317304608594, + "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.8499554704255605 }, { "seed": 123, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.023437544936108005, "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.30923899201764815, + "crossings": 0.1, + "crowding": 0.0005486896484388827, + "sprawl": 1.3688801775401178, + "long_connectors": 0.0, + "edge_length_cv": 0.3330810559825201, + "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.9985023119321567 }, { "seed": 456, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.026067546259404724, "label_overlap": 0.0, + "label_connector_overlap": 0.29843853731536085, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.3497259222338067, + "long_connectors": 0.0, + "edge_length_cv": 0.3747864843935116, + "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.8822243790503025 }, { "seed": 789, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.02531142633634823, "label_overlap": 0.0, + "label_connector_overlap": 0.24189731120462743, "crossings": 0.0, - "sprawl": 1.2408599625896033, - "edge_length_cv": 0.08831889638826458, - "aspect_penalty": 0.3978988546752582, - "chain_straightness": 0.0, + "crowding": 0.0, + "sprawl": 1.38640211682805, + "long_connectors": 0.0, + "edge_length_cv": 0.3396013719644028, + "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.7950693486866501 } ], - "median_cost": 0.3014527029273928, + "median_cost": 0.9002872218599447, "spread": [ - 0.3014527029273928, - 0.3014527029273928 + 0.8571041374955894, + 0.9708427238914547 ], - "best_of_k_cost": 0.3014527029273928, - "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "best_of_k_cost": 0.7950693486866501, + "best_seed": 789, + "median_seed": 456, + "worst_seed": 2 }, { - "model": "reliability", + "model": "workforce", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007096173654460949, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.3831631192975692, + "crossings": 0.10714285714285714, + "crowding": 0.003324436952535634, + "sprawl": 1.5686134831362382, + "long_connectors": 0.0, + "edge_length_cv": 0.42562781840399644, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.5862068965517242, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.1501783807899004 }, { "seed": 1, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0014414446307170312, + "node_overlap": 0.0, + "node_connector_overlap": 0.007372083988457059, "label_overlap": 0.0, - "crossings": 0.08695652173913043, - "sprawl": 1.3832868223999235, - "edge_length_cv": 0.37189676423713974, + "label_connector_overlap": 0.3942311725989049, + "crossings": 0.10714285714285714, + "crowding": 0.0021551724137931034, + "sprawl": 1.5216223358137748, + "long_connectors": 0.0, + "edge_length_cv": 0.37860605642109085, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5200378462318094, + "misalignment": 0.5172413793103448, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.31123117240504933 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6289566559807986 + "weighted_cost": 1.1475186783164 }, { "seed": 2, "metrics": { - "node_overlap": 0.011844697676119335, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007230642203432912, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 3.244912288623023, - "edge_length_cv": 0.2695427912851834, + "label_connector_overlap": 0.41324716597275996, + "crossings": 0.14285714285714285, + "crowding": 0.0, + "sprawl": 1.5286991198661164, + "long_connectors": 0.0, + "edge_length_cv": 0.430465606971024, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6717892770164458, + "misalignment": 0.48275862068965514, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.36178073107784336 + "loop_straightness": 0.0 }, - "weighted_cost": 1.096155721923782 + "weighted_cost": 1.2076398182586434 }, { "seed": 3, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007195837858323048, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.2638022287176784, - "edge_length_cv": 0.34166080818268335, + "label_connector_overlap": 0.3613413210814754, + "crossings": 0.10714285714285714, + "crowding": 0.0076207746312177875, + "sprawl": 1.5473311750226575, + "long_connectors": 0.0, + "edge_length_cv": 0.4432369079778298, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5849770287913719, + "misalignment": 0.5517241379310345, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.37234476093767166 + "loop_straightness": 0.0 }, - "weighted_cost": 0.679183585360285 + "weighted_cost": 1.113172496661702 }, { "seed": 4, "metrics": { - "node_overlap": 0.02476306939773773, - "node_connector_overlap": 0.01757739223256019, + "node_overlap": 0.0, + "node_connector_overlap": 0.01272653444764572, "label_overlap": 0.0, - "crossings": 0.17391304347826086, - "sprawl": 1.410765434804514, - "edge_length_cv": 0.46016881737394383, + "label_connector_overlap": 0.4149870412567001, + "crossings": 0.10714285714285714, + "crowding": 0.0, + "sprawl": 1.4931566983119746, + "long_connectors": 0.0, + "edge_length_cv": 0.39034079040783465, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6440847033872134, + "misalignment": 0.5862068965517242, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.3182416417478747 + "loop_straightness": 0.0 }, - "weighted_cost": 0.7878646375991344 + "weighted_cost": 1.1869863521563648 }, { "seed": 5, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007044712287514318, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.4242823233303506, + "crossings": 0.07142857142857142, + "crowding": 0.0022445819034710105, + "sprawl": 1.577684524150105, + "long_connectors": 0.0, + "edge_length_cv": 0.3828168162248467, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.5517241379310345, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.1737796077332265 }, { "seed": 6, "metrics": { - "node_overlap": 0.02476306939773773, - "node_connector_overlap": 0.009487406438905726, + "node_overlap": 0.0, + "node_connector_overlap": 0.007293054101507116, "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.4056034829542973, + "crossings": 0.10714285714285714, + "crowding": 0.0020866992238851305, + "sprawl": 1.5386164019189574, + "long_connectors": 0.0, + "edge_length_cv": 0.37753173641838067, + "aspect_penalty": 0.0, + "misalignment": 0.4137931034482759, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.44404714253744454 + "loop_straightness": 0.0 }, - "weighted_cost": 0.7880779980058206 + "weighted_cost": 1.1582542998257694 }, { "seed": 7, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.007675591013226364, + "node_overlap": 0.0, + "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.21739130434782608, - "sprawl": 1.3869543741108474, - "edge_length_cv": 0.40221938316813444, + "label_connector_overlap": 0.3582282189958258, + "crossings": 0.14285714285714285, + "crowding": 0.0016131454675492039, + "sprawl": 1.6589526087823843, + "long_connectors": 0.0, + "edge_length_cv": 0.3956640799785136, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6346648307435367, + "misalignment": 0.5172413793103448, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.35645147611838324 + "loop_straightness": 0.0 }, - "weighted_cost": 0.8167319194902127 + "weighted_cost": 1.1482749069450613 }, { "seed": 42, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007168294762011524, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.3881828383997622, + "crossings": 0.10714285714285714, + "crowding": 0.0021551724137931034, + "sprawl": 1.5873161079071918, + "long_connectors": 0.0, + "edge_length_cv": 0.40350336175353513, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.48275862068965514, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.1510137657260802 }, { "seed": 123, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.005377274496934227, + "node_overlap": 0.0, + "node_connector_overlap": 0.006936875618217165, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.2485781479016096, - "edge_length_cv": 0.46492254670069055, + "label_connector_overlap": 0.48043202983842237, + "crossings": 0.07142857142857142, + "crowding": 0.0, + "sprawl": 1.555066311306872, + "long_connectors": 0.0, + "edge_length_cv": 0.40687165760227795, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5334625826645447, + "misalignment": 0.5517241379310345, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.35421195756431567 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6590969849059389 + "weighted_cost": 1.2498893590424607 }, { "seed": 456, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.007049387257671134, "label_overlap": 0.0, - "crossings": 0.13043478260869565, - "sprawl": 1.1948775873522024, - "edge_length_cv": 0.33861002145028124, + "label_connector_overlap": 0.4121873369791232, + "crossings": 0.10714285714285714, + "crowding": 0.0032336379715036355, + "sprawl": 1.6038713865617287, + "long_connectors": 0.0, + "edge_length_cv": 0.39821972648448384, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5853627724940595, + "misalignment": 0.3793103448275862, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.33705916027673083 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6620243945021708 + "weighted_cost": 1.1816551562215787 }, { "seed": 789, "metrics": { - "node_overlap": 0.024763069397737713, - "node_connector_overlap": 0.020534662882022013, + "node_overlap": 0.0, + "node_connector_overlap": 0.009789842512974278, "label_overlap": 0.0, - "crossings": 0.17391304347826086, - "sprawl": 1.5255943560534784, - "edge_length_cv": 0.5612590549553415, + "label_connector_overlap": 0.3925058289873281, + "crossings": 0.03571428571428571, + "crowding": 0.0023286805153446043, + "sprawl": 1.5680390023361215, + "long_connectors": 0.0, + "edge_length_cv": 0.4231059257764172, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.674276852188812, + "misalignment": 0.48275862068965514, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.4116513268461032 + "loop_straightness": 0.0 }, - "weighted_cost": 0.8352055205288513 + "weighted_cost": 1.086667007389567 } ], - "median_cost": 0.6706039899312279, + "median_cost": 1.1546340327759248, "spread": [ - 0.6620243945021708, - 0.7952414783769186 + 1.148085849787896, + 1.182987955205275 ], - "best_of_k_cost": 0.6590969849059389, - "best_seed": 1, - "median_seed": 3, - "worst_seed": 2 + "best_of_k_cost": 1.086667007389567, + "best_seed": 789, + "median_seed": 6, + "worst_seed": 123 }, { - "model": "hares_and_foxes", + "model": "bathtub", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.03843090402230209, + "node_connector_overlap": 0.0260660483443771, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.228990217870891, - "edge_length_cv": 0.28606799997289617, + "label_connector_overlap": 0.27287474465028355, + "crossings": 0.3, + "crowding": 0.0362288492853644, + "sprawl": 2.2631856113900914, + "long_connectors": 0.0, + "edge_length_cv": 0.4062515431103036, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4193548387096774, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2457980435741782 + "weighted_cost": 1.539913113746092 }, { "seed": 1, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.05933890880127461, + "node_connector_overlap": 0.0413732296411515, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2218901467164682, - "edge_length_cv": 0.1961122230011898, + "label_connector_overlap": 0.24166661086516772, + "crossings": 0.35, + "crowding": 0.03225806451612903, + "sprawl": 1.727292831921362, + "long_connectors": 0.0, + "edge_length_cv": 0.4643517047023839, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4838709677419355, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.24437802934329367 + "weighted_cost": 1.5154009256551788 }, { "seed": 2, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.05933890880127461, + "node_connector_overlap": 0.058252435757656436, "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.24568176995990718, + "crossings": 0.3, + "crowding": 0.03616524641757505, + "sprawl": 1.7033079595297296, + "long_connectors": 0.0, + "edge_length_cv": 0.4694257288668445, + "aspect_penalty": 0.0, + "misalignment": 0.4516129032258065, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.23339723104649385 + "weighted_cost": 1.4998672338822228 }, { "seed": 3, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.03843090402230209, + "node_connector_overlap": 0.02667141285693712, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.4385141963465509, - "edge_length_cv": 0.4452967243084567, + "label_connector_overlap": 0.2745549483130331, + "crossings": 0.3, + "crowding": 0.036009806643084605, + "sprawl": 2.5296885439406767, + "long_connectors": 0.0, + "edge_length_cv": 0.3724815996392209, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4516129032258065, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2877028392693102 + "weighted_cost": 1.6132766452123157 }, { "seed": 4, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.05933890880127461, + "node_connector_overlap": 0.052243661747580245, "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.19084746212982187, + "crossings": 0.2, + "crowding": 0.05148575988338288, + "sprawl": 1.99273731768074, + "long_connectors": 0.0, + "edge_length_cv": 0.511718803048681, + "aspect_penalty": 0.0, + "misalignment": 0.4838709677419355, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.3953036462436665 + "weighted_cost": 1.3965018835721157 }, { "seed": 5, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.05933890880127461, + "node_connector_overlap": 0.04432174520885116, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.19525981983137, - "edge_length_cv": 0.4896083755529902, + "label_connector_overlap": 0.355332389691121, + "crossings": 0.35, + "crowding": 0.034822293998417145, + "sprawl": 1.7081960019657263, + "long_connectors": 0.0, + "edge_length_cv": 0.4075234934106665, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.3870967741935484, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.239051963966274 + "weighted_cost": 1.6799092276680487 }, { "seed": 6, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.059338908801274286, + "node_connector_overlap": 0.060503765917226095, "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.33320346726103794, + "crossings": 0.4, + "crowding": 0.037471046596949595, + "sprawl": 1.7253503559370102, + "long_connectors": 0.0, + "edge_length_cv": 0.4655353854238205, + "aspect_penalty": 0.0, + "misalignment": 0.4516129032258065, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2348074689410758 + "weighted_cost": 1.742468839434252 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.004471151519176097, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 0.9658275858003817, - "edge_length_cv": 0.3876784910946535, + "label_connector_overlap": 0.30197270752486405, + "crossings": 0.45, + "crowding": 0.0, + "sprawl": 9.275275776741196, + "long_connectors": 0.0, + "edge_length_cv": 0.4297485450095218, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.8064516129032258, "loop_compactness": 0.0, - "flow_bends": 0.0, + "flow_bends": 0.6666666666666666, "loop_straightness": 0.0 }, - "weighted_cost": 0.19316551716007635 + "weighted_cost": 3.41136546980127 }, { "seed": 42, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.059338908801274286, + "node_connector_overlap": 0.049671288422938394, "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.322100742120703, + "crossings": 0.2, + "crowding": 0.036290322580645164, + "sprawl": 1.7571851510009409, + "long_connectors": 0.0, + "edge_length_cv": 0.40975555503988564, + "aspect_penalty": 0.0, + "misalignment": 0.4193548387096774, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.23811362877263145 + "weighted_cost": 1.5077019650332395 }, { "seed": 123, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.05755964068162532, + "node_overlap": 0.05933890880127461, + "node_connector_overlap": 0.03353847459355867, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.1539380074938739, - "edge_length_cv": 0.3783713713241633, + "label_connector_overlap": 0.30193283404331045, + "crossings": 0.3, + "crowding": 0.03768387653046769, + "sprawl": 1.6761472220654632, + "long_connectors": 0.0, + "edge_length_cv": 0.3461058945727681, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4193548387096774, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.2883472421804001 + "weighted_cost": 1.5263185469743454 }, { "seed": 456, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.04348749323609555, + "node_overlap": 0.05933890880127461, + "node_connector_overlap": 0.05252143336657532, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.8026240922742207, - "edge_length_cv": 0.4208061767745891, + "label_connector_overlap": 0.33695075828998844, + "crossings": 0.35, + "crowding": 0.034274193548387094, + "sprawl": 1.8618123710690255, + "long_connectors": 0.0, + "edge_length_cv": 0.3779757938288533, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.5161290322580645, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.40401231169093976 + "weighted_cost": 1.7194953745140442 }, { "seed": 789, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.059338908801274286, + "node_connector_overlap": 0.0471407927792872, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.0856519054096903, - "edge_length_cv": 0.3352621885189167, + "label_connector_overlap": 0.24890300029128595, + "crossings": 0.2, + "crowding": 0.03508675781145527, + "sprawl": 1.7247062831720361, + "long_connectors": 0.0, + "edge_length_cv": 0.4064406466959635, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.32258064516129037, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.21713038108193805 + "weighted_cost": 1.3738436599205568 } ], - "median_cost": 0.24171499665478385, + "median_cost": 1.5331158303602188, "spread": [ - 0.2344549094674303, - 0.28786393999708265 + 1.5057432822454853, + 1.6898057643795477 ], - "best_of_k_cost": 0.21713038108193805, - "best_seed": 7, - "median_seed": 1, - "worst_seed": 456 + "best_of_k_cost": 1.3738436599205568, + "best_seed": 789, + "median_seed": 0, + "worst_seed": 7 }, { - "model": "multipoint", + "model": "catastrophe", "samples": [ { "seed": 0, "metrics": { - "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, + "node_overlap": 0.04277921332184914, + "node_connector_overlap": 0.07649089632598491, + "label_overlap": 0.000015200904892467164, + "label_connector_overlap": 0.25697036325242006, + "crossings": 0.20588235294117646, + "crowding": 0.04917221579322804, + "sprawl": 1.283186195601195, + "long_connectors": 0.0, + "edge_length_cv": 0.34934364210837365, + "aspect_penalty": 0.0, + "misalignment": 0.3023255813953488, + "loop_compactness": 0.784267125345664, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.6830083132366993 }, { "seed": 1, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.027706000574217547, + "node_connector_overlap": 0.05674552474641684, "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.3023687019186074, + "crossings": 0.29411764705882354, + "crowding": 0.028187147155785162, + "sprawl": 1.7956379724771934, + "long_connectors": 0.0, + "edge_length_cv": 0.3334752072040872, + "aspect_penalty": 0.0, + "misalignment": 0.39534883720930236, + "loop_compactness": 0.7842671253456643, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.8634711255736092 }, { "seed": 2, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.02770600057421779, + "node_connector_overlap": 0.053045161248027196, "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.31455545510712074, + "crossings": 0.3235294117647059, + "crowding": 0.027527400195566234, + "sprawl": 1.7470929690802584, + "long_connectors": 0.0, + "edge_length_cv": 0.3478984607286807, + "aspect_penalty": 0.0, + "misalignment": 0.39534883720930236, + "loop_compactness": 0.7842671253456641, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.8909662952560302 }, { "seed": 3, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.027706000574217547, + "node_connector_overlap": 0.06075444277579878, "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.24992140566272938, + "crossings": 0.2647058823529412, + "crowding": 0.026169167604437816, + "sprawl": 1.7649704972315727, + "long_connectors": 0.0, + "edge_length_cv": 0.36882991799676895, + "aspect_penalty": 0.0, + "misalignment": 0.32558139534883723, + "loop_compactness": 0.7842671253456643, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.7467446599938747 }, { "seed": 4, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.02770600057421779, + "node_connector_overlap": 0.053045161248027196, "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.31455545510712074, + "crossings": 0.3235294117647059, + "crowding": 0.027527400195566234, + "sprawl": 1.7470929690802584, + "long_connectors": 0.0, + "edge_length_cv": 0.3478984607286807, + "aspect_penalty": 0.0, + "misalignment": 0.39534883720930236, + "loop_compactness": 0.7842671253456641, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.8909662952560302 }, { "seed": 5, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.028365667254556307, + "node_connector_overlap": 0.057596251918922314, "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.327989089123597, + "crossings": 0.2647058823529412, + "crowding": 0.02728116843994271, + "sprawl": 1.9298247654529708, + "long_connectors": 0.0, + "edge_length_cv": 0.3899100189951688, + "aspect_penalty": 0.0, + "misalignment": 0.40476190476190477, + "loop_compactness": 0.7842671253456643, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.91008225568477 }, { "seed": 6, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.027706000574217547, + "node_connector_overlap": 0.05467062783789798, "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.3370115789372144, + "crossings": 0.3235294117647059, + "crowding": 0.02690386068673064, + "sprawl": 1.7373439504444699, + "long_connectors": 0.0, + "edge_length_cv": 0.3427653964761761, + "aspect_penalty": 0.0, + "misalignment": 0.32558139534883723, + "loop_compactness": 0.7842671253456647, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.9178638758270825 }, { "seed": 7, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.02770600057421779, + "node_connector_overlap": 0.05209105324892361, "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.3099245773779065, + "crossings": 0.3235294117647059, + "crowding": 0.027527400195566234, + "sprawl": 1.778928155989096, + "long_connectors": 0.0, + "edge_length_cv": 0.34516764971788766, + "aspect_penalty": 0.0, + "misalignment": 0.39534883720930236, + "loop_compactness": 0.7842671253456641, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.8900705593912113 }, { "seed": 42, "metrics": { - "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, + "node_overlap": 0.0437977660199884, + "node_connector_overlap": 0.07288650504285034, + "label_overlap": 0.0000258582943897902, + "label_connector_overlap": 0.24346336696695228, + "crossings": 0.29411764705882354, + "crowding": 0.044400425889557456, + "sprawl": 1.4302620198292808, + "long_connectors": 0.0, + "edge_length_cv": 0.3598673957232046, + "aspect_penalty": 0.0, + "misalignment": 0.30952380952380953, + "loop_compactness": 0.7842671253456646, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.7800935546328007 }, { "seed": 123, "metrics": { - "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, + "node_overlap": 0.042779213321848905, + "node_connector_overlap": 0.0754430251158815, + "label_overlap": 3.142823178591658e-7, + "label_connector_overlap": 0.3176415872642993, + "crossings": 0.23529411764705882, + "crowding": 0.05239364497547453, + "sprawl": 1.327627709505751, + "long_connectors": 0.0, + "edge_length_cv": 0.3775375405730366, + "aspect_penalty": 0.0, + "misalignment": 0.2093023255813954, + "loop_compactness": 0.7842671253456643, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.806308550438172 }, { "seed": 456, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_overlap": 0.02770600057421779, + "node_connector_overlap": 0.08071554703046019, "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.2889338869694489, + "crossings": 0.3235294117647059, + "crowding": 0.023255813953488372, + "sprawl": 1.929965552146705, + "long_connectors": 0.0, + "edge_length_cv": 0.40539310135084533, + "aspect_penalty": 0.0, + "misalignment": 0.3023255813953488, + "loop_compactness": 0.7842671253456642, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.9400189485575272 }, { "seed": 789, "metrics": { - "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, + "node_overlap": 0.042779213321848905, + "node_connector_overlap": 0.0700279598624416, + "label_overlap": 0.000017886073204223656, + "label_connector_overlap": 0.3097868524550448, + "crossings": 0.29411764705882354, + "crowding": 0.03933001561057297, + "sprawl": 1.2776532340594644, + "long_connectors": 0.0, + "edge_length_cv": 0.3726320560614229, + "aspect_penalty": 0.0, + "misalignment": 0.32558139534883723, + "loop_compactness": 0.7842671253456641, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.75 }, - "weighted_cost": 0.0 + "weighted_cost": 1.828652007147548 } ], - "median_cost": 0.0, + "median_cost": 1.87677084248241, "spread": [ - 0.0, - 0.0 + 1.7997548014868292, + 1.8957452853632153 ], - "best_of_k_cost": 0.0, + "best_of_k_cost": 1.7800935546328007, "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "median_seed": 1, + "worst_seed": 456 }, { - "model": "alias1", + "model": "groupon", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.014271829924799194, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.4616476236923571, + "crossings": 0.22916666666666666, + "crowding": 0.00870869750258955, + "sprawl": 1.656573452774021, + "long_connectors": 0.005129204788498755, + "edge_length_cv": 0.5376092895625322, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.6666666666666667, + "loop_compactness": 0.6361447229526843, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.78133836560827 }, { "seed": 1, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.020081903825109545, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.37725272906182605, + "crossings": 0.2916666666666667, + "crowding": 0.024322569240707255, + "sprawl": 1.1043510714932714, + "long_connectors": 0.01250863558122281, + "edge_length_cv": 0.6610046796073618, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.5121951219512195, + "loop_compactness": 0.5909205700009729, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.5765773476251572 }, { "seed": 2, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.00568162149995695, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.45579755116091203, + "crossings": 0.5416666666666666, + "crowding": 0.002976190476190476, + "sprawl": 1.76683144584467, + "long_connectors": 0.008052009438853572, + "edge_length_cv": 0.5429110622441976, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.6666666666666667, + "loop_compactness": 0.7236638252707938, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 2.126183874455102 }, { "seed": 3, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.018796842957660067, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.4143831128307079, + "crossings": 0.4166666666666667, + "crowding": 0.015217148528910863, + "sprawl": 1.2225639593454287, + "long_connectors": 0.01394497485369383, + "edge_length_cv": 0.7117436503088292, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.45238095238095233, + "loop_compactness": 0.7395733819502811, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.8293484802537556 }, { "seed": 4, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.01666781218517642, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.4347956287663958, + "crossings": 0.3541666666666667, + "crowding": 0.0076311269733087845, + "sprawl": 1.7055696735634782, + "long_connectors": 0.004572207844730701, + "edge_length_cv": 0.5562864286952276, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.4878048780487805, + "loop_compactness": 0.6670022659371307, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.876202162268272 }, { "seed": 5, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.02299890799554789, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.37060780646904634, + "crossings": 0.5, + "crowding": 0.013682548917309524, + "sprawl": 1.1786684207734912, + "long_connectors": 0.010596094963512664, + "edge_length_cv": 0.61687102316755, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.41463414634146345, + "loop_compactness": 0.686614596002508, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.8162818649376378 }, { "seed": 6, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.012557319867895681, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.3327704627275542, + "crossings": 0.5625, + "crowding": 0.015452188371085009, + "sprawl": 1.2068944080558612, + "long_connectors": 0.012853285582203835, + "edge_length_cv": 0.6593936923613066, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.5121951219512195, + "loop_compactness": 0.7502722339824853, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.8463165574067755 }, { "seed": 7, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.012470304758649256, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.39636575076835345, + "crossings": 0.2708333333333333, + "crowding": 0.009206887043949088, + "sprawl": 1.6142272348261806, + "long_connectors": 0.005220550791730666, + "edge_length_cv": 0.5812588613223643, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.5116279069767442, + "loop_compactness": 0.6195901513239371, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.6893107759921553 }, { "seed": 42, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.008759111680090068, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.3654224782572133, + "crossings": 0.2916666666666667, + "crowding": 0.005733700986427256, + "sprawl": 1.5786152354592806, + "long_connectors": 0.006887349677537181, + "edge_length_cv": 0.5858131553946206, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.5121951219512195, + "loop_compactness": 0.5862632209390699, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.6314899772888174 }, { "seed": 123, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.04508854556291611, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.404728143966934, + "crossings": 0.20833333333333334, + "crowding": 0.05159637012498731, + "sprawl": 1.048510850092427, + "long_connectors": 0.007672673300245399, + "edge_length_cv": 0.6436554996638777, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.41860465116279066, + "loop_compactness": 0.7559942883200771, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.6520366247674776 }, { "seed": 456, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, - "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "node_overlap": 0.0, + "node_connector_overlap": 0.029347087077200268, + "label_overlap": 0.0017945526244131313, + "label_connector_overlap": 0.3973293556565071, + "crossings": 0.375, + "crowding": 0.06390864538390151, + "sprawl": 1.2734710547938077, + "long_connectors": 0.008945617879868386, + "edge_length_cv": 0.6033756809219896, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.3571428571428571, + "loop_compactness": 0.6803816860901426, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.8152007046126222 }, { "seed": 789, "metrics": { - "node_overlap": 0.04231974921630094, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.026340639421717087, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.23975104782799, - "edge_length_cv": 0.09857461825509387, + "label_connector_overlap": 0.3015179292346872, + "crossings": 0.4791666666666667, + "crowding": 0.00914926102210008, + "sprawl": 1.2992503645824423, + "long_connectors": 0.011518678609647672, + "edge_length_cv": 0.5856597507994968, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "misalignment": 0.5365853658536586, + "loop_compactness": 0.7443673064538694, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 0.290269958781899 + "weighted_cost": 1.7598668746169646 } ], - "median_cost": 0.290269958781899, + "median_cost": 1.798269535110446, "spread": [ - 0.290269958781899, - 0.290269958781899 + 1.679992238185986, + 1.8335904995420107 ], - "best_of_k_cost": 0.290269958781899, - "best_seed": 0, + "best_of_k_cost": 1.6314899772888174, + "best_seed": 1, "median_seed": 0, - "worst_seed": 0 + "worst_seed": 2 }, { - "model": "cross_element", + "model": "delays", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.14292550977944257, - "node_connector_overlap": 0.07874643194986122, + "node_overlap": 0.0, + "node_connector_overlap": 0.02218066838083641, "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.32741939647250295, + "crossings": 0.35, + "crowding": 0.0018938237988164253, + "sprawl": 1.8477257970917058, + "long_connectors": 0.0, + "edge_length_cv": 0.31264042031560213, + "aspect_penalty": 0.0, + "misalignment": 0.18181818181818177, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.2748289317589672 + "loop_straightness": 0.0 }, - "weighted_cost": 1.3865095903460378 + "weighted_cost": 1.3674975227239883 }, { "seed": 1, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05368923480833391, + "node_overlap": 0.0, + "node_connector_overlap": 0.020674911119903973, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.2497817625149574, - "edge_length_cv": 0.15661518692207232, + "label_connector_overlap": 0.39065018302415533, + "crossings": 0.225, + "crowding": 0.06662476497811065, + "sprawl": 1.317633264078583, + "long_connectors": 0.0, + "edge_length_cv": 0.3366141747904354, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5141352860514037, + "misalignment": 0.12121212121212122, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.16166439453184614 + "loop_straightness": 0.0 }, - "weighted_cost": 1.043391650964514 + "weighted_cost": 1.2604793898950095 }, { "seed": 2, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05739384839998312, + "node_overlap": 0.0, + "node_connector_overlap": 0.02218066838083641, "label_overlap": 0.0, - "crossings": 0.125, - "sprawl": 1.105830775826482, - "edge_length_cv": 0.21414163583637771, + "label_connector_overlap": 0.32741939647250295, + "crossings": 0.35, + "crowding": 0.0018938237988164253, + "sprawl": 1.8477257970917058, + "long_connectors": 0.0, + "edge_length_cv": 0.31264042031560213, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4783146914388712, + "misalignment": 0.18181818181818177, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.23006389967349836 + "loop_straightness": 0.0 }, - "weighted_cost": 0.7608177798876204 + "weighted_cost": 1.3674975227239883 }, { "seed": 3, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.03366922572189482, + "node_overlap": 0.0, + "node_connector_overlap": 0.02218066838083641, "label_overlap": 0.0, - "crossings": 0.125, - "sprawl": 1.2854106234824583, - "edge_length_cv": 0.33748941379701564, + "label_connector_overlap": 0.32741939647250295, + "crossings": 0.35, + "crowding": 0.0018938237988164253, + "sprawl": 1.8477257970917058, + "long_connectors": 0.0, + "edge_length_cv": 0.31264042031560213, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7829428048865084, + "misalignment": 0.18181818181818177, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.45759328372739344 + "loop_straightness": 0.0 }, - "weighted_cost": 0.9176133105251717 + "weighted_cost": 1.3674975227239883 }, { "seed": 4, "metrics": { - "node_overlap": 0.1429255097794431, - "node_connector_overlap": 0.03376620751651381, + "node_overlap": 0.0, + "node_connector_overlap": 0.007528421408871635, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2989223527440266, - "edge_length_cv": 0.4699894313443017, + "label_connector_overlap": 0.2849672533324153, + "crossings": 0.15, + "crowding": 0.006825542734498282, + "sprawl": 1.6566564934447365, + "long_connectors": 0.008293837157956327, + "edge_length_cv": 0.5054211355484309, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5470879543433413, + "misalignment": 0.21212121212121215, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.048567333695646774 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6601681029516635 + "weighted_cost": 1.038856428703148 }, { "seed": 5, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05232996826889643, + "node_overlap": 0.0, + "node_connector_overlap": 0.001498028302292512, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.598872259770682, - "edge_length_cv": 0.2979907298096308, + "label_connector_overlap": 0.3278796829189022, + "crossings": 0.225, + "crowding": 0.0, + "sprawl": 7.721532003057264, + "long_connectors": 0.0, + "edge_length_cv": 0.38289567608599284, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5040015759096583, + "misalignment": 0.5625, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.1374144658794832 + "loop_straightness": 0.0 }, - "weighted_cost": 1.1053720069542872 + "weighted_cost": 2.7064485817472543 }, { "seed": 6, "metrics": { - "node_overlap": 0.09884281581485074, - "node_connector_overlap": 0.017585427164416256, + "node_overlap": 0.0, + "node_connector_overlap": 0.011742254730133735, "label_overlap": 0.0, + "label_connector_overlap": 0.2662380707953969, "crossings": 0.25, - "sprawl": 1.2240757649666012, - "edge_length_cv": 0.21785931759294788, + "crowding": 0.001893939393939394, + "sprawl": 2.328117751126569, + "long_connectors": 0.0, + "edge_length_cv": 0.31292902734498346, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.649219675628467, + "misalignment": 0.2727272727272727, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.3333333333333333 + "loop_straightness": 0.0 }, - "weighted_cost": 0.9042645995573074 + "weighted_cost": 1.284037720101672 }, { "seed": 7, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.03976287212374372, + "node_overlap": 0.0, + "node_connector_overlap": 0.017914866803731617, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.1030807484299563, - "edge_length_cv": 0.2540338479893443, + "label_connector_overlap": 0.4655527057116693, + "crossings": 0.225, + "crowding": 0.005730214739302544, + "sprawl": 1.8237208799966098, + "long_connectors": 0.0, + "edge_length_cv": 0.3251665261220471, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.565158858437151, + "misalignment": 0.18181818181818177, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.15641986418561762 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6450100613825998 + "weighted_cost": 1.4390010450952402 }, { "seed": 42, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.03823283129751742, + "node_overlap": 0.0, + "node_connector_overlap": 0.03196713343611331, "label_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, + "label_connector_overlap": 0.3418730232394007, + "crossings": 0.325, + "crowding": 0.008887921780497394, + "sprawl": 1.8250398586290622, + "long_connectors": 0.0, + "edge_length_cv": 0.30227069079222796, + "aspect_penalty": 0.0, + "misalignment": 0.18181818181818177, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.10498812980413981 + "loop_straightness": 0.0 }, - "weighted_cost": 0.6418383374637484 + "weighted_cost": 1.3850735063509088 }, { "seed": 123, "metrics": { - "node_overlap": 0.14292550977944254, - "node_connector_overlap": 0.05368923480833391, + "node_overlap": 0.0, + "node_connector_overlap": 0.04085458601305136, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.2497817625149574, - "edge_length_cv": 0.15661518692207232, + "label_connector_overlap": 0.40545481101488007, + "crossings": 0.55, + "crowding": 0.0037878218762103914, + "sprawl": 1.8884584536313105, + "long_connectors": 0.0, + "edge_length_cv": 0.3626971636659405, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5141352860514037, + "misalignment": 0.16129032258064513, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.16166439453184614 + "loop_straightness": 0.0 }, - "weighted_cost": 1.043391650964514 + "weighted_cost": 1.7319228560905253 }, { "seed": 456, "metrics": { - "node_overlap": 0.14292550977944202, - "node_connector_overlap": 0.07717328372897628, + "node_overlap": 0.0, + "node_connector_overlap": 0.029472904605496744, "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.3214087104619574, + "crossings": 0.225, + "crowding": 0.009260610710415005, + "sprawl": 1.8399075099905904, + "long_connectors": 0.0, + "edge_length_cv": 0.30249403395050967, + "aspect_penalty": 0.0, + "misalignment": 0.12121212121212122, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.127070122338004 + "loop_straightness": 0.0 }, - "weighted_cost": 0.9174905593846656 + "weighted_cost": 1.2474175752332042 }, { "seed": 789, "metrics": { - "node_overlap": 0.1429255097794431, - "node_connector_overlap": 0.04985300122850721, + "node_overlap": 0.0, + "node_connector_overlap": 0.013041946454782528, "label_overlap": 0.0, - "crossings": 0.375, - "sprawl": 1.7157507559852456, - "edge_length_cv": 0.7146821530127622, + "label_connector_overlap": 0.34757813117864156, + "crossings": 0.275, + "crowding": 0.002573821479836903, + "sprawl": 1.915257577728607, + "long_connectors": 0.0, + "edge_length_cv": 0.35430029256408135, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.9070368311076114, + "misalignment": 0.15625, + "loop_compactness": 0.0, "flow_bends": 0.0, - "loop_straightness": 0.27482893175896594 + "loop_straightness": 0.0 }, - "weighted_cost": 1.3012262878239407 + "weighted_cost": 1.3194643055895159 } ], - "median_cost": 0.9175519349549186, + "median_cost": 1.3674975227239883, "spread": [ - 0.7356553606536311, - 1.0588867399619573 + 1.2781481375500063, + 1.3985553910369917 ], - "best_of_k_cost": 0.6418383374637484, - "best_seed": 42, - "median_seed": 456, - "worst_seed": 0 + "best_of_k_cost": 1.2474175752332042, + "best_seed": 4, + "median_seed": 0, + "worst_seed": 5 }, { - "model": "arrayed_pop", + "model": "arms_race", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.009937653856781805, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.46635975194827745, + "crossings": 0.3888888888888889, + "crowding": 0.032963194767996584, + "sprawl": 1.6966985821558853, + "long_connectors": 0.0, + "edge_length_cv": 0.44423307193182415, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.8668963170399904, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.30290099704931833 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.975823624686098 }, { "seed": 1, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.3026911049928244, + "crossings": 0.2777777777777778, + "crowding": 0.013422486853090394, + "sprawl": 2.099192179209862, + "long_connectors": 0.0, + "edge_length_cv": 0.4039609620769209, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8825010929238619, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.23256127552027298 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.672958198310809 }, { "seed": 2, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.03006260605435891, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.39977021236883203, + "crossings": 0.3888888888888889, + "crowding": 0.005873827395426351, + "sprawl": 1.6732065979483157, + "long_connectors": 0.0, + "edge_length_cv": 0.3911741366529669, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8482301042144691, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.3077717431498357 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.8695807791007981 }, { "seed": 3, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.3649870377322633, + "crossings": 0.3888888888888889, + "crowding": 0.009582151993582467, + "sprawl": 1.6911220565773664, + "long_connectors": 0.0, + "edge_length_cv": 0.32558435869583185, + "aspect_penalty": 0.0, + "misalignment": 0.19999999999999996, + "loop_compactness": 0.8416431961799266, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2593563504377954 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.7513250251409582 }, { "seed": 4, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.012149193939759141, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.3888584573128413, + "crossings": 0.3888888888888889, + "crowding": 0.027695450751829064, + "sprawl": 1.654220830760522, + "long_connectors": 0.0, + "edge_length_cv": 0.3946108463204639, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8126558695054827, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2861075393585377 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.8180653895843424 }, { "seed": 5, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.022578434605595048, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.2887553298529586, + "crossings": 0.3888888888888889, + "crowding": 0.010314345255579099, + "sprawl": 1.791628882895522, + "long_connectors": 0.0, + "edge_length_cv": 0.40341526282612233, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.7615780267084172, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2531903249631419 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.688683895371991 }, { "seed": 6, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.0032535798816720604, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.32089436437169216, + "crossings": 0.3333333333333333, + "crowding": 0.04084121517467771, + "sprawl": 1.6141938159785616, + "long_connectors": 0.0, + "edge_length_cv": 0.41123562592049057, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.7611247473113882, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.39158358392693593 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.642513299474116 }, { "seed": 7, "metrics": { - "node_overlap": 0.053280710409472125, - "node_connector_overlap": 0.0, + "node_overlap": 0.0, + "node_connector_overlap": 0.03670882081113866, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.49459776881667733, + "crossings": 0.4444444444444444, + "crowding": 0.0041782308223587165, + "sprawl": 1.7875239990307978, + "long_connectors": 0.0, + "edge_length_cv": 0.29818499010342886, + "aspect_penalty": 0.0, + "misalignment": 0.4, + "loop_compactness": 0.9028734661034997, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.3184099795632304 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 2.143808354269519 }, { "seed": 42, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.3649870377322633, + "crossings": 0.3888888888888889, + "crowding": 0.009582151993582467, + "sprawl": 1.6911220565773664, + "long_connectors": 0.0, + "edge_length_cv": 0.32558435869583185, + "aspect_penalty": 0.0, + "misalignment": 0.19999999999999996, + "loop_compactness": 0.8416431961799266, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2593563504377954 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.7513250251409582 }, { "seed": 123, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.3649870377322633, + "crossings": 0.3888888888888889, + "crowding": 0.009582151993582467, + "sprawl": 1.6911220565773664, + "long_connectors": 0.0, + "edge_length_cv": 0.32558435869583185, + "aspect_penalty": 0.0, + "misalignment": 0.19999999999999996, + "loop_compactness": 0.8416431961799266, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2593563504377954 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.7513250251409582 }, { "seed": 456, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.2758772681314898, + "crossings": 0.3888888888888889, + "crowding": 0.013266291661162376, + "sprawl": 1.6273606759473405, + "long_connectors": 0.0, + "edge_length_cv": 0.3979894091320524, + "aspect_penalty": 0.0, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.8334649713097237, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.2507034744572673 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.6079342543704038 }, { "seed": 789, "metrics": { - "node_overlap": 0.053280710409472125, + "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "crossings": 0.0, - "sprawl": 1.2719329648690079, - "edge_length_cv": 0.08831889638826457, - "aspect_penalty": 0.33361314038954415, - "chain_straightness": 0.0, - "loop_compactness": 0.0, + "label_connector_overlap": 0.3591697971858699, + "crossings": 0.4444444444444444, + "crowding": 0.009375, + "sprawl": 1.6420603991345986, + "long_connectors": 0.0, + "edge_length_cv": 0.34311101296497853, + "aspect_penalty": 0.0, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.8529478565851238, "flow_bends": 0.0, - "loop_straightness": 0.0 + "loop_straightness": 0.25789935168069605 }, - "weighted_cost": 0.3076673033832737 + "weighted_cost": 1.8033916511423516 } ], - "median_cost": 0.3076673033832737, + "median_cost": 1.7513250251409582, "spread": [ - 0.3076673033832737, - 0.3076673033832737 + 1.6847524711066955, + 1.8309442369634563 ], - "best_of_k_cost": 0.3076673033832737, - "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "best_of_k_cost": 1.6079342543704038, + "best_seed": 456, + "median_seed": 3, + "worst_seed": 7 }, { - "model": "ai_pure_human", + "model": "hares_and_foxes", "samples": [ { "seed": 0, @@ -2625,16 +3311,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.31444149819945955, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.015624123171601102, + "sprawl": 1.3329422116410952, + "long_connectors": 0.0, + "edge_length_cv": 0.2510596910558491, "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.0 + "weighted_cost": 0.8705219233810643 }, { "seed": 1, @@ -2642,16 +3331,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.2720230771454212, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.018919353137174265, + "sprawl": 0.8479801515896476, + "long_connectors": 0.0, + "edge_length_cv": 0.24153987589618206, "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.0 + "weighted_cost": 0.7389490067527179 }, { "seed": 2, @@ -2659,50 +3351,59 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.23363799539462698, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.026493866103904064, + "sprawl": 0.832904196283862, + "long_connectors": 0.0, + "edge_length_cv": 0.29811564277165953, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 0.58517690826681 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.045631972168947994, "label_overlap": 0.0, + "label_connector_overlap": 0.1863992276401865, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.02780055156895072, + "sprawl": 1.1633234192206812, + "long_connectors": 0.0, + "edge_length_cv": 0.46598188906536736, "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.0 + "weighted_cost": 0.7894941921722968 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.03707432906789591, "label_overlap": 0.0, + "label_connector_overlap": 0.16311579941847928, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.02055091455615535, + "sprawl": 1.4630744694512237, + "long_connectors": 0.0, + "edge_length_cv": 0.45253448331487, "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.0 + "weighted_cost": 0.805141889182472 }, { "seed": 5, @@ -2710,16 +3411,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.2645075771829968, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.0, + "sprawl": 1.041188953961122, + "long_connectors": 0.0, + "edge_length_cv": 0.4896083755529902, "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.0 + "weighted_cost": 0.7070586042647757 }, { "seed": 6, @@ -2727,16 +3431,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.39352662018498963, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "crowding": 0.26304403370640206, + "sprawl": 0.954242722342609, + "long_connectors": 0.0, + "edge_length_cv": 0.2961652906451127, + "aspect_penalty": 0.24091651477240017, + "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.0 + "weighted_cost": 1.1918946445695389 }, { "seed": 7, @@ -2744,16 +3451,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.12504268419362394, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.01800120855572146, + "sprawl": 0.8407694601300947, + "long_connectors": 0.0, + "edge_length_cv": 0.3876784910946535, "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.0 + "weighted_cost": 0.515757599878681 }, { "seed": 42, @@ -2761,50 +3471,59 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.2877827456412807, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.015625000000000444, + "sprawl": 1.2029531429780467, + "long_connectors": 0.0, + "edge_length_cv": 0.182365967999511, "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.0 + "weighted_cost": 0.8480374042064331 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.05755964068162532, "label_overlap": 0.0, + "label_connector_overlap": 0.37449814972518786, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.005891374512217282, + "sprawl": 1.0046754405757474, + "long_connectors": 0.0, + "edge_length_cv": 0.3783713713241633, "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.0 + "weighted_cost": 1.0339267406071868 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.053381024540718136, "label_overlap": 0.0, + "label_connector_overlap": 0.3797722662477906, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.014844340800618038, + "sprawl": 1.2745732551232656, + "long_connectors": 0.0, + "edge_length_cv": 0.4562838397703892, "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.0 + "weighted_cost": 1.1099081030345568 }, { "seed": 789, @@ -2812,30 +3531,33 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.13157874193717545, "crossings": 0.0, - "sprawl": 0.0, - "edge_length_cv": 0.0, + "crowding": 0.2560510993520694, + "sprawl": 0.8696379463960998, + "long_connectors": 0.0, + "edge_length_cv": 0.3352621885189167, "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.0 + "weighted_cost": 0.7708286988568575 } ], - "median_cost": 0.0, + "median_cost": 0.7973180406773843, "spread": [ - 0.0, - 0.0 + 0.7309764061307324, + 0.9113731276875949 ], - "best_of_k_cost": 0.0, - "best_seed": 0, - "median_seed": 0, - "worst_seed": 0 + "best_of_k_cost": 0.7708286988568575, + "best_seed": 7, + "median_seed": 3, + "worst_seed": 6 }, { - "model": "ai_pure_ai", + "model": "ai_modules_arrays", "samples": [ { "seed": 0, @@ -2843,16 +3565,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 1, @@ -2860,16 +3585,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 2, @@ -2877,16 +3605,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 3, @@ -2894,16 +3625,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 4, @@ -2911,16 +3645,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 5, @@ -2928,16 +3665,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 6, @@ -2945,16 +3685,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 7, @@ -2962,16 +3705,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 42, @@ -2979,16 +3725,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 123, @@ -2996,16 +3745,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 456, @@ -3013,16 +3765,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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 }, { "seed": 789, @@ -3030,248 +3785,33 @@ "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, + "crowding": 0.035599127828787706, + "sprawl": 0.34259214987287323, + "long_connectors": 0.0, + "edge_length_cv": 0.370733451417772, "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.0 + "weighted_cost": 0.22124716529700603 } ], - "median_cost": 0.0, + "median_cost": 0.22124716529700603, "spread": [ - 0.0, - 0.0 + 0.22124716529700603, + 0.22124716529700603 ], - "best_of_k_cost": 0.0, + "best_of_k_cost": 0.22124716529700603, "best_seed": 0, "median_seed": 0, "worst_seed": 0 }, { - "model": "ai_edited", - "samples": [ - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.715042092339224, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.026687995014649 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7232043134115199, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0471699185762975 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7271292194699819, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.017173291143086 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.718913716417784, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0198663630598142 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.700679022277094, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0233520247339996 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7232043134115199, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0471699185762975 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.723816866157302, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0073962464591282 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7232043134115199, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.046854182732916 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7252669344572478, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0521707647125498 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7222903559191249, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.018166543346931 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7202424271245069, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0034807330735849 - }, - { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7118726224687183, - "flow_bends": 0.0, - "loop_straightness": 0.9642857142857143 - }, - "weighted_cost": 1.0568598081993477 - } - ], - "median_cost": 1.0250200098743243, - "spread": [ - 1.0179182302959697, - 1.0471699185762975 - ], - "best_of_k_cost": 1.0034807330735849, - "best_seed": 456, - "median_seed": 0, - "worst_seed": 789 - }, - { - "model": "ai_modules_arrays", + "model": "alias1", "samples": [ { "seed": 0, @@ -3279,16 +3819,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 1, @@ -3296,16 +3839,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 2, @@ -3313,16 +3859,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 3, @@ -3330,16 +3879,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 4, @@ -3347,16 +3899,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 5, @@ -3364,16 +3919,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 6, @@ -3381,16 +3939,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 7, @@ -3398,16 +3959,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 42, @@ -3415,16 +3979,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 123, @@ -3432,16 +3999,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 456, @@ -3449,16 +4019,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 }, { "seed": 789, @@ -3466,681 +4039,2062 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, + "label_connector_overlap": 0.1667300847940283, "crossings": 0.0, - "sprawl": 0.37326949442324286, - "edge_length_cv": 0.370733451417772, + "crowding": 0.0, + "sprawl": 1.2004023727154887, + "long_connectors": 0.0, + "edge_length_cv": 0.09806235152453334, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.07465389888464857 + "weighted_cost": 0.5501957203699146 } ], - "median_cost": 0.07465389888464857, + "median_cost": 0.5501957203699146, "spread": [ - 0.07465389888464857, - 0.07465389888464857 + 0.5501957203699146, + 0.5501957203699146 ], - "best_of_k_cost": 0.07465389888464857, + "best_of_k_cost": 0.5501957203699146, "best_seed": 0, "median_seed": 0, "worst_seed": 0 }, { - "model": "wrld3_03", + "model": "cross_element", "samples": [ { "seed": 0, "metrics": { - "node_overlap": 0.015735131757445838, - "node_connector_overlap": 0.01687604720051637, + "node_overlap": 0.2043895747599453, + "node_connector_overlap": 0.060879623488298944, "label_overlap": 0.0, - "crossings": 1.2738693467336684, - "sprawl": 2.4109057250978414, - "edge_length_cv": 0.6912592137719059, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8557404213973576, + "label_connector_overlap": 0.151029094354389, + "crossings": 0.5, + "crowding": 0.2365609687528194, + "sprawl": 1.5882909233200797, + "long_connectors": 0.0, + "edge_length_cv": 0.45227255025103114, + "aspect_penalty": 0.7862803320367586, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.912716313979774, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.2783934613608023 }, - "weighted_cost": 2.215740447965794 + "weighted_cost": 2.6124481937010415 }, { "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.2043895747599453, + "node_connector_overlap": 0.05395763522098385, + "label_overlap": 0.0, + "label_connector_overlap": 0.3465092833108762, + "crossings": 0.375, + "crowding": 0.14037436036375414, + "sprawl": 1.4621858161069112, + "long_connectors": 0.0, + "edge_length_cv": 0.15900756922487203, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8315990150948197, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.5141352860514037, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.19206098053703893 }, - "weighted_cost": 2.4598283325799604 + "weighted_cost": 2.4710459561550597 }, { "seed": 2, "metrics": { - "node_overlap": 0.012919003008107444, - "node_connector_overlap": 0.007081133335195029, + "node_overlap": 0.2043895747599453, + "node_connector_overlap": 0.04748458711605549, "label_overlap": 0.0, - "crossings": 1.379396984924623, - "sprawl": 4.006889579365737, - "edge_length_cv": 0.6322929780685507, + "label_connector_overlap": 0.33084854186147, + "crossings": 0.125, + "crowding": 0.13368055555555555, + "sprawl": 1.3076047259801458, + "long_connectors": 0.0, + "edge_length_cv": 0.20688696924357516, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6399223889676684, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.4592298206273945, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.20562751154321554 }, - "weighted_cost": 2.5415266014237927 + "weighted_cost": 2.118664137362218 }, { "seed": 3, "metrics": { - "node_overlap": 0.015818098378156713, - "node_connector_overlap": 0.01694000359458034, + "node_overlap": 0.2043895747599453, + "node_connector_overlap": 0.03246361484207835, "label_overlap": 0.0, - "crossings": 1.3618090452261307, - "sprawl": 2.3962451728808656, - "edge_length_cv": 0.6990074254863038, + "label_connector_overlap": 0.34279585209295355, + "crossings": 0.125, + "crowding": 0.12196180555555555, + "sprawl": 1.5595215612838664, + "long_connectors": 0.0, + "edge_length_cv": 0.3152809745642004, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8649300916516701, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.7942029010435935, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.27482893175896606 }, - "weighted_cost": 2.304570827131361 + "weighted_cost": 2.2987129911754742 }, { "seed": 4, "metrics": { - "node_overlap": 0.01587389723774591, - "node_connector_overlap": 0.016112267552554186, + "node_overlap": 0.2043895747599459, + "node_connector_overlap": 0.03378579141677818, "label_overlap": 0.0, - "crossings": 1.4723618090452262, - "sprawl": 2.495825459016063, - "edge_length_cv": 0.6591462795229761, + "label_connector_overlap": 0.2815102256183621, + "crossings": 0.0, + "crowding": 0.22045177993641105, + "sprawl": 1.5223534249673132, + "long_connectors": 0.0, + "edge_length_cv": 0.470978273732384, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.838047164531921, + "misalignment": 0.11111111111111116, + "loop_compactness": 0.5470879543433413, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.048567333695646774 }, - "weighted_cost": 2.4235145401471594 + "weighted_cost": 2.041043595317162 }, { "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.2043895747599453, + "node_connector_overlap": 0.04559409422241866, + "label_overlap": 0.0, + "label_connector_overlap": 0.4346069900843088, + "crossings": 0.375, + "crowding": 0.1328125, + "sprawl": 1.8667905503847568, + "long_connectors": 0.0, + "edge_length_cv": 0.2973118558028373, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8483740428550308, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.5040015759096583, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.20517996229815672 }, - "weighted_cost": 2.334218844030718 + "weighted_cost": 2.6773131716431995 }, { "seed": 6, "metrics": { - "node_overlap": 0.015818098378156703, - "node_connector_overlap": 0.01499822034986526, + "node_overlap": 0.05994513031550091, + "node_connector_overlap": 0.040143424815926476, "label_overlap": 0.0, - "crossings": 1.3165829145728642, - "sprawl": 2.399513149544941, - "edge_length_cv": 0.6904307463123497, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.839578355794327, + "label_connector_overlap": 0.1668550909220817, + "crossings": 0.25, + "crowding": 0.06854214891975367, + "sprawl": 2.1336539671479353, + "long_connectors": 0.0, + "edge_length_cv": 0.329558318090864, + "aspect_penalty": 0.1315295815295816, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.822943123314879, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.25293324939130984 }, - "weighted_cost": 2.2479158142232576 + "weighted_cost": 1.769025879313271 }, { "seed": 7, "metrics": { - "node_overlap": 0.015818098378156703, - "node_connector_overlap": 0.01173347309505542, + "node_overlap": 0.2043895747599453, + "node_connector_overlap": 0.03977143246992413, "label_overlap": 0.0, - "crossings": 1.5125628140703518, - "sprawl": 2.4468128896798063, - "edge_length_cv": 0.6924514002039581, + "label_connector_overlap": 0.3019091229276845, + "crossings": 0.0, + "crowding": 0.21483900538109604, + "sprawl": 1.2933184411962333, + "long_connectors": 0.0, + "edge_length_cv": 0.2545319074999315, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8199274977010701, + "misalignment": 0.11111111111111116, + "loop_compactness": 0.5644217853189382, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.17176629114026679 }, - "weighted_cost": 2.4422305712556054 + "weighted_cost": 2.0399951310240514 }, { "seed": 42, "metrics": { - "node_overlap": 0.012919003008107444, - "node_connector_overlap": 0.007544837134564105, + "node_overlap": 0.2043895747599453, + "node_connector_overlap": 0.03824709357573146, + "label_overlap": 0.0, + "label_connector_overlap": 0.10078421929156152, + "crossings": 0.0, + "crowding": 0.21550212908439786, + "sprawl": 1.344977666692242, + "long_connectors": 0.0, + "edge_length_cv": 0.2901375528365082, + "aspect_penalty": 0.24895073500693243, + "misalignment": 0.11111111111111116, + "loop_compactness": 0.5518054002419153, + "flow_bends": 0.0, + "loop_straightness": 0.10498812980413981 + }, + "weighted_cost": 1.7371126576943634 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.2043895747599453, + "node_connector_overlap": 0.05395763522098385, + "label_overlap": 0.0, + "label_connector_overlap": 0.3465092833108762, + "crossings": 0.375, + "crowding": 0.14037436036375414, + "sprawl": 1.4621858161069112, + "long_connectors": 0.0, + "edge_length_cv": 0.15900756922487203, + "aspect_penalty": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.5141352860514037, + "flow_bends": 0.0, + "loop_straightness": 0.19206098053703893 + }, + "weighted_cost": 2.4710459561550597 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.20438957475994476, + "node_connector_overlap": 0.08546511205105828, + "label_overlap": 0.0, + "label_connector_overlap": 0.5, + "crossings": 0.375, + "crowding": 0.20662916633561346, + "sprawl": 1.4013008685890471, + "long_connectors": 0.0, + "edge_length_cv": 0.37438119897611427, + "aspect_penalty": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.4873195293257049, + "flow_bends": 0.0, + "loop_straightness": 0.16814579396149446 + }, + "weighted_cost": 2.802212732593452 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.2043895747599459, + "node_connector_overlap": 0.03852898789738477, "label_overlap": 0.0, - "crossings": 1.3241206030150754, - "sprawl": 3.959859595876896, - "edge_length_cv": 0.6558804297021629, + "label_connector_overlap": 0.10341408600121772, + "crossings": 0.375, + "crowding": 0.135354919371156, + "sprawl": 2.009499201587725, + "long_connectors": 0.036690362378647465, + "edge_length_cv": 0.714597445615422, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8555238862147909, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.9070368311076114, + "flow_bends": 0.0, + "loop_straightness": 0.27482893175896594 + }, + "weighted_cost": 2.391137365254982 + } + ], + "median_cost": 2.3449251782152283, + "spread": [ + 2.0407814792438845, + 2.5063965155415553 + ], + "best_of_k_cost": 1.7371126576943634, + "best_seed": 42, + "median_seed": 789, + "worst_seed": 456 + }, + { + "model": "arrayed_pop", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0, + "label_overlap": 0.0, + "label_connector_overlap": 0.15568282680129364, + "crossings": 0.0, + "crowding": 0.017855542082559493, + "sprawl": 1.3463727490885313, + "long_connectors": 0.0, + "edge_length_cv": 0.0759518425927967, + "aspect_penalty": 0.28135994365149086, + "misalignment": 0.0, + "loop_compactness": 0.0, + "flow_bends": 0.0, + "loop_straightness": 0.0 + }, + "weighted_cost": 0.5879729695566328 + } + ], + "median_cost": 0.5879729695566328, + "spread": [ + 0.5879729695566328, + 0.5879729695566328 + ], + "best_of_k_cost": 0.5879729695566328, + "best_seed": 0, + "median_seed": 0, + "worst_seed": 0 + }, + { + "model": "ai_edited", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.013694557960544522, + "label_overlap": 0.00026148673684916454, + "label_connector_overlap": 0.45568562377621297, + "crossings": 0.2, + "crowding": 0.023157404220743836, + "sprawl": 1.7350585180926998, + "long_connectors": 0.00025497302559060974, + "edge_length_cv": 0.5298837404528474, + "aspect_penalty": 0.0, + "misalignment": 0.5319148936170213, + "loop_compactness": 0.715042092339224, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.804519173147058 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.009562933545921248, + "label_overlap": 0.0002855994930455662, + "label_connector_overlap": 0.4269900255300387, + "crossings": 0.22857142857142856, + "crowding": 0.026065812036718046, + "sprawl": 1.6845132103459781, + "long_connectors": 0.00009469358719338286, + "edge_length_cv": 0.5449424414263883, + "aspect_penalty": 0.0, + "misalignment": 0.5789473684210527, + "loop_compactness": 0.7232043134115199, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7800284272360827 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.011638953138247428, + "label_overlap": 0.00033325500650263054, + "label_connector_overlap": 0.46877522738582256, + "crossings": 0.19047619047619047, + "crowding": 0.019347833413445762, + "sprawl": 1.6847438012231613, + "long_connectors": 0.000442501056854147, + "edge_length_cv": 0.5659437530902042, + "aspect_penalty": 0.0, + "misalignment": 0.631578947368421, + "loop_compactness": 0.7271292194699819, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.809276518555248 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.012228782200654893, + "label_overlap": 0.00030509805366980825, + "label_connector_overlap": 0.4072921808271034, + "crossings": 0.2, + "crowding": 0.027427766687565323, + "sprawl": 1.6775412115396586, + "long_connectors": 0.00040800445796101325, + "edge_length_cv": 0.5585503711799498, + "aspect_penalty": 0.0, + "misalignment": 0.6, + "loop_compactness": 0.718913716417784, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7274748086269547 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.011447994283651878, + "label_overlap": 0.0002855994930455662, + "label_connector_overlap": 0.47475145019179604, + "crossings": 0.20952380952380953, + "crowding": 0.02653459416031592, + "sprawl": 1.6964793117171, + "long_connectors": 0.0013007252658454951, + "edge_length_cv": 0.5531268810625588, + "aspect_penalty": 0.0, + "misalignment": 0.5473684210526315, + "loop_compactness": 0.700679022277094, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.828288378771653 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.009562933545921248, + "label_overlap": 0.0002855994930455662, + "label_connector_overlap": 0.4269900255300387, + "crossings": 0.22857142857142856, + "crowding": 0.026065812036718046, + "sprawl": 1.6845132103459781, + "long_connectors": 0.00009469358719338286, + "edge_length_cv": 0.5449424414263883, + "aspect_penalty": 0.0, + "misalignment": 0.5789473684210527, + "loop_compactness": 0.7232043134115199, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7800284272360827 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.01048780414889235, + "label_overlap": 0.00021967591092924717, + "label_connector_overlap": 0.44067089924169023, + "crossings": 0.19047619047619047, + "crowding": 0.024506237212747896, + "sprawl": 1.696941433294048, + "long_connectors": 0.00005330792760332216, + "edge_length_cv": 0.5403344574084297, + "aspect_penalty": 0.0, + "misalignment": 0.5368421052631579, + "loop_compactness": 0.723816866157302, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7616347912426324 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.009936137118681018, + "label_overlap": 0.0002855994930455662, + "label_connector_overlap": 0.41635014814152804, + "crossings": 0.22857142857142856, + "crowding": 0.024784562084357004, + "sprawl": 1.6821232303118019, + "long_connectors": 0.00009469358719338286, + "edge_length_cv": 0.5420324031953525, + "aspect_penalty": 0.0, + "misalignment": 0.5789473684210527, + "loop_compactness": 0.7232043134115199, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.762936273337931 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.010670272600082801, + "label_overlap": 0.0002855994930455662, + "label_connector_overlap": 0.43271391685171456, + "crossings": 0.22857142857142856, + "crowding": 0.026065812036718046, + "sprawl": 1.700785729925124, + "long_connectors": 0.0002725223975255504, + "edge_length_cv": 0.53627086427199, + "aspect_penalty": 0.0, + "misalignment": 0.5578947368421052, + "loop_compactness": 0.7252669344572478, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7937057718872684 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.01488480016081996, + "label_overlap": 0.0002522009235372137, + "label_connector_overlap": 0.45640211279403425, + "crossings": 0.2, + "crowding": 0.02363768835060908, + "sprawl": 1.6851388030810486, + "long_connectors": 0.0010329411898757214, + "edge_length_cv": 0.5535939290017915, + "aspect_penalty": 0.0, + "misalignment": 0.5319148936170213, + "loop_compactness": 0.7222903559191249, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7992305356188043 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.01398956509162962, + "label_overlap": 0.0002984518167107434, + "label_connector_overlap": 0.4618764585178653, + "crossings": 0.18095238095238095, + "crowding": 0.030853983008026338, + "sprawl": 1.6887927517140062, + "long_connectors": 0.0001067117136648143, + "edge_length_cv": 0.5563941299831441, + "aspect_penalty": 0.0, + "misalignment": 0.5638297872340425, + "loop_compactness": 0.7202424271245069, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.7968048280660645 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.012894769358283226, + "label_overlap": 0.00028223949900973603, + "label_connector_overlap": 0.44500580599051903, + "crossings": 0.23809523809523808, + "crowding": 0.024717549543468383, + "sprawl": 1.693249139317837, + "long_connectors": 0.00036045512160048026, + "edge_length_cv": 0.5466254311343512, + "aspect_penalty": 0.0, + "misalignment": 0.5625, + "loop_compactness": 0.7118726224687183, + "flow_bends": 0.0, + "loop_straightness": 0.9642857142857143 + }, + "weighted_cost": 1.818019006393904 + } + ], + "median_cost": 1.7952552999766664, + "spread": [ + 1.7757553887615447, + 1.8057085094991054 + ], + "best_of_k_cost": 1.7937057718872684, + "best_seed": 3, + "median_seed": 42, + "worst_seed": 4 + }, + { + "model": "wrld3_03", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.015572388693763017, + "label_overlap": 0.0, + "label_connector_overlap": 0.5286197245943369, + "crossings": 1.2185929648241205, + "crowding": 0.0038362452071276903, + "sprawl": 2.525411610958908, + "long_connectors": 0.010973751568254156, + "edge_length_cv": 0.6914077229100626, + "aspect_penalty": 0.0, + "misalignment": 0.5859154929577465, + "loop_compactness": 0.8560717103122828, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.1691461949504736 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.012502333137228771, + "label_overlap": 0.0, + "label_connector_overlap": 0.5004102163223417, + "crossings": 1.271356783919598, + "crowding": 0.0010248893304223008, + "sprawl": 3.3839607131009264, + "long_connectors": 0.008262870933632718, + "edge_length_cv": 0.6558585682918281, + "aspect_penalty": 0.0, + "misalignment": 0.6761363636363636, + "loop_compactness": 0.8315265988369029, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.3831301623440884 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.005535729158511072, + "label_overlap": 0.0, + "label_connector_overlap": 0.4658818598589807, + "crossings": 1.3819095477386936, + "crowding": 0.00039847283224037907, + "sprawl": 4.506558146886454, + "long_connectors": 0.006976775473588293, + "edge_length_cv": 0.6324108069090563, + "aspect_penalty": 0.0, + "misalignment": 0.7655367231638418, + "loop_compactness": 0.6419705976766702, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.6404547132175393 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0107562111632543, + "label_overlap": 0.0, + "label_connector_overlap": 0.503723708759342, + "crossings": 1.4020100502512562, + "crowding": 0.0010123876941172227, + "sprawl": 3.320240625705552, + "long_connectors": 0.011693760116720568, + "edge_length_cv": 0.6904975184787713, + "aspect_penalty": 0.0, + "misalignment": 0.6676136363636364, + "loop_compactness": 0.8651852490477071, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.513645531846742 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.012941227991718388, + "label_overlap": 0.0, + "label_connector_overlap": 0.5516631938266956, + "crossings": 1.4673366834170853, + "crowding": 0.004187679045091946, + "sprawl": 2.6133356388012308, + "long_connectors": 0.006562510437388137, + "edge_length_cv": 0.6590378494054465, + "aspect_penalty": 0.0, + "misalignment": 0.6142857142857143, + "loop_compactness": 0.8380507083644845, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.4629482375746767 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.014930233150242491, + "label_overlap": 0.0007051551101393699, + "label_connector_overlap": 0.5528689386771439, + "crossings": 1.2010050251256281, + "crowding": 0.023757123057085043, + "sprawl": 1.8820374606643302, + "long_connectors": 0.013581226879381575, + "edge_length_cv": 0.7010283355165973, + "aspect_penalty": 0.0, + "misalignment": 0.49857549857549854, + "loop_compactness": 0.8486902073837164, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.0378102854968643 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.013245348564510508, + "label_overlap": 0.0, + "label_connector_overlap": 0.5276135676132545, + "crossings": 1.3165829145728642, + "crowding": 0.004723621382385712, + "sprawl": 2.5130623675228265, + "long_connectors": 0.013776823274982659, + "edge_length_cv": 0.6899016905726063, + "aspect_penalty": 0.0, + "misalignment": 0.6136363636363636, + "loop_compactness": 0.839578355794327, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.2563491753993703 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0073836956987028655, + "label_overlap": 0.0, + "label_connector_overlap": 0.5247141017615626, + "crossings": 1.5251256281407035, + "crowding": 0.0018815581981534316, + "sprawl": 3.396148422003256, + "long_connectors": 0.010053695349774483, + "edge_length_cv": 0.682760537856356, + "aspect_penalty": 0.0, + "misalignment": 0.6875, + "loop_compactness": 0.8200117888058551, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.6644470077723024 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0063712236126864125, + "label_overlap": 0.0, + "label_connector_overlap": 0.47821530978324406, + "crossings": 1.3190954773869348, + "crowding": 0.0004719890333401077, + "sprawl": 4.456902366346363, + "long_connectors": 0.008318991589672602, + "edge_length_cv": 0.655612664236303, + "aspect_penalty": 0.0, + "misalignment": 0.8192090395480226, + "loop_compactness": 0.8555230827709296, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.676930711460767 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.007214819571877599, + "label_overlap": 0.0, + "label_connector_overlap": 0.5023844723238251, + "crossings": 1.6055276381909547, + "crowding": 0.0009787724277578872, + "sprawl": 3.383480650040559, + "long_connectors": 0.01457076554286801, + "edge_length_cv": 0.6981814201197211, + "aspect_penalty": 0.0, + "misalignment": 0.6864406779661016, + "loop_compactness": 0.8314813707773371, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.713687528332976 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.010161402179077524, + "label_overlap": 0.00025957170668397143, + "label_connector_overlap": 0.5215620733243398, + "crossings": 1.4195979899497488, + "crowding": 0.0038521451647914873, + "sprawl": 2.5835231277998347, + "long_connectors": 0.007634843990830857, + "edge_length_cv": 0.670767435822502, + "aspect_penalty": 0.0, + "misalignment": 0.6118980169971671, + "loop_compactness": 0.8598660159507296, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.3666415711536337 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.009844350050413779, + "label_overlap": 0.0, + "label_connector_overlap": 0.5340388187567631, + "crossings": 1.5025125628140703, + "crowding": 0.004288074050205525, + "sprawl": 2.5550483942086095, + "long_connectors": 0.01161098769101577, + "edge_length_cv": 0.6899247978227445, + "aspect_penalty": 0.0, + "misalignment": 0.546742209631728, + "loop_compactness": 0.8680142533534396, + "flow_bends": 0.0, + "loop_straightness": 0.8478260869565217 + }, + "weighted_cost": 3.4587776884981096 + } + ], + "median_cost": 3.4608629630363934, + "spread": [ + 3.3390684722150676, + 3.64645278685623 + ], + "best_of_k_cost": 3.3666415711536337, + "best_seed": 5, + "median_seed": 4, + "worst_seed": 123 + }, + { + "model": "beer_game", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.020022448120181228, + "label_overlap": 0.0, + "label_connector_overlap": 0.5175683482708673, + "crossings": 0.62, + "crowding": 0.011086673116601883, + "sprawl": 2.2790534285651685, + "long_connectors": 0.0, + "edge_length_cv": 0.4503255017098236, + "aspect_penalty": 0.0, + "misalignment": 0.4642857142857143, + "loop_compactness": 0.68449242284427, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.4220883740862216 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.020158651542442854, + "label_overlap": 0.0, + "label_connector_overlap": 0.44278162866156956, + "crossings": 0.69, + "crowding": 0.0032893846960740415, + "sprawl": 2.257574661334337, + "long_connectors": 0.0, + "edge_length_cv": 0.4637480668662704, + "aspect_penalty": 0.0, + "misalignment": 0.40476190476190477, + "loop_compactness": 0.7055014757109696, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.3694649614828616 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.020022448120181228, + "label_overlap": 0.0, + "label_connector_overlap": 0.5175683482708673, + "crossings": 0.62, + "crowding": 0.011086673116601883, + "sprawl": 2.2790534285651685, + "long_connectors": 0.0, + "edge_length_cv": 0.4503255017098236, + "aspect_penalty": 0.0, + "misalignment": 0.4642857142857143, + "loop_compactness": 0.68449242284427, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.4220883740862216 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.02066485651295293, + "label_overlap": 0.0, + "label_connector_overlap": 0.4557000963010446, + "crossings": 0.6, + "crowding": 0.006875258586801746, + "sprawl": 2.336301974877801, + "long_connectors": 0.0, + "edge_length_cv": 0.4905588594842285, + "aspect_penalty": 0.0, + "misalignment": 0.45783132530120485, + "loop_compactness": 0.6953539729374377, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.324370716104205 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.018293366308734807, + "label_overlap": 0.0, + "label_connector_overlap": 0.469675453959938, + "crossings": 0.6, + "crowding": 0.004903458201487479, + "sprawl": 2.2351001735332336, + "long_connectors": 0.0, + "edge_length_cv": 0.47972145484406864, + "aspect_penalty": 0.0, + "misalignment": 0.42352941176470593, + "loop_compactness": 0.682473153680573, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.304736002406257 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.019178672719521256, + "label_overlap": 0.0, + "label_connector_overlap": 0.4802064017697673, + "crossings": 0.63, + "crowding": 0.010008889145096758, + "sprawl": 2.2824991318413717, + "long_connectors": 0.0, + "edge_length_cv": 0.5027084630876966, + "aspect_penalty": 0.0, + "misalignment": 0.47619047619047616, + "loop_compactness": 0.7406729000260835, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.3978042124439987 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.006285108984130271, + "label_overlap": 0.0, + "label_connector_overlap": 0.43255927743090594, + "crossings": 0.67, + "crowding": 0.0, + "sprawl": 4.053217292216389, + "long_connectors": 0.0, + "edge_length_cv": 0.457079862849838, + "aspect_penalty": 0.0, + "misalignment": 0.5, + "loop_compactness": 0.7322735054969137, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.772238243982867 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.022406190535740552, + "label_overlap": 0.0, + "label_connector_overlap": 0.4632725659523487, + "crossings": 0.59, + "crowding": 0.017978555595564494, + "sprawl": 2.2740395076391375, + "long_connectors": 0.0, + "edge_length_cv": 0.48979786972697426, + "aspect_penalty": 0.0, + "misalignment": 0.426829268292683, + "loop_compactness": 0.6779565732111612, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.3146906032344705 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.020022448120181228, + "label_overlap": 0.0, + "label_connector_overlap": 0.5175683482708673, + "crossings": 0.62, + "crowding": 0.011086673116601883, + "sprawl": 2.2790534285651685, + "long_connectors": 0.0, + "edge_length_cv": 0.4503255017098236, + "aspect_penalty": 0.0, + "misalignment": 0.4642857142857143, + "loop_compactness": 0.68449242284427, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.4220883740862216 + }, + { + "seed": 123, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.018295496265333687, + "label_overlap": 0.0, + "label_connector_overlap": 0.44584119673352934, + "crossings": 0.65, + "crowding": 0.004405830521328966, + "sprawl": 2.31937981273737, + "long_connectors": 0.0, + "edge_length_cv": 0.45781394079774046, + "aspect_penalty": 0.0, + "misalignment": 0.36585365853658536, + "loop_compactness": 0.7337261998113247, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.3542948017302057 + }, + { + "seed": 456, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.0438897516083697, + "label_overlap": 0.0, + "label_connector_overlap": 0.41039526182061165, + "crossings": 0.59, + "crowding": 0.004776683818501667, + "sprawl": 2.2554744659811647, + "long_connectors": 0.0, + "edge_length_cv": 0.5206409279680329, + "aspect_penalty": 0.0, + "misalignment": 0.45238095238095233, + "loop_compactness": 0.721850987510048, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.280611571118949 + }, + { + "seed": 789, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.024482980383915953, + "label_overlap": 0.0, + "label_connector_overlap": 0.5041843536311619, + "crossings": 0.62, + "crowding": 0.011915132327146506, + "sprawl": 2.312090770180308, + "long_connectors": 0.0, + "edge_length_cv": 0.44618476652345873, + "aspect_penalty": 0.0, + "misalignment": 0.4285714285714286, + "loop_compactness": 0.6844924228442685, + "flow_bends": 0.0, + "loop_straightness": 0.8461538461538461 + }, + "weighted_cost": 2.416449812697033 + } + ], + "median_cost": 2.38363458696343, + "spread": [ + 2.3219506878867713, + 2.4220883740862216 + ], + "best_of_k_cost": 2.280611571118949, + "best_seed": 456, + "median_seed": 1, + "worst_seed": 6 + }, + { + "model": "wonderland", + "samples": [ + { + "seed": 0, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.011842011400760587, + "label_overlap": 0.0005204014619917476, + "label_connector_overlap": 0.40668326288108675, + "crossings": 0.1346153846153846, + "crowding": 0.0264317350930829, + "sprawl": 1.5529742712604704, + "long_connectors": 0.0027890050349126208, + "edge_length_cv": 0.5657597957286353, + "aspect_penalty": 0.0, + "misalignment": 0.5510204081632653, + "loop_compactness": 0.6521605242362543, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.5637202243304535 + }, + { + "seed": 1, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.005324047963756839, + "label_overlap": 0.0004505991710809624, + "label_connector_overlap": 0.30129079688586385, + "crossings": 0.1346153846153846, + "crowding": 0.032845810461274674, + "sprawl": 1.4963218868749228, + "long_connectors": 0.006802760590201837, + "edge_length_cv": 0.5914107229358416, + "aspect_penalty": 0.0, + "misalignment": 0.5306122448979591, + "loop_compactness": 0.48166469605376566, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.3163699998953475 + }, + { + "seed": 2, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.014472351378418189, + "label_overlap": 0.010479953287388053, + "label_connector_overlap": 0.30369670672719906, + "crossings": 0.15384615384615385, + "crowding": 0.05373601470969349, + "sprawl": 1.4889559711168476, + "long_connectors": 0.0056586344527813125, + "edge_length_cv": 0.5680100171972058, + "aspect_penalty": 0.0, + "misalignment": 0.5918367346938775, + "loop_compactness": 0.5379659453871359, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.439728591077647 + }, + { + "seed": 3, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.007731219423780055, + "label_overlap": 0.0005420860636048062, + "label_connector_overlap": 0.34007383780959644, + "crossings": 0.15384615384615385, + "crowding": 0.04336734693877551, + "sprawl": 1.5626531327019215, + "long_connectors": 0.004460242288205139, + "edge_length_cv": 0.5661788104752684, + "aspect_penalty": 0.0, + "misalignment": 0.4897959183673469, + "loop_compactness": 0.47216302249773134, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.4169606642633725 + }, + { + "seed": 4, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.013787441331784701, + "label_overlap": 0.000640575891404105, + "label_connector_overlap": 0.40131037847797, + "crossings": 0.11538461538461539, + "crowding": 0.05437427107608682, + "sprawl": 1.493420920985673, + "long_connectors": 0.013342161661229354, + "edge_length_cv": 0.6473545550538722, + "aspect_penalty": 0.0, + "misalignment": 0.5800000000000001, + "loop_compactness": 0.5335232531681342, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.5145154263438896 + }, + { + "seed": 5, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.016171780908997326, + "label_overlap": 0.0005289017893514109, + "label_connector_overlap": 0.3349754434088236, + "crossings": 0.21153846153846154, + "crowding": 0.046252680946694345, + "sprawl": 1.4847509813384698, + "long_connectors": 0.011184212778795092, + "edge_length_cv": 0.6119508656460726, + "aspect_penalty": 0.0, + "misalignment": 0.6122448979591837, + "loop_compactness": 0.49107389469874596, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.4904213866170093 + }, + { + "seed": 6, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.009231687427713395, + "label_overlap": 0.0005816742599001578, + "label_connector_overlap": 0.3266396217749026, + "crossings": 0.1346153846153846, + "crowding": 0.043572758545151975, + "sprawl": 1.545578273940615, + "long_connectors": 0.0037165075658005856, + "edge_length_cv": 0.5218737705490942, + "aspect_penalty": 0.0, + "misalignment": 0.5714285714285714, + "loop_compactness": 0.5343484186756164, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.4093203190075871 + }, + { + "seed": 7, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.013015432708125456, + "label_overlap": 0.000637326095233826, + "label_connector_overlap": 0.3161425747189096, + "crossings": 0.15384615384615385, + "crowding": 0.05295696485537152, + "sprawl": 1.4929536005749129, + "long_connectors": 0.006419776893031433, + "edge_length_cv": 0.5631581836353289, + "aspect_penalty": 0.0, + "misalignment": 0.5510204081632653, + "loop_compactness": 0.5240520484752231, + "flow_bends": 0.0, + "loop_straightness": 0.6153846153846154 + }, + "weighted_cost": 1.4119880978645805 + }, + { + "seed": 42, + "metrics": { + "node_overlap": 0.0, + "node_connector_overlap": 0.006561082178295447, + "label_overlap": 0.00046105628298931823, + "label_connector_overlap": 0.3330283153470702, + "crossings": 0.11538461538461539, + "crowding": 0.0337799242133768, + "sprawl": 1.5217518265222731, + "long_connectors": 0.005017634321620256, + "edge_length_cv": 0.5716290091088273, + "aspect_penalty": 0.0, + "misalignment": 0.5510204081632653, + "loop_compactness": 0.5216707279949413, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.6153846153846154 }, - "weighted_cost": 2.563548525514695 + "weighted_cost": 1.371698441309794 }, { "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.00992736494588411, + "label_overlap": 0.010752053801823687, + "label_connector_overlap": 0.36752215048680975, + "crossings": 0.1346153846153846, + "crowding": 0.024556510919070195, + "sprawl": 1.5410193916827395, + "long_connectors": 0.016214352170625747, + "edge_length_cv": 0.6350349742195764, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8311110329168642, + "misalignment": 0.5918367346938775, + "loop_compactness": 0.6231344218760133, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.6153846153846154 }, - "weighted_cost": 2.5064226806466245 + "weighted_cost": 1.5312799672270727 }, { "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.014183965092465015, + "label_overlap": 0.000637326095233826, + "label_connector_overlap": 0.31980847737820195, + "crossings": 0.1346153846153846, + "crowding": 0.04441010756170548, + "sprawl": 1.4828342868602136, + "long_connectors": 0.006314976320968418, + "edge_length_cv": 0.5660511304523829, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8601128693414721, + "misalignment": 0.5510204081632653, + "loop_compactness": 0.5878985778713302, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.6153846153846154 }, - "weighted_cost": 2.6426622055590925 + "weighted_cost": 1.4150027731414991 }, { "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.0029608620660240602, + "label_overlap": 0.0006507472897726539, + "label_connector_overlap": 0.3250468195269403, + "crossings": 0.19230769230769232, + "crowding": 0.06553827222910726, + "sprawl": 1.3932971171349964, + "long_connectors": 0.018725444959522983, + "edge_length_cv": 0.7379166733126099, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8680142533534396, + "misalignment": 0.653061224489796, + "loop_compactness": 0.7124993063621157, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.6153846153846154 }, - "weighted_cost": 2.4426836313325775 + "weighted_cost": 1.5231468417692604 } ], - "median_cost": 2.4424571012940914, + "median_cost": 1.4283446276705098, "spread": [ - 2.326806839805879, - 2.515198660840917 + 1.411321153150332, + 1.5166732802002323 ], - "best_of_k_cost": 2.4426836313325775, - "best_seed": 0, - "median_seed": 7, - "worst_seed": 456 + "best_of_k_cost": 1.371698441309794, + "best_seed": 1, + "median_seed": 2, + "worst_seed": 0 }, { - "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.013693770398751262, + "node_connector_overlap": 0.01040053079729197, + "label_overlap": 0.00001874162477953097, + "label_connector_overlap": 0.4124708370064381, + "crossings": 0.21951219512195122, + "crowding": 0.023895997723297473, + "sprawl": 1.2781649381210607, + "long_connectors": 0.0029820176477094945, + "edge_length_cv": 0.4294161464441858, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.685757139303958, + "misalignment": 0.4482758620689655, + "loop_compactness": 0.7464557344017121, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.2127779595817625 }, - "weighted_cost": 1.558500244276022 + "weighted_cost": 1.616629221311725 }, { "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.013853000287108834, + "node_connector_overlap": 0.008177774367075051, + "label_overlap": 0.00004210393544710142, + "label_connector_overlap": 0.3843000311993992, + "crossings": 0.2073170731707317, + "crowding": 0.015687470520710595, + "sprawl": 1.243286315194182, + "long_connectors": 0.0032976256214132713, + "edge_length_cv": 0.4426922108323814, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7055014757109696, + "misalignment": 0.5232558139534884, + "loop_compactness": 0.5549144982099412, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.12668151475175202 }, - "weighted_cost": 1.527138214921332 + "weighted_cost": 1.4638729277673899 }, { "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.013853000287108834, + "node_connector_overlap": 0.011693124832717623, + "label_overlap": 0.0012563703182779586, + "label_connector_overlap": 0.42554222819781423, + "crossings": 0.17073170731707318, + "crowding": 0.0297290997032429, + "sprawl": 1.2346960587881983, + "long_connectors": 0.0033347979106033462, + "edge_length_cv": 0.4486734135227615, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.685757139303958, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.4988691465982324, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.16634680892458886 }, - "weighted_cost": 1.558500244276022 + "weighted_cost": 1.4880785771924065 }, { "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.021143749113097788, + "node_connector_overlap": 0.01921866276230942, + "label_overlap": 0.0004684226946538357, + "label_connector_overlap": 0.38067858892828377, + "crossings": 0.15853658536585366, + "crowding": 0.09838295224096388, + "sprawl": 0.9067191473356297, + "long_connectors": 0.0037304188372425753, + "edge_length_cv": 0.4493452495287874, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6988574379764088, + "misalignment": 0.3908045977011494, + "loop_compactness": 0.5128974548898316, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.20481209201322984 }, - "weighted_cost": 1.4684100119126462 + "weighted_cost": 1.435282995030892 }, { "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.0287838270303383, + "node_connector_overlap": 0.0324212216889203, + "label_overlap": 0.000896780968488125, + "label_connector_overlap": 0.4048947719037728, + "crossings": 0.17073170731707318, + "crowding": 0.122552466822154, + "sprawl": 0.9292113903786264, + "long_connectors": 0.002859175629347797, + "edge_length_cv": 0.453006162513223, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6881830187329596, + "misalignment": 0.31034482758620685, + "loop_compactness": 0.6089264638380886, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.18591454340072558 }, - "weighted_cost": 1.455594868955288 + "weighted_cost": 1.5962798614118787 }, { "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.013853000287108834, + "node_connector_overlap": 0.014489617529305821, + "label_overlap": 0.000030464344305507277, + "label_connector_overlap": 0.41637078681948175, + "crossings": 0.2073170731707317, + "crowding": 0.020940648545613613, + "sprawl": 1.2922438063232113, + "long_connectors": 0.002393664549133571, + "edge_length_cv": 0.43829912953863087, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7406286565308288, + "misalignment": 0.4418604651162791, + "loop_compactness": 0.5315608934888757, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.24201841826002984 }, - "weighted_cost": 1.632065157925064 + "weighted_cost": 1.5356552928026808 }, { "seed": 6, "metrics": { - "node_overlap": 0.02944624696676391, - "node_connector_overlap": 0.015091972533178967, + "node_overlap": 0.006273327591157072, + "node_connector_overlap": 0.007824231364931798, "label_overlap": 0.0, - "crossings": 0.66, - "sprawl": 2.499588682224831, - "edge_length_cv": 0.46767479443480164, + "label_connector_overlap": 0.4531799416336009, + "crossings": 0.21951219512195122, + "crowding": 0.008980077464608155, + "sprawl": 1.6526807925894411, + "long_connectors": 0.0030917651484321765, + "edge_length_cv": 0.41159900167398755, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7318127728530209, + "misalignment": 0.5, + "loop_compactness": 0.48681199271656445, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.20310364595729763 }, - "weighted_cost": 1.581796449701502 + "weighted_cost": 1.625618536739806 }, { "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.013693770398751262, + "node_connector_overlap": 0.011546298535072044, + "label_overlap": 0.00002274816601542367, + "label_connector_overlap": 0.43963245756961566, + "crossings": 0.1951219512195122, + "crowding": 0.015965983127226267, + "sprawl": 1.2763693615305314, + "long_connectors": 0.002716792524725256, + "edge_length_cv": 0.3992024014964546, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6779565732111612, + "misalignment": 0.4367816091954023, + "loop_compactness": 0.555573099938561, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.1848187017899759 }, - "weighted_cost": 1.412166772338616 + "weighted_cost": 1.5464770404669472 }, { "seed": 42, "metrics": { - "node_overlap": 0.029446246966763904, - "node_connector_overlap": 0.017133000809880207, + "node_overlap": 0.006201220377465587, + "node_connector_overlap": 0.004767100232412607, "label_overlap": 0.0, - "crossings": 0.65, - "sprawl": 2.5150137808120516, - "edge_length_cv": 0.44009345332440797, + "label_connector_overlap": 0.4145773333687313, + "crossings": 0.1951219512195122, + "crowding": 0.013809330444728509, + "sprawl": 1.6800513393052905, + "long_connectors": 0.0024891989189802016, + "edge_length_cv": 0.39458057183927747, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.685757139303958, + "misalignment": 0.5632183908045977, + "loop_compactness": 0.5377457099744919, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.18381008024520223 }, - "weighted_cost": 1.558500244276022 + "weighted_cost": 1.573094318883882 }, { "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.013853000287108834, + "node_connector_overlap": 0.008448316467509199, + "label_overlap": 0.000014396589012814155, + "label_connector_overlap": 0.39658420462794564, + "crossings": 0.17073170731707318, + "crowding": 0.017419881472261593, + "sprawl": 1.2613080959372562, + "long_connectors": 0.0024464178979556515, + "edge_length_cv": 0.39457366601434846, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7338360215760534, + "misalignment": 0.4767441860465116, + "loop_compactness": 0.5820206447378818, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.18798941983588344 }, - "weighted_cost": 1.5187262548688771 + "weighted_cost": 1.4642922691493816 }, { "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.013853000287108834, + "node_connector_overlap": 0.012378620643396347, + "label_overlap": 0.000027513190235392115, + "label_connector_overlap": 0.347356779119712, + "crossings": 0.17073170731707318, + "crowding": 0.017528618661598803, + "sprawl": 1.23137617957387, + "long_connectors": 0.004275363577927803, + "edge_length_cv": 0.4724448722728046, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7200247915273112, + "misalignment": 0.4418604651162791, + "loop_compactness": 0.5959226302920894, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.21156720578845886 }, - "weighted_cost": 1.45883265176052 + "weighted_cost": 1.3963280790054786 }, { "seed": 789, "metrics": { - "node_overlap": 0.029446246966763904, - "node_connector_overlap": 0.019095047467563353, + "node_overlap": 0.006201220377465587, + "node_connector_overlap": 0.007679529802912755, "label_overlap": 0.0, - "crossings": 0.63, - "sprawl": 2.5520377966516854, - "edge_length_cv": 0.4357793844159173, + "label_connector_overlap": 0.40193789818182907, + "crossings": 0.2073170731707317, + "crowding": 0.010403474485490227, + "sprawl": 1.6867088223967823, + "long_connectors": 0.002428130642699764, + "edge_length_cv": 0.4202411369545741, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6857571393039583, + "misalignment": 0.5632183908045977, + "loop_compactness": 0.561359966904477, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.21721872263904585 }, - "weighted_cost": 1.5478670941016321 + "weighted_cost": 1.5831696948826213 } ], - "median_cost": 1.537502654511482, + "median_cost": 1.541066166634814, "spread": [ - 1.4660156718746147, - 1.558500244276022 + 1.4641874338038838, + 1.5864472365149356 ], - "best_of_k_cost": 1.45883265176052, - "best_seed": 7, - "median_seed": 1, - "worst_seed": 5 + "best_of_k_cost": 1.3963280790054786, + "best_seed": 456, + "median_seed": 5, + "worst_seed": 6 }, { - "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6521605242362543, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008997867803837952, + "node_connector_overlap": 0.056117291570325314, + "label_overlap": 0.007172114756029206, + "label_connector_overlap": 0.42495936655061023, + "crossings": 0.5, + "crowding": 0.038456679800438945, + "sprawl": 1.2480236919234147, + "long_connectors": 0.014246592189297707, + "edge_length_cv": 0.649043414753701, + "aspect_penalty": 0.10873543260381124, + "misalignment": 0.5223880597014925, + "loop_compactness": 0.5813712753042742, + "flow_bends": 0.4, + "loop_straightness": 0.2381738983218848 }, - "weighted_cost": 0.8558066791953833 + "weighted_cost": 2.03245917672609 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.48166469605376566, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008997867803837952, + "node_connector_overlap": 0.04627031842754376, + "label_overlap": 0.005092175131001212, + "label_connector_overlap": 0.3784484947184568, + "crossings": 0.5833333333333334, + "crowding": 0.01147438510145448, + "sprawl": 1.7342651913624505, + "long_connectors": 0.006758554404669946, + "edge_length_cv": 0.6033437653521346, + "aspect_penalty": 0.027190909568809207, + "misalignment": 0.6119402985074627, + "loop_compactness": 0.6102178515054799, + "flow_bends": 0.4, + "loop_straightness": 0.24164193357938007 }, - "weighted_cost": 0.7667704936061046 + "weighted_cost": 2.1307271864933215 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5379659453871359, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008997867803837952, + "node_connector_overlap": 0.03573064214310311, + "label_overlap": 0.005111791993510558, + "label_connector_overlap": 0.4362971349060946, + "crossings": 0.5972222222222222, + "crowding": 0.012261710226586345, + "sprawl": 2.273217927634735, + "long_connectors": 0.008997014153906725, + "edge_length_cv": 0.5985271505017521, + "aspect_penalty": 0.037957684434125394, + "misalignment": 0.6865671641791045, + "loop_compactness": 0.56258535983816, + "flow_bends": 0.4, + "loop_straightness": 0.2508288549147973 }, - "weighted_cost": 1.196220898567232 + "weighted_cost": 2.3363514632151676 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.47216302249773134, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.041092723582942095, + "label_overlap": 0.005023083817651139, + "label_connector_overlap": 0.40314731564208844, + "crossings": 0.6041666666666666, + "crowding": 0.009722222222222222, + "sprawl": 1.6961161925709245, + "long_connectors": 0.01026561225734679, + "edge_length_cv": 0.6011729769877637, + "aspect_penalty": 0.12221656647654777, + "misalignment": 0.5851851851851853, + "loop_compactness": 0.5547687466913905, + "flow_bends": 0.4, + "loop_straightness": 0.2553688036239825 }, - "weighted_cost": 0.8015075775781749 + "weighted_cost": 2.144760113967822 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5335232531681342, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008800834202294057, + "node_connector_overlap": 0.041666750916489234, + "label_overlap": 0.005027728938392752, + "label_connector_overlap": 0.41937743421096524, + "crossings": 0.5902777777777778, + "crowding": 0.011984210955510631, + "sprawl": 1.6962185447019416, + "long_connectors": 0.013232848451165739, + "edge_length_cv": 0.6154940892120532, + "aspect_penalty": 0.10230283951998764, + "misalignment": 0.5912408759124088, + "loop_compactness": 0.5630739096954348, + "flow_bends": 0.4, + "loop_straightness": 0.2601693464837388 }, - "weighted_cost": 0.7845906104435901 + "weighted_cost": 2.1641032593939755 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.49107389469874596, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.045099553913648206, + "label_overlap": 0.005135457024607648, + "label_connector_overlap": 0.3926419713705743, + "crossings": 0.5833333333333334, + "crowding": 0.01119393854351798, + "sprawl": 1.7251165730225821, + "long_connectors": 0.00844346628779814, + "edge_length_cv": 0.5812379085500089, + "aspect_penalty": 0.09708191476799466, + "misalignment": 0.5629629629629629, + "loop_compactness": 0.5511661264801115, + "flow_bends": 0.4, + "loop_straightness": 0.263766152488173 }, - "weighted_cost": 0.8579859339093865 + "weighted_cost": 2.1215629341420983 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5343484186756164, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008865546218487395, + "node_connector_overlap": 0.043190072390752406, + "label_overlap": 0.005006215932730438, + "label_connector_overlap": 0.3984706099898476, + "crossings": 0.5763888888888888, + "crowding": 0.01870019798855911, + "sprawl": 1.6986723176494816, + "long_connectors": 0.00958494564369222, + "edge_length_cv": 0.6183372482442994, + "aspect_penalty": 0.12353458757644753, + "misalignment": 0.5808823529411764, + "loop_compactness": 0.5458379516310521, + "flow_bends": 0.4, + "loop_straightness": 0.2724730904381128 }, - "weighted_cost": 1.660989328899629 + "weighted_cost": 2.120857591397553 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5240520484752231, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008931216931216932, + "node_connector_overlap": 0.04020512946928141, + "label_overlap": 0.005048072957976342, + "label_connector_overlap": 0.3973786263942085, + "crossings": 0.5763888888888888, + "crowding": 0.010373100075321167, + "sprawl": 1.7212334916872887, + "long_connectors": 0.008630233840349744, + "edge_length_cv": 0.5986594565968657, + "aspect_penalty": 0.11566683046285586, + "misalignment": 0.6222222222222222, + "loop_compactness": 0.6120389917258025, + "flow_bends": 0.4, + "loop_straightness": 0.22924044404087426 }, - "weighted_cost": 0.8167709051020915 + "weighted_cost": 2.1367530552648897 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5216707279949413, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008865546218487395, + "node_connector_overlap": 0.04511675601648066, + "label_overlap": 0.005047636154244, + "label_connector_overlap": 0.41487452076586695, + "crossings": 0.5486111111111112, + "crowding": 0.01087771489282873, + "sprawl": 1.6894761193326395, + "long_connectors": 0.011115705948597305, + "edge_length_cv": 0.6045190039096039, + "aspect_penalty": 0.04229204144137433, + "misalignment": 0.6029411764705883, + "loop_compactness": 0.5483328477985437, + "flow_bends": 0.4, + "loop_straightness": 0.2545527197047442 }, - "weighted_cost": 0.7700952448215446 + "weighted_cost": 2.113739669034671 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.6231344218760133, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008865546218487395, + "node_connector_overlap": 0.036167770221092396, + "label_overlap": 0.005085212275962662, + "label_connector_overlap": 0.4120992564236773, + "crossings": 0.5277777777777778, + "crowding": 0.011275739842895658, + "sprawl": 2.234051544315823, + "long_connectors": 0.00946898570472732, + "edge_length_cv": 0.6093207420467147, + "aspect_penalty": 0.04149108992339423, + "misalignment": 0.6470588235294117, + "loop_compactness": 0.558037841119737, + "flow_bends": 0.4, + "loop_straightness": 0.235194272446005 }, - "weighted_cost": 1.230634863287723 + "weighted_cost": 2.2130534224057055 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5878985778713302, - "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "node_overlap": 0.008997867803837952, + "node_connector_overlap": 0.04087019476408071, + "label_overlap": 0.005108821586583814, + "label_connector_overlap": 0.41089624242178524, + "crossings": 0.5694444444444444, + "crowding": 0.010920476763439232, + "sprawl": 1.7021859505723111, + "long_connectors": 0.008926179613588867, + "edge_length_cv": 0.6065325880351983, + "aspect_penalty": 0.07232561034013862, + "misalignment": 0.5447761194029851, + "loop_compactness": 0.5681472059896638, + "flow_bends": 0.4, + "loop_straightness": 0.2595426416644441 }, - "weighted_cost": 0.8222793265073907 + "weighted_cost": 2.1255234231876794 }, { "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.008737060041407868, + "node_connector_overlap": 0.043395814051365576, + "label_overlap": 0.006970998844127809, + "label_connector_overlap": 0.404645221717569, + "crossings": 0.5, + "crowding": 0.0451932809247536, + "sprawl": 1.2824654863971627, + "long_connectors": 0.01124485509739876, + "edge_length_cv": 0.6295914771321264, + "aspect_penalty": 0.05656374193217961, + "misalignment": 0.5289855072463768, + "loop_compactness": 0.6542163593215728, + "flow_bends": 0.4, + "loop_straightness": 0.2193168846431064 + }, + "weighted_cost": 2.016686529768781 } ], - "median_cost": 0.839043002851387, + "median_cost": 2.1281253048405, "spread": [ - 0.7972783357945287, - 0.975516929537079 + 2.1190781108068326, + 2.1495959003243605 ], - "best_of_k_cost": 0.7700952448215446, - "best_seed": 1, - "median_seed": 0, - "worst_seed": 6 + "best_of_k_cost": 2.016686529768781, + "best_seed": 789, + "median_seed": 456, + "worst_seed": 2 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8051977327874777, - "flow_bends": 0.23076923076923078, + "node_overlap": 0.0, + "node_connector_overlap": 0.005023063954558897, + "label_overlap": 0.00006165547732262022, + "label_connector_overlap": 0.6478933024301062, + "crossings": 2.4602510460251046, + "crowding": 0.0014756944444444444, + "sprawl": 4.413217055946651, + "long_connectors": 0.0, + "edge_length_cv": 0.4690793842542505, + "aspect_penalty": 0.0, + "misalignment": 0.8, + "loop_compactness": 0.7900226559183088, + "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.9673281093388972 + "weighted_cost": 5.089295788702287 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.003718643097832968, + "label_overlap": 0.00015091385813115736, + "label_connector_overlap": 0.6212182437382824, + "crossings": 2.1631799163179917, + "crowding": 0.005818369149413545, + "sprawl": 5.304473271443493, + "long_connectors": 0.000610994239668717, + "edge_length_cv": 0.47653609295606086, + "aspect_penalty": 0.0, + "misalignment": 0.8770949720670391, + "loop_compactness": 0.7931094837823149, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.502747060643707 + "weighted_cost": 4.986322087628136 }, { "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, - "flow_bends": 0.15384615384615385, + "node_overlap": 0.0, + "node_connector_overlap": 0.0031691726755202574, + "label_overlap": 0.000055147204960106984, + "label_connector_overlap": 0.6544211780219957, + "crossings": 2.3807531380753137, + "crowding": 0.004586585350068897, + "sprawl": 5.531269460850888, + "long_connectors": 0.0, + "edge_length_cv": 0.4816996524277825, + "aspect_penalty": 0.0, + "misalignment": 0.8659217877094972, + "loop_compactness": 0.8662611774734862, + "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.273324298853347 + "weighted_cost": 5.3355707121536895 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.004703374787170532, + "label_overlap": 0.00009076248464353908, + "label_connector_overlap": 0.6122644957288166, + "crossings": 2.426778242677824, + "crowding": 0.004908592991600378, + "sprawl": 5.619111123251116, + "long_connectors": 8.693664334654772e-6, + "edge_length_cv": 0.538956368076091, + "aspect_penalty": 0.0, + "misalignment": 0.8674033149171271, + "loop_compactness": 0.8743010088118505, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.8703813930209674 + "weighted_cost": 5.347204706348488 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.0034564224011617187, + "label_overlap": 0.00011857882381027136, + "label_connector_overlap": 0.6077300886361133, + "crossings": 2.209205020920502, + "crowding": 0.0017786831610465989, + "sprawl": 5.506221479705362, + "long_connectors": 0.0, + "edge_length_cv": 0.47835003926543646, + "aspect_penalty": 0.0, + "misalignment": 0.8722222222222222, + "loop_compactness": 0.8539470439865794, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.5901247406483496 + "weighted_cost": 5.081416963618419 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.005038318766440887, + "label_overlap": 0.0001499845731317493, + "label_connector_overlap": 0.5828094544276113, + "crossings": 2.221757322175732, + "crowding": 0.0018449571761196592, + "sprawl": 5.519637655883641, + "long_connectors": 0.0, + "edge_length_cv": 0.47792912970104645, + "aspect_penalty": 0.0, + "misalignment": 0.8555555555555556, + "loop_compactness": 0.8697752887284731, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.75184558075862 + "weighted_cost": 5.067946975703813 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.004166469990543615, + "label_overlap": 0.00016058117107346686, + "label_connector_overlap": 0.5849137727267681, + "crossings": 2.514644351464435, + "crowding": 0.007881504329962596, + "sprawl": 5.271380105329591, + "long_connectors": 0.0, + "edge_length_cv": 0.5072989670169774, + "aspect_penalty": 0.0, + "misalignment": 0.8777777777777778, + "loop_compactness": 0.8618490123154294, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.9391297487655477 + "weighted_cost": 5.305307744154588 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.005191340066181163, + "label_overlap": 0.00018378398089789345, + "label_connector_overlap": 0.5938372974925183, + "crossings": 2.00418410041841, + "crowding": 0.00435173000299102, + "sprawl": 5.2497399421485405, + "long_connectors": 0.0, + "edge_length_cv": 0.4980603105822829, + "aspect_penalty": 0.0, + "misalignment": 0.8659217877094972, + "loop_compactness": 0.7746097802369686, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.3310255915728297 + "weighted_cost": 4.765342623282401 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.00573984941381459, + "label_overlap": 0.00008661890633228802, + "label_connector_overlap": 0.6098656456512631, + "crossings": 2.0418410041841004, + "crowding": 0.0023835831581735813, + "sprawl": 5.282052575119516, + "long_connectors": 0.0, + "edge_length_cv": 0.4822333842689033, + "aspect_penalty": 0.0, + "misalignment": 0.8770949720670391, + "loop_compactness": 0.8203212112013726, "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 3.4430169898685734 + "weighted_cost": 4.853310892439938 }, { "seed": 123, "metrics": { - "node_overlap": 0.00882951251094925, - "node_connector_overlap": 0.04678445545218922, + "node_overlap": 0.0, + "node_connector_overlap": 0.0058313743979972014, "label_overlap": 0.0, - "crossings": 2.0251046025104604, - "sprawl": 2.1468711179563367, - "edge_length_cv": 0.4851011766911578, + "label_connector_overlap": 0.5961759974835781, + "crossings": 2.1673640167364017, + "crowding": 0.0013611956612301467, + "sprawl": 4.017832362210687, + "long_connectors": 0.0, + "edge_length_cv": 0.4743695844538718, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7343177052226957, - "flow_bends": 0.15384615384615385, + "misalignment": 0.7388888888888889, + "loop_compactness": 0.8009616859957324, + "flow_bends": 0.3076923076923077, "loop_straightness": 1.0 }, - "weighted_cost": 2.926896799230868 + "weighted_cost": 4.619537457412692 }, { "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.0005631001371741201, + "node_connector_overlap": 0.014425924465999006, + "label_overlap": 0.0, + "label_connector_overlap": 0.6357150052687544, + "crossings": 2.0209205020920504, + "crowding": 0.014461796502613082, + "sprawl": 2.377009418098556, + "long_connectors": 0.0, + "edge_length_cv": 0.4974357385848453, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8442884162162339, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7111111111111111, + "loop_compactness": 0.8306717254801246, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.4621912977080265 + "weighted_cost": 4.140486584814625 }, { "seed": 789, "metrics": { - "node_overlap": 0.006128788237794972, - "node_connector_overlap": 0.03405854750367748, - "label_overlap": 0.009157262889271461, + "node_overlap": 0.0005631001371742138, + "node_connector_overlap": 0.012487640981371435, + "label_overlap": 0.0, + "label_connector_overlap": 0.6493130793156838, "crossings": 1.8451882845188285, - "sprawl": 2.7667208133505996, - "edge_length_cv": 0.50576315802094, + "crowding": 0.0025797670241376304, + "sprawl": 2.344672247197794, + "long_connectors": 0.00034875069487206147, + "edge_length_cv": 0.5349005418792311, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.7159114140931172, + "misalignment": 0.7277777777777779, + "loop_compactness": 0.7045927584663121, "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 2.8573185345338623 + "weighted_cost": 3.912718044347455 } ], - "median_cost": 3.482469179175867, + "median_cost": 5.027134531665975, "spread": [ - 3.3166002683929587, - 3.781479533824207 + 4.728891331814975, + 5.143298777565363 ], - "best_of_k_cost": 2.8573185345338623, + "best_of_k_cost": 3.912718044347455, "best_seed": 789, "median_seed": 1, - "worst_seed": 0 + "worst_seed": 3 }, { "model": "thyroid", @@ -4366,217 +6356,253 @@ { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.03966330727955609, + "label_overlap": 0.006168609048954947, + "label_connector_overlap": 0.25033335226633535, + "crossings": 1.5, + "crowding": 0.0002643549074675683, + "sprawl": 12.81290676855133, + "long_connectors": 0.010330936330363928, + "edge_length_cv": 0.6699175945576715, + "aspect_penalty": 0.0, + "misalignment": 0.7080745341614907, + "loop_compactness": 0.8227524948900049, "flow_bends": 0.5161290322580645, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.066242688905067 + "weighted_cost": 5.74661162236709 }, { "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.0006295529483935832, + "node_connector_overlap": 0.048940624196058685, + "label_overlap": 0.00708723361211398, + "label_connector_overlap": 0.3234851979376413, + "crossings": 1.447674418604651, + "crowding": 0.03074371521914429, + "sprawl": 5.704974325973123, + "long_connectors": 0.011629144335154431, + "edge_length_cv": 0.6815045435166093, + "aspect_penalty": 0.0, + "misalignment": 0.5279503105590062, + "loop_compactness": 0.8313354380503716, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.040398641718904 + "weighted_cost": 4.04819833446588 }, { "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, + "node_overlap": 0.0006334876543210432, + "node_connector_overlap": 0.04877135327492422, + "label_overlap": 0.007255171059925045, + "label_connector_overlap": 0.3417587890347798, + "crossings": 1.4593023255813953, + "crowding": 0.030248219810880928, + "sprawl": 5.68528476639671, + "long_connectors": 0.012032809935387247, + "edge_length_cv": 0.6657082063690742, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8232218390085322, - "flow_bends": 0.5161290322580645, + "misalignment": 0.525, + "loop_compactness": 0.8232955709161561, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.050866243159366 + "weighted_cost": 4.0787726083723115 }, { "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, - "flow_bends": 0.3870967741935484, + "node_overlap": 0.0, + "node_connector_overlap": 0.0393262687154309, + "label_overlap": 0.006118320953305693, + "label_connector_overlap": 0.2561399529161206, + "crossings": 1.494186046511628, + "crowding": 0.00022860145319784307, + "sprawl": 12.798289700935555, + "long_connectors": 0.014280668329756148, + "edge_length_cv": 0.6683593958934213, + "aspect_penalty": 0.0, + "misalignment": 0.6851851851851851, + "loop_compactness": 0.8259536335374792, + "flow_bends": 0.5161290322580645, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.619080158412975 + "weighted_cost": 5.7459338505932145 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8280517525504121, - "flow_bends": 0.3870967741935484, + "node_overlap": 0.0, + "node_connector_overlap": 0.03824767701555717, + "label_overlap": 0.0062187482002812695, + "label_connector_overlap": 0.23193936835783716, + "crossings": 1.4069767441860466, + "crowding": 0.0002986443670278954, + "sprawl": 12.93469618807689, + "long_connectors": 0.014018480568867928, + "edge_length_cv": 0.6705300025261002, + "aspect_penalty": 0.0, + "misalignment": 0.7, + "loop_compactness": 0.8108644119980205, + "flow_bends": 0.5161290322580645, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.579705510801846 + "weighted_cost": 5.650104347079293 }, { "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.0006415064853883981, + "node_connector_overlap": 0.050600614125710694, + "label_overlap": 0.007239102903802137, + "label_connector_overlap": 0.3509901713983321, + "crossings": 1.4883720930232558, + "crowding": 0.028596516166396406, + "sprawl": 5.7507966662530965, + "long_connectors": 0.012449954790844278, + "edge_length_cv": 0.6755888394967652, + "aspect_penalty": 0.0, + "misalignment": 0.5506329113924051, + "loop_compactness": 0.8174784097806754, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.683517926867939 + "weighted_cost": 4.140491068855767 }, { "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.006157610828627198, + "node_connector_overlap": 0.05662552629414887, + "label_overlap": 0.012853028209920134, + "label_connector_overlap": 0.3495036076836421, + "crossings": 1.3430232558139534, + "crowding": 0.06218588829989763, + "sprawl": 4.306274410505637, + "long_connectors": 0.014024329043745589, + "edge_length_cv": 0.6747858565219813, + "aspect_penalty": 0.0, + "misalignment": 0.49689440993788825, + "loop_compactness": 0.8238908958141743, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 9.597906634129338 + "weighted_cost": 3.7143544537750897 }, { "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.0006334876543210432, + "node_connector_overlap": 0.048484313957383056, + "label_overlap": 0.007255171059925045, + "label_connector_overlap": 0.3297168600559956, + "crossings": 1.4709302325581395, + "crowding": 0.02982206145263986, + "sprawl": 5.682827319548289, + "long_connectors": 0.01203000717648589, + "edge_length_cv": 0.6675089643621525, + "aspect_penalty": 0.0, + "misalignment": 0.525, + "loop_compactness": 0.8232955709161561, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.056680727225862 + "weighted_cost": 4.070721621795999 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8245557247431585, + "node_overlap": 0.0, + "node_connector_overlap": 0.03961161927854259, + "label_overlap": 0.006861919294376245, + "label_connector_overlap": 0.2648261463137394, + "crossings": 1.4709302325581395, + "crowding": 0.0002367492887973657, + "sprawl": 12.715633141290564, + "long_connectors": 0.014446639982495279, + "edge_length_cv": 0.6798828921334571, + "aspect_penalty": 0.0, + "misalignment": 0.68944099378882, + "loop_compactness": 0.8246338474151633, "flow_bends": 0.5161290322580645, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 11.985208911051867 + "weighted_cost": 5.718205282218284 }, { "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.0006334876543210432, + "node_connector_overlap": 0.05012140549026802, + "label_overlap": 0.007255171059925045, + "label_connector_overlap": 0.32388035587798764, + "crossings": 1.505813953488372, + "crowding": 0.029115845186034862, + "sprawl": 5.776202821769986, + "long_connectors": 0.011170460799049283, + "edge_length_cv": 0.6580093057869585, + "aspect_penalty": 0.0, + "misalignment": 0.53125, + "loop_compactness": 0.8203301293525155, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.077729629290038 + "weighted_cost": 4.1217714789996345 }, { "seed": 456, "metrics": { - "node_overlap": 0.8758707162888123, - "node_connector_overlap": 0.15065851254467985, - "label_overlap": 7.490550486574051, + "node_overlap": 0.0, + "node_connector_overlap": 0.03919860888150385, + "label_overlap": 0.0060825555901274225, + "label_connector_overlap": 0.20091649619908045, "crossings": 1.3895348837209303, - "sprawl": 3.881120076093125, - "edge_length_cv": 0.6871482315467444, + "crowding": 0.00025653499688617197, + "sprawl": 12.772216366949294, + "long_connectors": 0.011955824409981487, + "edge_length_cv": 0.6853001942790974, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8198932555549225, - "flow_bends": 0.3870967741935484, + "misalignment": 0.705521472392638, + "loop_compactness": 0.8207422223665977, + "flow_bends": 0.5161290322580645, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 11.153070959013892 + "weighted_cost": 5.550363246627606 }, { "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, - "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.8220247257512111, - "flow_bends": 0.5161290322580645, + "node_overlap": 0.0006334876543210432, + "node_connector_overlap": 0.04815188342443994, + "label_overlap": 0.007255171059925045, + "label_connector_overlap": 0.3600774182889406, + "crossings": 1.4709302325581395, + "crowding": 0.03159233157716464, + "sprawl": 5.665526359591076, + "long_connectors": 0.012211821181441943, + "edge_length_cv": 0.6791960018036255, + "aspect_penalty": 0.0, + "misalignment": 0.48124999999999996, + "loop_compactness": 0.8220980755184399, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 12.123523550089912 + "weighted_cost": 4.108279537058144 } ], - "median_cost": 12.012803776385386, + "median_cost": 4.131131273927701, "spread": [ - 9.667408484754198, - 12.059071217645663 + 4.076759861728234, + 5.6671295808640405 ], - "best_of_k_cost": 11.153070959013892, - "best_seed": 4, - "median_seed": 1, - "worst_seed": 789 + "best_of_k_cost": 4.108279537058144, + "best_seed": 6, + "median_seed": 5, + "worst_seed": 0 }, { "model": "covid19", @@ -4584,217 +6610,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.006305257786806489, - "node_connector_overlap": 0.024290261455352583, + "node_overlap": 0.0, + "node_connector_overlap": 0.00615917490643215, "label_overlap": 0.0, - "crossings": 1.074468085106383, - "sprawl": 2.6162576353814475, - "edge_length_cv": 0.450891737122621, + "label_connector_overlap": 0.6289152730416947, + "crossings": 1.0265957446808511, + "crowding": 0.0018166113015519614, + "sprawl": 2.9102186839705437, + "long_connectors": 0.000053799121073144075, + "edge_length_cv": 0.44921663909022064, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5162635378033302, + "misalignment": 0.7913907284768211, + "loop_compactness": 0.4907402593276686, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.9324676053696932 + "weighted_cost": 3.084767421313261 }, { "seed": 1, "metrics": { - "node_overlap": 0.008313379487320138, - "node_connector_overlap": 0.026635318481394193, + "node_overlap": 0.0, + "node_connector_overlap": 0.005597656125710306, "label_overlap": 0.0, - "crossings": 1.0212765957446808, - "sprawl": 2.0267142784782166, - "edge_length_cv": 0.4806988985456621, + "label_connector_overlap": 0.6580590619129433, + "crossings": 1.0398936170212767, + "crowding": 0.0019625715125136905, + "sprawl": 2.911464483169768, + "long_connectors": 0.00007503194099108844, + "edge_length_cv": 0.4671574140543176, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.504836724777955, - "flow_bends": 0.058823529411764705, + "misalignment": 0.7350993377483444, + "loop_compactness": 0.4818829991629495, + "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.7523263687319852 + "weighted_cost": 3.1319539226811064 }, { "seed": 2, "metrics": { - "node_overlap": 0.006305257786806491, - "node_connector_overlap": 0.02635893165067456, + "node_overlap": 0.0, + "node_connector_overlap": 0.006709313898815959, "label_overlap": 0.0, + "label_connector_overlap": 0.6539110851999062, "crossings": 1.0186170212765957, - "sprawl": 2.604097078015303, - "edge_length_cv": 0.45600725649134544, + "crowding": 0.0024940404907049483, + "sprawl": 2.9029952473258573, + "long_connectors": 0.00008934390574667157, + "edge_length_cv": 0.45542895463532385, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5094623614827638, + "misalignment": 0.7350993377483444, + "loop_compactness": 0.4947394747738395, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.8735326297337724 + "weighted_cost": 3.110242583657029 }, { "seed": 3, "metrics": { - "node_overlap": 0.00829216842796186, - "node_connector_overlap": 0.028033870891733165, + "node_overlap": 0.0, + "node_connector_overlap": 0.00971615141603583, "label_overlap": 0.0, - "crossings": 0.9228723404255319, - "sprawl": 2.080055775759923, - "edge_length_cv": 0.5149695620472804, + "label_connector_overlap": 0.6446003509338402, + "crossings": 0.976063829787234, + "crowding": 0.003533310969859126, + "sprawl": 2.1997592250686693, + "long_connectors": 0.0006982835206528556, + "edge_length_cv": 0.4974729463652315, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4725335952064032, + "misalignment": 0.6546052631578947, + "loop_compactness": 0.4889196619086824, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.6618700318033022 + "weighted_cost": 2.8748943679202106 }, { "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.007873160331053508, + "label_overlap": 0.0, + "label_connector_overlap": 0.6523285037046619, + "crossings": 0.9308510638297872, + "crowding": 0.005603538754880324, + "sprawl": 2.141783106824253, + "long_connectors": 0.00011631198139608342, + "edge_length_cv": 0.487121510633476, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.49650043175824465, + "misalignment": 0.6666666666666667, + "loop_compactness": 0.5120546200276667, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.7493349408992174 + "weighted_cost": 2.835333185001792 }, { "seed": 5, "metrics": { - "node_overlap": 0.006305257786806491, - "node_connector_overlap": 0.02635893165067456, + "node_overlap": 0.0, + "node_connector_overlap": 0.006709313898815959, "label_overlap": 0.0, + "label_connector_overlap": 0.6539110851999062, "crossings": 1.0186170212765957, - "sprawl": 2.604097078015303, - "edge_length_cv": 0.45600725649134544, + "crowding": 0.0024940404907049483, + "sprawl": 2.9029952473258573, + "long_connectors": 0.00008934390574667157, + "edge_length_cv": 0.45542895463532385, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5094623614827638, + "misalignment": 0.7350993377483444, + "loop_compactness": 0.4947394747738395, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.8735326297337724 + "weighted_cost": 3.110242583657029 }, { "seed": 6, "metrics": { - "node_overlap": 0.006299147364042278, - "node_connector_overlap": 0.024031182977694753, + "node_overlap": 0.0, + "node_connector_overlap": 0.0075481421858177985, "label_overlap": 0.0, - "crossings": 0.9893617021276596, - "sprawl": 2.5798152761929267, - "edge_length_cv": 0.47564391468778217, + "label_connector_overlap": 0.6625775812161386, + "crossings": 0.9707446808510638, + "crowding": 0.002938227910864237, + "sprawl": 2.162979758873929, + "long_connectors": 0.00007756445961175807, + "edge_length_cv": 0.4740887253447899, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5157461335500366, + "misalignment": 0.735973597359736, + "loop_compactness": 0.48474729286323537, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.8396005999515261 + "weighted_cost": 2.888572622610857 }, { "seed": 7, "metrics": { - "node_overlap": 0.00830276041069147, - "node_connector_overlap": 0.02782907900703134, + "node_overlap": 0.0, + "node_connector_overlap": 0.005195531969425724, "label_overlap": 0.0, - "crossings": 0.9414893617021277, - "sprawl": 2.0455433440560014, - "edge_length_cv": 0.46983011799055296, + "label_connector_overlap": 0.6407332663759905, + "crossings": 1.0053191489361701, + "crowding": 0.0005471579788172043, + "sprawl": 2.9284224720549807, + "long_connectors": 0.00019486613934488334, + "edge_length_cv": 0.45624388676580585, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4705403791154808, + "misalignment": 0.7623762376237624, + "loop_compactness": 0.4687516643629327, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.6725930804007727 + "weighted_cost": 3.070945669832321 }, { "seed": 42, "metrics": { - "node_overlap": 0.006305257786806492, - "node_connector_overlap": 0.023858531887311775, + "node_overlap": 0.0, + "node_connector_overlap": 0.005470471473618134, "label_overlap": 0.0, - "crossings": 1.0558510638297873, - "sprawl": 2.6011344357116646, - "edge_length_cv": 0.49473966671777525, + "label_connector_overlap": 0.6259108991198233, + "crossings": 1.050531914893617, + "crowding": 0.0014532093357187952, + "sprawl": 2.8999670344196824, + "long_connectors": 0.00017215059444317726, + "edge_length_cv": 0.48900631526792304, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.45250370363083936, + "misalignment": 0.7748344370860927, + "loop_compactness": 0.45428007734512843, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.8848902809221035 + "weighted_cost": 3.0837127832286395 }, { "seed": 123, "metrics": { - "node_overlap": 0.006305257786806491, - "node_connector_overlap": 0.02635893165067456, + "node_overlap": 0.0, + "node_connector_overlap": 0.006709313898815959, "label_overlap": 0.0, + "label_connector_overlap": 0.6539110851999062, "crossings": 1.0186170212765957, - "sprawl": 2.604097078015303, - "edge_length_cv": 0.45600725649134544, + "crowding": 0.0024940404907049483, + "sprawl": 2.9029952473258573, + "long_connectors": 0.00008934390574667157, + "edge_length_cv": 0.45542895463532385, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5094623614827638, + "misalignment": 0.7350993377483444, + "loop_compactness": 0.4947394747738395, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.8735326297337724 + "weighted_cost": 3.110242583657029 }, { "seed": 456, "metrics": { - "node_overlap": 0.008313379487320145, - "node_connector_overlap": 0.03043818135113868, + "node_overlap": 0.0, + "node_connector_overlap": 0.00693048632096331, "label_overlap": 0.0, - "crossings": 0.9521276595744681, - "sprawl": 2.0284642058228726, - "edge_length_cv": 0.4697240772395471, + "label_connector_overlap": 0.6496883182765716, + "crossings": 0.9893617021276596, + "crowding": 0.0023414575388026534, + "sprawl": 2.897626260734402, + "long_connectors": 0.0, + "edge_length_cv": 0.453974106587494, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.4768223574178368, + "misalignment": 0.7483443708609272, + "loop_compactness": 0.4739657329847972, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.6849480633681657 + "weighted_cost": 3.0665709640103875 }, { "seed": 789, "metrics": { - "node_overlap": 0.006305257786806492, - "node_connector_overlap": 0.023880710798448255, + "node_overlap": 0.0, + "node_connector_overlap": 0.0036856484628799194, "label_overlap": 0.0, - "crossings": 1.0159574468085106, - "sprawl": 2.6104826172140685, - "edge_length_cv": 0.4894754039643907, + "label_connector_overlap": 0.6098338282666653, + "crossings": 1.0319148936170213, + "crowding": 0.0014899834210312473, + "sprawl": 3.7698565321425206, + "long_connectors": 0.00016859479082210503, + "edge_length_cv": 0.47987674254222495, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.526890651939126, + "misalignment": 0.794701986754967, + "loop_compactness": 0.5047261576024729, "flow_bends": 0.11764705882352941, "loop_straightness": 0.8 }, - "weighted_cost": 1.876643258435759 + "weighted_cost": 3.277083067334867 } ], - "median_cost": 1.8565666148426492, + "median_cost": 3.0842401022709502, "spread": [ - 1.7332382215164546, - 1.8743102869092692 + 3.0220713786605047, + 3.110242583657029 ], - "best_of_k_cost": 1.6849480633681657, - "best_seed": 3, - "median_seed": 6, - "worst_seed": 0 + "best_of_k_cost": 3.0665709640103875, + "best_seed": 4, + "median_seed": 0, + "worst_seed": 789 }, { "model": "industrial_dynamics", @@ -4802,218 +6864,254 @@ { "seed": 0, "metrics": { - "node_overlap": 0.028738690792974985, - "node_connector_overlap": 0.05124641556980498, + "node_overlap": 0.0, + "node_connector_overlap": 0.030513475695683034, "label_overlap": 0.0, - "crossings": 0.8603351955307262, - "sprawl": 2.7139202176054287, - "edge_length_cv": 0.6655845813203781, + "label_connector_overlap": 0.3698277099939075, + "crossings": 0.8770949720670391, + "crowding": 0.0028266241853821387, + "sprawl": 4.615469194271222, + "long_connectors": 0.010658559137354421, + "edge_length_cv": 0.6454274129419643, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.48973275125194426, + "misalignment": 0.4105960264900662, + "loop_compactness": 0.4976421698916555, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.7456641125820365 + "weighted_cost": 2.961669828043467 }, { "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.014921270519999912, + "label_overlap": 0.0, + "label_connector_overlap": 0.30154881184249893, + "crossings": 0.9162011173184358, + "crowding": 0.0008741120120509924, + "sprawl": 6.11118743644531, + "long_connectors": 0.0057307361728089565, + "edge_length_cv": 0.5979554106971011, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.49480668555743135, + "misalignment": 0.5165562913907285, + "loop_compactness": 0.49499103127366184, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 2.4776990552176708 + "weighted_cost": 3.246221923647171 }, { "seed": 2, "metrics": { - "node_overlap": 0.028738690792974985, - "node_connector_overlap": 0.03833122664056828, + "node_overlap": 0.0, + "node_connector_overlap": 0.03125929190716765, "label_overlap": 0.0, - "crossings": 0.6480446927374302, - "sprawl": 2.734698014412557, - "edge_length_cv": 0.6037978212862715, + "label_connector_overlap": 0.35509650943173393, + "crossings": 0.6312849162011173, + "crowding": 0.0023875038013415994, + "sprawl": 3.4247320332408275, + "long_connectors": 0.006310779311449283, + "edge_length_cv": 0.6053796222121666, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.3841059602649006, "loop_compactness": 0.5154635281687995, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.5349062909876714 + "weighted_cost": 2.399436839891003 }, { "seed": 3, "metrics": { - "node_overlap": 0.028738690792975013, - "node_connector_overlap": 0.040597114630195295, + "node_overlap": 0.0, + "node_connector_overlap": 0.031759447397215576, "label_overlap": 0.0, - "crossings": 0.8491620111731844, - "sprawl": 2.7110268081414146, - "edge_length_cv": 0.6414205715616035, + "label_connector_overlap": 0.3881038674925595, + "crossings": 0.8603351955307262, + "crowding": 0.003694192232437518, + "sprawl": 3.3955146917480885, + "long_connectors": 0.009591945421482723, + "edge_length_cv": 0.6417279417964075, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.35761589403973515, "loop_compactness": 0.5118828556068377, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.7321229871340393 + "weighted_cost": 2.6705601277575735 }, { "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, + "node_overlap": 0.0, + "node_connector_overlap": 0.005639225627946134, + "label_overlap": 0.0, + "label_connector_overlap": 0.2721808837861293, + "crossings": 1.0614525139664805, + "crowding": 0.0, + "sprawl": 11.508983938725803, + "long_connectors": 0.011164629282722051, + "edge_length_cv": 0.6451737834650603, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.50277865606712, + "misalignment": 0.576158940397351, + "loop_compactness": 0.5045938955838185, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 2.607127814487485 + "weighted_cost": 4.689950709164307 }, { "seed": 5, "metrics": { - "node_overlap": 0.028738690792974985, - "node_connector_overlap": 0.03340234145379649, + "node_overlap": 0.0, + "node_connector_overlap": 0.027548913749498367, "label_overlap": 0.0, - "crossings": 0.7597765363128491, - "sprawl": 2.6896286213088136, - "edge_length_cv": 0.649016564770906, + "label_connector_overlap": 0.411499309096306, + "crossings": 0.7821229050279329, + "crowding": 0.003714019560968497, + "sprawl": 3.3677610800267526, + "long_connectors": 0.007908236530979652, + "edge_length_cv": 0.649429689509678, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5155600178231583, + "misalignment": 0.29801324503311255, + "loop_compactness": 0.514216933638872, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.6327339666173133 + "weighted_cost": 2.606232868630062 }, { "seed": 6, "metrics": { - "node_overlap": 0.0316803557092571, - "node_connector_overlap": 0.07187165120137179, + "node_overlap": 0.0, + "node_connector_overlap": 0.03827888021557868, "label_overlap": 0.0, - "crossings": 0.659217877094972, - "sprawl": 2.191993340313167, - "edge_length_cv": 0.6808473537367026, + "label_connector_overlap": 0.3775337159252767, + "crossings": 0.7262569832402235, + "crowding": 0.003961838068060162, + "sprawl": 3.414051028074197, + "long_connectors": 0.007953514351871274, + "edge_length_cv": 0.6536977116545847, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5226237460504322, + "misalignment": 0.3708609271523179, + "loop_compactness": 0.523070014607171, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.476884717155074 + "weighted_cost": 2.543547435046608 }, { "seed": 7, "metrics": { - "node_overlap": 0.02564326864198148, - "node_connector_overlap": 0.022500899187697838, + "node_overlap": 0.0, + "node_connector_overlap": 0.018468716546348428, "label_overlap": 0.0, - "crossings": 0.8324022346368715, - "sprawl": 3.3612716499710404, - "edge_length_cv": 0.636934626207713, + "label_connector_overlap": 0.38691768563178996, + "crossings": 0.8379888268156425, + "crowding": 0.001207700686972501, + "sprawl": 4.691067141302526, + "long_connectors": 0.006233283756800905, + "edge_length_cv": 0.6365547409398998, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.4105960264900662, "loop_compactness": 0.5536700212856426, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.8409354076416826 + "weighted_cost": 2.961588194076959 }, { "seed": 42, "metrics": { - "node_overlap": 0.014077173299414129, - "node_connector_overlap": 0.011590620172461768, + "node_overlap": 0.0, + "node_connector_overlap": 0.02055503040569357, "label_overlap": 0.0, - "crossings": 0.9217877094972067, - "sprawl": 5.433100230635522, - "edge_length_cv": 0.5651560939180177, + "label_connector_overlap": 0.33344805011767015, + "crossings": 0.8770949720670391, + "crowding": 0.0019356968854189634, + "sprawl": 4.55940948007257, + "long_connectors": 0.005520947861305558, + "edge_length_cv": 0.5981900880489274, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5232375534703375, + "misalignment": 0.42384105960264906, + "loop_compactness": 0.5232375534703377, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 2.3100372371509885 + "weighted_cost": 2.8812714429042123 }, { "seed": 123, "metrics": { - "node_overlap": 0.031680355709257126, - "node_connector_overlap": 0.061253576731312716, + "node_overlap": 0.0, + "node_connector_overlap": 0.05081668349230503, "label_overlap": 0.0, - "crossings": 0.770949720670391, - "sprawl": 2.2024636554666874, - "edge_length_cv": 0.6604846197995592, + "label_connector_overlap": 0.4349520209023505, + "crossings": 0.7821229050279329, + "crowding": 0.012854533185762098, + "sprawl": 2.528638257365052, + "long_connectors": 0.011652730397795182, + "edge_length_cv": 0.6598183022979814, "aspect_penalty": 0.0, - "chain_straightness": 0.0, + "misalignment": 0.2847682119205298, "loop_compactness": 0.5277887129238317, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.5821585360404977 + "weighted_cost": 2.493283739120244 }, { "seed": 456, "metrics": { - "node_overlap": 0.031680355709257105, - "node_connector_overlap": 0.05264428011455551, + "node_overlap": 0.0, + "node_connector_overlap": 0.04691298125221594, "label_overlap": 0.0, + "label_connector_overlap": 0.3494696859885434, "crossings": 0.8491620111731844, - "sprawl": 2.2161925387452794, - "edge_length_cv": 0.6716631900653252, + "crowding": 0.019302890186407237, + "sprawl": 2.5425887316185447, + "long_connectors": 0.01259411572128257, + "edge_length_cv": 0.6709867725555383, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.5326227150881526, + "misalignment": 0.3112582781456954, + "loop_compactness": 0.5309608387153537, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.6564409074479804 + "weighted_cost": 2.438616463579494 }, { "seed": 789, "metrics": { - "node_overlap": 0.02564326864198148, - "node_connector_overlap": 0.02281358006160794, + "node_overlap": 0.0, + "node_connector_overlap": 0.02798788425768864, "label_overlap": 0.0, - "crossings": 0.8994413407821229, - "sprawl": 3.2774152064318076, - "edge_length_cv": 0.606125352979846, + "label_connector_overlap": 0.36242030716896995, + "crossings": 0.8659217877094972, + "crowding": 0.0032944844686630336, + "sprawl": 3.385790349353551, + "long_connectors": 0.007510887314357774, + "edge_length_cv": 0.6262130148686557, "aspect_penalty": 0.0, - "chain_straightness": 0.0, - "loop_compactness": 0.511267386135937, + "misalignment": 0.3708609271523179, + "loop_compactness": 0.511007549966521, "flow_bends": 0.0, "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.8745548518931157 + "weighted_cost": 2.6271813118110665 } ], - "median_cost": 1.7388935498580378, + "median_cost": 2.6488707197843198, "spread": [ - 1.6200901089731095, - 1.983425448207584 + 2.5309815110650167, + 2.9616086025685857 ], - "best_of_k_cost": 1.5821585360404977, - "best_seed": 6, - "median_seed": 3, + "best_of_k_cost": 2.438616463579494, + "best_seed": 2, + "median_seed": 789, "worst_seed": 4 } ], - "aggregate_cost": 0.8011384042018516 + "aggregate_cost": 1.536536579786258 } \ No newline at end of file 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..a908d05bf 100644 --- a/src/simlin-engine/src/layout/declutter.rs +++ b/src/simlin-engine/src/layout/declutter.rs @@ -32,7 +32,7 @@ use crate::diagram::common::{Rect, rect_overlap_area}; use crate::diagram::label::label_bounds; use super::metrics::{ - alias_label_props_for, alias_source_names, element_label_props_for, node_shape_box, + 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 @@ -408,6 +408,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 -- the same obstacles the metric charges labels +/// and connectors against. +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 +500,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 @@ -533,7 +553,7 @@ fn optimize_label_sides(elements: &mut [ViewElement], alias_names: &HashMap = elements .iter() .enumerate() - .filter_map(|(i, e)| node_shape_box(e).map(|r| (i, r))) + .flat_map(|(i, e)| shape_rects(e).into_iter().map(move |r| (i, r))) .collect(); // The label box an element would occupy on `side`. Aliases resolve their @@ -579,10 +599,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; } @@ -1282,9 +1321,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/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/metrics.rs b/src/simlin-engine/src/layout/metrics.rs index ecd5d25e6..232ca7044 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::{ @@ -23,110 +31,152 @@ use crate::diagram::common::{ segment_clip_interval_in_rect, }; 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, -}; -use crate::diagram::flow::{flow_bounds, flow_shape_bounds}; +use crate::diagram::elements::{aux_shape_bounds, cloud_bounds, module_bounds, stock_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. +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`). +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, other nodes' shapes, and other flows' pipes (capped at 1). pub label_overlap: f64, - /// Edge crossings normalized by connector count. + /// Mean over labels of how much link polyline 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. The label's own node's links count at + /// `OWN_LINK_STRIKE_FACTOR`. + #[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 +185,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. + /// + /// 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. /// - /// 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. + /// 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 +232,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::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 +445,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 +462,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 +475,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 +490,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,39 +502,143 @@ 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(view: &datamodel::StockFlow) -> Vec { + let alias_names = alias_source_names(&view.elements); + let not_arrayed = |_: &str| false; + view.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, + incident_uids: HashSet, + /// 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(view: &datamodel::StockFlow) -> Vec { + let uid_elements: HashMap = + view.elements.iter().map(|e| (e.get_uid(), e)).collect(); let not_arrayed = |_: &str| false; let mut out = Vec::new(); @@ -441,14 +656,11 @@ fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec { @@ -460,20 +672,13 @@ fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec {} @@ -482,608 +687,434 @@ 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; +/// 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() +} -/// 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; +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), + } +} -/// 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 inset(r: &Rect, d: f64) -> Rect { + Rect { + left: r.left + d, + top: r.top + d, + right: r.right - d, + bottom: r.bottom - d, + } } -/// 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 }); +// --- 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 +} - // 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); +/// `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; + } + 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 e in &view.elements { - match e { - ViewElement::Link(link) => { - add_edge(link.from_uid, link.to_uid, ¢ers); - } - ViewElement::Flow(flow) => { - // Consecutive attached points define stock->flow and flow->stock - // edges through the flow's own valve uid. - let attached: Vec = flow - .points - .iter() - .filter_map(|p| p.attached_to_uid) - .collect(); - for w in attached.windows(2) { - add_edge(w[0], flow.uid, ¢ers); - add_edge(flow.uid, w[1], ¢ers); - } - } - _ => {} + for (k, length) in per_obstacle { + sink.push(DefectKind::ConnectorThroughNode, obstacles[k].1, length); } } - - let adj: BTreeMap> = edge_sets - .into_iter() - .map(|(k, set)| (k, set.into_iter().collect())) - .collect(); - LoopGraph { adj, centers } + inside / total_length } -/// Enumerate simple directed cycles (each >= 2 nodes), bounded by -/// `MAX_CYCLE_LEN` and `MAX_CYCLES`, canonicalized and de-duplicated so the same -/// directed cycle is returned exactly once regardless of where the search -/// started. A bounded DFS suffices: SD diagrams are tiny, and the caps keep it -/// O(small) on the rare dense graph. -/// -/// Each returned cycle is a `Vec` of uids in traversal order, rotated so -/// its smallest uid is first (canonical form), and the set of returned cycles is -/// itself sorted for a fully deterministic result. -fn enumerate_simple_cycles(graph: &LoopGraph) -> Vec> { - let mut found: BTreeSet> = BTreeSet::new(); - // Start a DFS from each node in sorted uid order. To avoid re-finding the - // same cycle from each of its members we still canonicalize+dedup, but we - // also restrict each search to cycles whose minimum node is the start node, - // which prunes the bulk of the duplicate work. - let starts: Vec = graph.adj.keys().copied().collect(); - let mut path: Vec = Vec::new(); - let mut on_path: HashSet = HashSet::new(); - for &start in &starts { - path.clear(); - on_path.clear(); - dfs_cycles(graph, start, start, &mut path, &mut on_path, &mut found); - if found.len() >= MAX_CYCLES { - break; +/// `label_overlap`: mean covered fraction of each label box by other labels, +/// other nodes' shapes, and other flows' pipes. +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); + } + for r in &b.pipe { + covered += rect_overlap_area(&lbl, r); + } } + let fraction = covered.min(area) / area; + if fraction > 0.0 { + sink.push(DefectKind::LabelObscured, lbl, fraction); + } + total += fraction; } - found.into_iter().take(MAX_CYCLES).collect() + total / labeled.len() as f64 } -/// Depth-first walk that records every simple cycle returning to `start` and -/// composed only of nodes whose uid is >= `start` (so each cycle is discovered -/// from its smallest member). `path`/`on_path` track the current simple path. -fn dfs_cycles( - graph: &LoopGraph, - start: i32, - current: i32, - path: &mut Vec, - on_path: &mut HashSet, - found: &mut BTreeSet>, -) { - if found.len() >= MAX_CYCLES { - return; +/// `label_connector_overlap`: mean over labels of the link length 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(); + if labels.is_empty() { + return 0.0; } - path.push(current); - on_path.insert(current); - - if let Some(succs) = graph.adj.get(¤t) { - for &next in succs { - if next == start { - // Closed a cycle back to the start. Record it (>= 2 nodes by - // construction; self-loops were never added as edges). - if path.len() >= 2 { - found.insert(canonicalize_cycle(path)); - if found.len() >= MAX_CYCLES { - break; - } - } - continue; - } - // Only extend through nodes strictly greater than the start (so the - // start is the minimum), not already on the path, within the length - // cap. - if next > start && !on_path.contains(&next) && path.len() < MAX_CYCLE_LEN { - dfs_cycles(graph, start, next, path, on_path, found); - if found.len() >= MAX_CYCLES { - break; + let mut total = 0.0; + for (owner, lbl) in &labels { + let text = inset(lbl, LABEL_INSET); + let side = common::rect_width(&text).min(common::rect_height(&text)); + if side <= 0.0 { + continue; + } + let mut through = 0.0; + for c in connectors.iter().filter(|c| c.kind == ConnectorKind::Link) { + // A link into or out of the labeled node at least points at (or + // leaves from) that name, the way a modeler draws an arrow to a + // variable, so it strikes the name out half as badly as a line + // passing through on its way somewhere else. + let factor = if c.incident_uids.contains(owner) { + OWN_LINK_STRIKE_FACTOR + } else { + 1.0 + }; + for seg in c.polyline.windows(2) { + if let Some((t0, t1)) = segment_clip_interval_in_rect(&seg[0], &seg[1], &text) { + let seg_len = + ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)).sqrt(); + through += factor * (t1 - t0) * seg_len; } } } + let fraction = (through / side).min(1.0); + if fraction > 0.0 { + sink.push(DefectKind::LabelCrossed, *lbl, fraction); + } + total += fraction; } - - on_path.remove(¤t); - path.pop(); + total / labels.len() as f64 } -/// Rotate a cycle so its smallest uid is first, preserving traversal direction. -/// The DFS already guarantees the start (= minimum) is element 0, but rotating -/// defensively keeps the canonical form correct for any caller. -/// -/// Note: this canonicalizes rotation (start at min uid) but NOT traversal -/// direction, so a directed cycle and its reverse canonicalize to distinct -/// entries. That is harmless: a reverse-direction duplicate (essentially never -/// present for directed SD feedback loops, which would require both directed -/// edge sets in the graph) would compute the same isoperimetric penalty because -/// the shoelace polygon area in `cycle_penalty` is direction-invariant. -fn canonicalize_cycle(cycle: &[i32]) -> Vec { - if cycle.is_empty() { - return Vec::new(); +/// `crossings`: crossings per connector, on the drawn polylines. +fn crossings_term( + view: &datamodel::StockFlow, + connector_count: usize, + sink: &mut DefectSink, +) -> f64 { + if connector_count == 0 { + return 0.0; } - let min_idx = cycle - .iter() - .enumerate() - .min_by_key(|&(_, v)| *v) - .map(|(i, _)| i) - .unwrap_or(0); - let mut out = Vec::with_capacity(cycle.len()); - for k in 0..cycle.len() { - out.push(cycle[(min_idx + k) % cycle.len()]); + let segments = build_view_segments(view); + let mut count = 0usize; + for i in 0..segments.len() { + for j in (i + 1)..segments.len() { + if let Some(p) = segment_intersection(&segments[i], &segments[j]) { + count += 1; + sink.push_point(DefectKind::Crossing, Point { x: p.x, y: p.y }, 1.0); + } + } } - out + count as f64 / connector_count as f64 } -/// Isoperimetric penalty `1 - Q` for one cycle's node-box centers, or `None` if -/// the cycle does not qualify (fewer than 3 distinct positioned nodes, or a -/// degenerate zero-perimeter polygon). `Q = 4*PI*Area / Perimeter^2` is clamped -/// to [0, 1]; `Area` is the shoelace area (absolute value) and `Perimeter` the -/// summed edge length over the closed polygon. -fn cycle_penalty(cycle: &[i32], centers: &BTreeMap) -> Option { - // Distinct positioned nodes only: a polygon needs >= 3 vertices. - let distinct: BTreeSet = cycle.iter().copied().collect(); - if distinct.len() < 3 { - return None; +/// `crowding`: the mean clearance deficit per node over pairs of non-cloud +/// nodes whose footprints come closer than `COMFORTABLE_CLEARANCE`, plus the +/// mean deficit per link over links whose visible length (outside both +/// endpoint shapes) falls below `MIN_VISIBLE_LINK`. +fn crowding_term( + nodes: &[SceneNode], + connectors: &[ConnectorGeometry], + sink: &mut DefectSink, +) -> f64 { + if nodes.len() < 2 { + return 0.0; } - let pts: Vec = cycle + let shapes: HashMap = nodes.iter().map(|n| (n.uid, n.shape)).collect(); + let links: Vec<&ConnectorGeometry> = connectors .iter() - .filter_map(|uid| centers.get(uid).copied()) + .filter(|c| c.kind == ConnectorKind::Link) .collect(); - if pts.len() < 3 { - return None; - } - - let n = pts.len(); - let mut area2 = 0.0; - let mut perimeter = 0.0; - for i in 0..n { - let a = pts[i]; - let b = pts[(i + 1) % n]; - area2 += a.x * b.y - b.x * a.y; - let dx = b.x - a.x; - let dy = b.y - a.y; - perimeter += (dx * dx + dy * dy).sqrt(); + let mut short = 0.0; + for c in &links { + let hidden: f64 = c + .incident_uids + .iter() + .filter_map(|uid| shapes.get(uid)) + .map(|shape| { + c.polyline + .windows(2) + .filter_map(|seg| { + segment_clip_interval_in_rect(&seg[0], &seg[1], shape).map(|(t0, t1)| { + (t1 - t0) + * ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)) + .sqrt() + }) + }) + .sum::() + }) + .sum(); + let visible = (c.length - hidden).max(0.0); + if visible < MIN_VISIBLE_LINK { + let deficit = (1.0 - visible / MIN_VISIBLE_LINK).powi(2); + short += deficit; + let mid = c.polyline[c.polyline.len() / 2]; + sink.push_point(DefectKind::Crowded, mid, deficit); + } } - if perimeter <= 0.0 { - // All centers coincide: no polygon. Guarded so the division below is - // never NaN; such a degenerate cycle simply does not contribute. - return None; + let short_rate = if links.is_empty() { + 0.0 + } else { + short / links.len() as f64 + }; + // (is_label, rect) for each node's shape and label. + let footprint = |n: &SceneNode| -> Vec<(bool, Rect)> { + let mut rects = vec![(false, n.shape)]; + rects.extend(n.label.map(|l| (true, l))); + rects + }; + let footprints: Vec> = nodes.iter().map(footprint).collect(); + let boxes: Vec = nodes.iter().map(SceneNode::footprint_box).collect(); + let mut total = 0.0; + for i in 0..nodes.len() { + for j in (i + 1)..nodes.len() { + if nodes[i].is_cloud || nodes[j].is_cloud { + continue; + } + // Cheap reject: the merged boxes are already comfortably apart. + if rect_gap(&boxes[i], &boxes[j]) >= COMFORTABLE_CLEARANCE { + continue; + } + // A flow's valve sits a fixed short pipe away from the stock or + // cloud it attaches to by construction: their SHAPES being close is + // structure, but either one's label crowding the other is not. + let attached = nodes[i].attached_to(&nodes[j]); + let mut gap = f64::INFINITY; + let mut closest = (boxes[i], boxes[j]); + for &(a_is_label, a) in &footprints[i] { + for &(b_is_label, b) in &footprints[j] { + if attached && !a_is_label && !b_is_label { + continue; + } + let g = rect_gap(&a, &b); + if g < gap { + gap = g; + closest = (a, b); + } + } + } + if gap < COMFORTABLE_CLEARANCE { + let deficit = (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + total += deficit; + sink.push( + DefectKind::Crowded, + merge_bounds(closest.0, closest.1), + deficit, + ); + } + } } - let area = area2.abs() / 2.0; - let q = (4.0 * std::f64::consts::PI * area / (perimeter * perimeter)).clamp(0.0, 1.0); - Some(1.0 - q) + total / nodes.len() as f64 + short_rate } -/// `loop_compactness`: mean isoperimetric penalty `1 - Q` over the view's -/// bounded simple directed cycles of >= 3 positioned nodes. 0.0 when there is no -/// qualifying cycle. Deterministic for a given view regardless of element order -/// (see the module comment above). PURE. -fn compute_loop_compactness(view: &datamodel::StockFlow) -> f64 { - let graph = build_loop_graph(view); - let cycles = enumerate_simple_cycles(&graph); - let penalties: Vec = cycles +/// `long_connectors`: mean excess of each link over `LONG_CONNECTOR_FACTOR` +/// times the median link length. +fn long_connectors_term(connectors: &[ConnectorGeometry], sink: &mut DefectSink) -> f64 { + let links: Vec<&ConnectorGeometry> = connectors .iter() - .filter_map(|c| cycle_penalty(c, &graph.centers)) + .filter(|c| c.kind == ConnectorKind::Link) .collect(); - if penalties.is_empty() { - 0.0 + if links.len() < 2 { + return 0.0; + } + let mut lengths: Vec = links.iter().map(|c| c.length).collect(); + lengths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = lengths.len() / 2; + let median = if lengths.len().is_multiple_of(2) { + (lengths[mid - 1] + lengths[mid]) / 2.0 } else { - penalties.iter().sum::() / penalties.len() as f64 + lengths[mid] + }; + let threshold = LONG_CONNECTOR_FACTOR * median.max(1.0); + let mut total = 0.0; + for c in &links { + let excess = (c.length / threshold - 1.0).max(0.0); + if excess > 0.0 { + total += excess; + let mid_point = c.polyline[c.polyline.len() / 2]; + sink.push_point(DefectKind::LongConnector, mid_point, excess); + } } + total / links.len() as f64 } -// --- loop_straightness (are feedback-loop connectors drawn as visible curves) - -// -// loop_compactness above scores how circular a loop's NODE arrangement is, but -// not whether the connectors between those nodes are actually drawn as arcs. A -// loop can have well-spread node centers yet still read as a zig-zag if its -// causal connectors are straight chords. This term measures exactly that: the -// shortfall of each loop connector's drawn curvature below a target bow. It is -// primarily a Goodhart guard -- `apply_loop_curvature` curves loop connectors -// deterministically, so a healthy layout scores ~0; if any future change flattens -// loop connectors, this term (and the metric) rises, so the optimizer can never -// trade away the curvature that makes a loop legible. Flow pipes in a loop are -// exempt: they are orthogonal by convention, never arced. - -/// Target bow ratio (max perpendicular deviation / chord length) for a loop's -/// causal connectors. A quarter-circle arc -- a clearly visible loop curve -- -/// has a bow of ~0.21; 0.15 treats moderate curvature as "enough" so the term -/// only fires on connectors drawn (near-)straight. -const LOOP_LINK_TARGET_BOW: f64 = 0.15; - -/// Maximum perpendicular deviation of a polyline from its straight chord -/// (first -> last point), divided by the chord length. 0 for a straight two-point -/// line; ~0.21 for a quarter-circle arc. Returns 0 for a degenerate (near-zero) -/// chord so the ratio is always finite. -fn polyline_bow_ratio(polyline: &[Point]) -> f64 { - if polyline.len() < 3 { - return 0.0; - } - let a = polyline[0]; - let b = polyline[polyline.len() - 1]; - let cx = b.x - a.x; - let cy = b.y - a.y; - let chord = (cx * cx + cy * cy).sqrt(); - if chord < 1e-9 { +/// `misalignment`: fraction of nodes sharing no row or column with a nearby +/// node. +fn misalignment_term(nodes: &[SceneNode]) -> f64 { + if nodes.len() < 2 { return 0.0; } - let mut max_perp = 0.0_f64; - for p in &polyline[1..polyline.len() - 1] { - // Perpendicular distance from p to the infinite line through a,b. - let perp = (cx * (a.y - p.y) - cy * (a.x - p.x)).abs() / chord; - max_perp = max_perp.max(perp); - } - max_perp / chord + let aligned = nodes + .iter() + .filter(|a| { + nodes.iter().any(|b| { + if a.uid == b.uid { + return false; + } + let dx = (a.center.x - b.center.x).abs(); + let dy = (a.center.y - b.center.y).abs(); + (dx <= ALIGN_TOLERANCE && dy <= ALIGN_REACH) + || (dy <= ALIGN_TOLERANCE && dx <= ALIGN_REACH) + }) + }) + .count(); + 1.0 - aligned as f64 / nodes.len() as f64 } -/// Map each directed causal connector (Link) `from_uid -> to_uid` to the polyline -/// the renderer draws for it, so loop-straightness can look up the drawn -/// curvature of a loop edge. Flows are not included (loop edges through a flow -/// valve have no Link and are correctly skipped). -fn link_polylines( - view: &datamodel::StockFlow, -) -> std::collections::HashMap<(i32, i32), Vec> { - let mut uid_elements: std::collections::HashMap = - std::collections::HashMap::new(); - for elem in &view.elements { - uid_elements.insert(elem.get_uid(), elem); - } - let not_arrayed = |_: &str| false; - let mut out: std::collections::HashMap<(i32, i32), Vec> = - std::collections::HashMap::new(); - for elem in &view.elements { - if let ViewElement::Link(link) = elem - && let (Some(&from), Some(&to)) = ( - uid_elements.get(&link.from_uid), - uid_elements.get(&link.to_uid), - ) - { - let polyline = connector_polyline(link, from, to, ¬_arrayed, ARC_POLYLINE_SAMPLES); - if polyline.len() >= 2 { - out.insert((link.from_uid, link.to_uid), polyline); - } - } - } - out -} - -/// `loop_straightness`: mean bow shortfall over the causal connectors that -/// participate in a feedback loop. 0.0 = every loop connector is drawn with at -/// least the target curvature (the loop reads as a visible circle); 1.0 = loop -/// connectors are straight (the loop collapses to a zig-zag). 0.0 when the view -/// has no loop with a causal connector. Deterministic and PURE; reuses the same -/// loop graph / cycle enumeration as loop_compactness. -fn compute_loop_straightness(view: &datamodel::StockFlow) -> f64 { - let graph = build_loop_graph(view); - let cycles = enumerate_simple_cycles(&graph); - if cycles.is_empty() { - return 0.0; - } - let polys = link_polylines(view); - let mut seen: HashSet<(i32, i32)> = HashSet::new(); - let mut total = 0.0; - let mut count = 0usize; - for cycle in &cycles { - let n = cycle.len(); - for k in 0..n { - let edge = (cycle[k], cycle[(k + 1) % n]); - let Some(poly) = polys.get(&edge) else { - continue; // a flow-pipe edge (no Link): exempt - }; - if !seen.insert(edge) { - continue; // count each loop connector once - } - let bow = polyline_bow_ratio(poly); - let shortfall = (LOOP_LINK_TARGET_BOW - bow).max(0.0) / LOOP_LINK_TARGET_BOW; - total += shortfall; - count += 1; - } - } - if count == 0 { - 0.0 - } else { - total / count as f64 - } +/// Union of rects, or `None` for an empty set. +fn view_bounding_box(boxes: &[Rect]) -> Option { + let mut iter = boxes.iter(); + let first = *iter.next()?; + Some(iter.fold(first, |acc, r| merge_bounds(acc, *r))) } /// Compute the layout quality metrics for a completed view. /// /// PURE: takes data, returns scalars, performs no I/O. The `_config` parameter -/// is kept to match the design's optimizer-facing signature and for forward -/// compatibility; the box geometry is sourced entirely from the `diagram` -/// helpers (which use fixed pixel element sizes), so the config is presently -/// unused. Every term is guaranteed finite (each division guards a zero -/// denominator by returning 0), so empty and single-element views yield -/// all-zero, NaN-free metrics. +/// is kept for the optimizer-facing signature; all geometry comes from the +/// `diagram` helpers (fixed pixel element sizes). Every term is finite: each +/// division guards a zero denominator by returning 0. pub fn compute_layout_metrics( view: &datamodel::StockFlow, _config: &LayoutConfig, ) -> LayoutMetrics { - // --- node boxes (with their owning element for incidence checks) --- - // - // Two box sets, used by different terms: - // * `node_boxes` is the LABEL-MERGED box (`node_box`): each element's own - // label unioned into its shape. The view's visual extent and its - // characteristic node size both include labels, so `sprawl` and - // `aspect_penalty` use this set. - // * `node_shape_boxes` is the bare SHAPE box (`node_shape_box`): - // label-free. `node_overlap` and `node_connector_overlap` use this set - // so they measure exactly what the user cares about -- node SHAPES - // overlapping other node shapes, and a connector passing under a node - // SHAPE (a false-causal-connection at a glance). A connector passing - // only under a node's LABEL is mild noise (labels are semi-transparent - // and no connector terminates on one) and must NOT be charged here; - // label collisions are the province of `label_overlap`. - // Aliases render an aux circle + their source element's label; resolve - // those names once so aliases are charged like any other node (an - // invisible alias would let alias generation game the score). - let alias_names = alias_source_names(&view.elements); - let alias_node_box = |a: &crate::datamodel::view_element::Alias| -> Rect { - let shape = alias_shape_box(a); - match alias_names.get(&a.uid) { - Some(name) => { - let props = alias_label_props_for(a, name, a.label_side); - merge_bounds(shape, label_bounds(&props)) - } - // Dangling alias: the circle still renders; no label. - None => shape, - } - }; - - let node_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Alias(a) => Some((a.uid, alias_node_box(a))), - _ => node_box(e).map(|r| (e.get_uid(), r)), - }) - .collect(); - let node_shape_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| node_shape_box(e).map(|r| (e.get_uid(), r))) - .collect(); + analyze(view, &mut DefectSink { defects: None }) +} - // --- node_overlap (bare shape boxes, normalized by total shape-box area) --- - let total_shape_area: f64 = node_shape_boxes.iter().map(|(_, r)| rect_area(r)).sum(); - let node_overlap = if total_shape_area > 0.0 { - let mut overlap = 0.0; - for i in 0..node_shape_boxes.len() { - for j in (i + 1)..node_shape_boxes.len() { - overlap += rect_overlap_area(&node_shape_boxes[i].1, &node_shape_boxes[j].1); - } - } - overlap / total_shape_area - } else { - 0.0 +/// The metrics of `view` plus every defect behind them, located on the diagram. +/// Computed by the same code as [`compute_layout_metrics`]. +pub fn analyze_layout(view: &datamodel::StockFlow) -> LayoutAnalysis { + let mut sink = DefectSink { + defects: Some(Vec::new()), }; + let metrics = analyze(view, &mut sink); + LayoutAnalysis { + metrics, + defects: sink.defects.unwrap_or_default(), + } +} - // --- connector geometry (shared by several terms) --- +fn analyze(view: &datamodel::StockFlow, sink: &mut DefectSink) -> LayoutMetrics { + let nodes = build_scene_nodes(view); let connectors = collect_connector_geometry(view); - let total_connector_length: f64 = connectors.iter().map(|c| c.length).sum(); - - // --- node_connector_overlap (length inside non-incident shape boxes) --- - // - // Documented as a "fraction of total connector length", so each physical - // sub-length of connector covered by ANY non-incident node shape box must be - // counted AT MOST ONCE. Summing the per-box clipped length double-counts the - // region where two non-incident boxes overlap, which can push the normalized - // value above 1.0 (overlapping shape boxes are common -- a Flow's shape box is - // its whole-pipe bounding box, which frequently overlaps stocks/auxes/other - // flows). Instead, for EACH segment we collect the clip intervals over all - // non-incident boxes and UNION them (merge overlapping/adjacent intervals) - // before summing, so each covered sub-length contributes once and the term is - // a true fraction in [0, 1]. The per-segment merge result is order-independent, - // so this is deterministic regardless of `node_shape_boxes` iteration order. - let node_connector_overlap = if total_connector_length > 0.0 { - let mut inside = 0.0; - for c in &connectors { - for seg in c.polyline.windows(2) { - let dx = seg[1].x - seg[0].x; - let dy = seg[1].y - seg[0].y; - let seg_len = (dx * dx + dy * dy).sqrt(); - if seg_len == 0.0 { - continue; // degenerate segment covers no length - } - // Clip interval [t0, t1] of this segment within each non-incident - // box, in segment-parameter space (t in [0,1]). - let mut intervals: Vec<(f64, f64)> = Vec::new(); - for (uid, rect) in &node_shape_boxes { - if c.incident_uids.contains(uid) { - continue; // skip the connector's own endpoints - } - if let Some(iv) = segment_clip_interval_in_rect(&seg[0], &seg[1], rect) { - intervals.push(iv); - } - } - inside += merged_interval_length(&mut intervals) * seg_len; - } - } - inside / total_connector_length - } else { - 0.0 - }; - // --- label_overlap (per-label obscuration) --- - // - // For each labeled element L, measure how much of its label box B_L is - // covered (obscured) by OTHER drawn geometry, then SUM each label's obscured - // fraction. This is per-label rather than a single corpus-wide ratio: a - // small-but-readability-killing overlap (e.g. a node circle clipping the last - // two characters of a short label) registers at its true obscuration - // fraction instead of being diluted to ~0 by the corpus's total label area - // (the prior `sum_of_overlaps / total_label_area` definition under-counted - // exactly this case). - // - // The coverers of B_L are (a) any OTHER label box and (b) any OTHER element's - // bare *shape* box (`node_shape_box`, NOT the label-merged `node_box`): - // * A label is never charged against its OWN element's shape box. By - // construction a label sits adjacent to (and within the merged bounds of) - // its own element, so charging it there would always add a constant that - // is not a real collision. - // * Comparing against the bare shape box (not the label-merged box) keeps - // "label lands on another label" and "label lands on another node's - // shape" cleanly separate -- the merged box unions that node's own label, - // which would re-count the label-vs-label coverage already captured by - // the label-box term. - // - // A pixel-exact union of all coverers is unnecessary: the covered area is - // approximated by the SUM of individual overlap areas, capped at area(B_L) so - // a label's obscured fraction stays in [0,1] even when coverers overlap each - // other. This is a monotone proxy (more/larger overlaps never decrease the - // fraction). A mutual label-label collision is charged from BOTH labels' - // perspectives -- intended, since both are unreadable. Guards area(B_L) == 0 - // (degenerate label) by skipping it, so the term is always finite. - let label_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Alias(a) => alias_names.get(&a.uid).map(|name| { - let props = alias_label_props_for(a, name, a.label_side); - (a.uid, label_bounds(&props)) - }), - _ => element_label_props(e).map(|props| (e.get_uid(), label_bounds(&props))), - }) - .collect(); - // `node_shape_boxes` is computed once above (shared with node_overlap and - // node_connector_overlap). - let mut label_overlap = 0.0; - for (lbl_uid, lbl) in &label_boxes { - let lbl_area = rect_area(lbl); - if lbl_area <= 0.0 { - continue; // degenerate label box: no NaN, contributes nothing - } - let mut covered = 0.0; - // Covered by every OTHER label box. - for (other_uid, other) in &label_boxes { - if other_uid == lbl_uid { - continue; - } - covered += rect_overlap_area(lbl, other); - } - // Covered by every OTHER element's bare shape box. - for (node_uid, node) in &node_shape_boxes { - if node_uid == lbl_uid { - continue; - } - covered += rect_overlap_area(lbl, node); - } - // Cap the (possibly over-counted) covered area at the label's own area - // so the obscured fraction is in [0,1]. - let obscured_fraction = (covered.min(lbl_area)) / lbl_area; - label_overlap += obscured_fraction; - } + let node_overlap = node_overlap_term(&nodes, sink); + let node_connector_overlap = node_connector_overlap_term(&nodes, &connectors, sink); + let label_overlap = label_overlap_term(&nodes, sink); + let label_connector_overlap = label_connector_overlap_term(&nodes, &connectors, sink); + let crossings = crossings_term(view, connectors.len(), sink); + let crowding = crowding_term(&nodes, &connectors, sink); + let long_connectors = long_connectors_term(&connectors, sink); + let misalignment = misalignment_term(&nodes); - // --- crossings --- - let connector_count = connectors.len(); - let crossings = if connector_count > 0 { - count_crossings(&build_view_segments(view)) as f64 / connector_count as f64 - } else { - 0.0 - }; + let footprints: Vec = nodes.iter().map(SceneNode::footprint_box).collect(); + let total_connector_length: f64 = connectors.iter().map(|c| c.length).sum(); // --- sprawl --- - let sprawl = if !connectors.is_empty() && !node_boxes.is_empty() { + let sprawl = if !connectors.is_empty() && !footprints.is_empty() { let mean_connector_length = total_connector_length / connectors.len() as f64; - let characteristic_node_size = node_boxes + let characteristic_node_size = footprints .iter() - .map(|(_, r)| { + .map(|r| { let w = common::rect_width(r); let h = common::rect_height(r); (w * w + h * h).sqrt() }) .sum::() - / node_boxes.len() as f64; + / footprints.len() as f64; if characteristic_node_size > 0.0 { mean_connector_length / characteristic_node_size } else { @@ -1100,12 +1131,9 @@ pub fn compute_layout_metrics( if mean > 0.0 { let variance = connectors .iter() - .map(|c| { - let d = c.length - mean; - d * d - }) + .map(|c| (c.length - mean).powi(2)) .sum::() - / n; // population variance + / n; variance.sqrt() / mean } else { 0.0 @@ -1114,12 +1142,8 @@ pub fn compute_layout_metrics( 0.0 }; - // --- aspect_penalty --- - // Bounding box over node boxes (union). The aspect ratio is the long side - // over the short side (always >= 1); we penalize the amount by which it - // exceeds the target band. Chosen formula: `ar - TARGET_AR_MAX` (a plain - // unit-of-ratio overshoot). Documented here and matched in the AC1.5 test. - let aspect_penalty = match view_bounding_box(&node_boxes) { + // --- aspect_penalty: long side over short side, beyond the target band --- + let aspect_penalty = match view_bounding_box(&footprints) { Some(bbox) => { let w = common::rect_width(&bbox); let h = common::rect_height(&bbox); @@ -1127,17 +1151,13 @@ pub fn compute_layout_metrics( if short <= 0.0 { 0.0 } else { - let ar = long / short; - (ar - TARGET_AR_MAX).max(0.0) + (long / short - TARGET_AR_MAX).max(0.0) } } None => 0.0, }; - // --- loop_compactness (isoperimetric feedback-loop quality) --- let loop_compactness = compute_loop_compactness(view); - - // --- loop_straightness (loop connectors drawn as visible curves) --- let loop_straightness = compute_loop_straightness(view); // --- flow_bends (mean right-angle bends per flow pipe) --- @@ -1161,1830 +1181,414 @@ pub fn compute_layout_metrics( node_overlap, node_connector_overlap, label_overlap, + label_connector_overlap, crossings, + crowding, sprawl, + long_connectors, edge_length_cv, aspect_penalty, - // reserved; computed in a future rung - chain_straightness: 0.0, + misalignment, loop_compactness, flow_bends, loop_straightness, } } -/// Union of the node boxes, or `None` if there are no node boxes. -fn view_bounding_box(node_boxes: &[(i32, Rect)]) -> Option { - let mut iter = node_boxes.iter(); - let first = iter.next()?.1; - Some(iter.fold(first, |acc, (_, r)| merge_bounds(acc, *r))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::datamodel::view_element::{self, LabelSide, LinkShape}; - // `segment_length_in_rect` is the simple single-box clip; the AC1.3 tests and - // the union tests use it as an independent reference oracle to cross-check the - // production union path (which composes `segment_clip_interval_in_rect`). - use crate::diagram::common::segment_length_in_rect; - use crate::diagram::constants::STOCK_WIDTH; - use proptest::prelude::*; - - // --- fixture helpers --- - - fn stock(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { - ViewElement::Stock(view_element::Stock { - name: name.to_string(), - uid, - x, - y, - label_side: LabelSide::Bottom, - compat: None, - }) - } - - fn aux(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { - ViewElement::Aux(view_element::Aux { - name: name.to_string(), - uid, - x, - y, - label_side: LabelSide::Bottom, - compat: None, - }) - } - - /// A cloud at `(x, y)`. A cloud is a positioned node with a bare shape box - /// (`cloud_bounds`, a 27x27 square: CLOUD_RADIUS = 13.5) and NO rendered - /// label, so it is the cleanest "obscuring shape" fixture for label_overlap. - fn cloud(uid: i32, x: f64, y: f64) -> ViewElement { - ViewElement::Cloud(view_element::Cloud { - uid, - flow_uid: -1, - x, - y, - compat: None, - }) - } +// --- loop_compactness (isoperimetric feedback-loop quality) ----------------- +// +// What it measures: how cleanly the view draws its feedback loops as visible +// circles. For each simple directed cycle of >= 3 positioned nodes we take the +// node-box centers in cycle order and form a polygon. Its isoperimetric +// quotient Q = 4*PI*Area / Perimeter^2 is 1 for a perfect circle and tends to 0 +// as the polygon collapses toward a line (the area vanishes while the perimeter +// stays large). The per-cycle penalty is `1 - Q` (0 = ideal clean loop, ~1 = +// squished/collinear), and `loop_compactness` is the mean penalty over all +// qualifying cycles (0.0 when the view has no cycle of >= 3 nodes). It thus +// REWARDS well-spread loops and PENALIZES collapsed ones. +// +// Bounds (SD diagrams are small, so this stays O(small) and total): a simple +// cycle is enumerated only up to `MAX_CYCLE_LEN` nodes, and at most +// `MAX_CYCLES` cycles are scored; enumeration stops once the cap is hit. The +// graph is built over positioned node-box elements (aux/stock/flow/module/cloud +// -- the same set as `node_box`); links and flows supply the directed edges. +// +// Determinism: layout is deterministic per seed, but this term is additionally +// independent of element ordering. Adjacency targets are sorted, the DFS starts +// from each node in sorted uid order, and every enumerated cycle is canonicalized +// (rotated so its smallest uid is first) and de-duplicated, so the mean is the +// same regardless of how the elements are listed in the view. - fn straight_link(uid: i32, from_uid: i32, to_uid: i32) -> ViewElement { - ViewElement::Link(view_element::Link { - uid, - from_uid, - to_uid, - shape: LinkShape::Straight, - polarity: None, - }) - } +/// Maximum number of nodes in an enumerated simple cycle. SD feedback loops are +/// short; a longer "cycle" is almost always an artifact of many overlapping +/// smaller loops and is not worth the combinatorial cost. +const MAX_CYCLE_LEN: usize = 12; - fn arc_link(uid: i32, from_uid: i32, to_uid: i32, angle: f64) -> ViewElement { - ViewElement::Link(view_element::Link { - uid, - from_uid, - to_uid, - shape: LinkShape::Arc(angle), - polarity: None, - }) - } +/// Maximum number of distinct simple cycles scored. Bounds the work on dense +/// graphs; the mean penalty over the first `MAX_CYCLES` cycles is a faithful +/// proxy for the whole (SD diagrams rarely approach this). +const MAX_CYCLES: usize = 64; - /// A flow valve at `(x, y)` with a two-point polyline whose endpoints attach - /// to `from_uid` and `to_uid` (a stock--flow--stock segment). The point - /// coordinates are irrelevant to `loop_compactness` (which uses node-box - /// centers, not flow points), so they are placed at the valve. - fn flow_between( - uid: i32, - name: &str, - x: f64, - y: f64, - from_uid: i32, - to_uid: i32, - ) -> ViewElement { - ViewElement::Flow(view_element::Flow { - name: name.to_string(), - uid, - x, - y, - label_side: LabelSide::Bottom, - points: vec![ - view_element::FlowPoint { - x, - y, - attached_to_uid: Some(from_uid), - }, - view_element::FlowPoint { - x, - y, - attached_to_uid: Some(to_uid), - }, - ], - compat: None, - label_compat: None, - }) - } +/// Directed adjacency over positioned node-box elements, keyed by uid with +/// sorted successor lists. Each node's loop vertex is the renderer's VISUAL +/// center (`diagram::connector::get_visual_center`) -- for a flow that is its +/// VALVE `(flow.x, flow.y)`, NOT the pipe-extent center of `flow_shape_bounds` +/// (which unions the valve box with every pipe point and so drifts off the valve +/// when the pipe is bent or the valve is dragged off-center); for an +/// aux/stock/module/cloud it is the element center, which already equals the +/// symmetric shape-box midpoint. Using the same visual center the SVG renderer +/// draws keeps the loop polygon faithful to the drawn diagram. +struct LoopGraph { + /// uid -> sorted, de-duplicated successor uids. + adj: BTreeMap>, + /// uid -> node visual-center point (the valve for flows; the element center + /// for aux/stock/module/cloud). + centers: BTreeMap, +} - fn make_view(elements: Vec) -> datamodel::StockFlow { - datamodel::StockFlow { - name: None, - elements, - view_box: datamodel::Rect { - x: 0.0, - y: 0.0, - width: 1000.0, - height: 1000.0, - }, - zoom: 1.0, - use_lettered_polarity: false, - font: None, - sketch_compat: None, +/// Build the directed loop graph from the view. Nodes are exactly the elements +/// with a node box (`node_shape_box` -- aux/stock/module/cloud/flow; links, +/// aliases, and groups are excluded). Each node's loop vertex is the renderer's +/// VISUAL center (`get_visual_center`), so a flow's vertex is its VALVE +/// `(flow.x, flow.y)`, NOT the pipe-extent center of `flow_shape_bounds` (the +/// valve box unioned with every pipe point), which drifts off the valve when the +/// pipe is bent or the valve is dragged off-center. For aux/stock/module/cloud +/// the visual center is the element center, which already equals the symmetric +/// shape-box midpoint, so those vertices are unchanged. Edges to/from uids that +/// are not positioned nodes are dropped. Edges come from: +/// * each Link: `from_uid -> to_uid`; +/// * each Flow: for consecutive attached points, `source_attached -> flow.uid` +/// and `flow.uid -> dest_attached`, so a stock--flow--stock feedback path is +/// part of the graph (the flow's own valve is the intermediate node). +fn build_loop_graph(view: &datamodel::StockFlow) -> LoopGraph { + // The node-membership gate stays `node_shape_box` (it defines which elements + // are loop nodes), but the loop VERTEX is the renderer's visual center, which + // is correct for every gated kind: the valve for a flow, the element center + // for aux/stock/module/cloud. `not_arrayed` matches `collect_connector_geometry` + // / `build_view_segments` (offset 0, deterministic). + let not_arrayed = |_: &str| false; + let mut centers: BTreeMap = BTreeMap::new(); + for e in &view.elements { + if node_shape_box(e).is_some() { + let (cx, cy) = get_visual_center(e, ¬_arrayed); + centers.insert(e.get_uid(), Point { x: cx, y: cy }); } } - fn cfg() -> LayoutConfig { - LayoutConfig::default() - } - - /// An alias (ghost) of the element with uid `alias_of_uid`, at `(x, y)`. - /// Renders as an aux-sized circle labeled with the SOURCE element's name. - fn alias_of(uid: i32, alias_of_uid: i32, x: f64, y: f64) -> ViewElement { - ViewElement::Alias(view_element::Alias { - uid, - alias_of_uid, - x, - y, - label_side: LabelSide::Bottom, - compat: None, - }) - } - - // --- alias scoring --- - // - // An alias renders as an aux-sized circle plus its source element's label; - // the metric must charge it like any other node. If aliases were invisible - // (the pre-rung-4 state), alias GENERATION could game the score: ghosts - // could pile on top of anything for free. - - #[test] - fn test_alias_node_overlap_charged() { - // An alias stacked exactly on an aux vs the same alias far away: the - // stacked layout must score strictly worse on node_overlap. - let stacked = make_view(vec![ - aux(1, "source variable", 100.0, 100.0), - aux(2, "another aux", 300.0, 100.0), - alias_of(3, 1, 300.0, 100.0), - ]); - let apart = make_view(vec![ - aux(1, "source variable", 100.0, 100.0), - aux(2, "another aux", 300.0, 100.0), - alias_of(3, 1, 600.0, 100.0), - ]); - let m_stacked = compute_layout_metrics(&stacked, &cfg()); - let m_apart = compute_layout_metrics(&apart, &cfg()); - assert!( - m_stacked.node_overlap > m_apart.node_overlap, - "an alias stacked on a node must be charged: stacked {} vs apart {}", - m_stacked.node_overlap, - m_apart.node_overlap, - ); - assert!( - m_apart.node_overlap.abs() < 1e-9, - "the far-apart alias layout has no overlap to charge" - ); - } - - #[test] - fn test_alias_label_sized_by_source_name() { - // The alias's label box is the SOURCE element's name. Two layouts with - // identical geometry, differing only in the source's name length: the - // long-named source's alias label must collide with a nearby aux's - // label while the short-named one's must not. - let dx = 80.0; - let long_name = make_view(vec![ - aux(1, "an extremely long variable name here", 100.0, 600.0), - aux(2, "consumer", 300.0, 100.0), - alias_of(3, 1, 300.0 + dx, 100.0), - ]); - let short_name = make_view(vec![ - aux(1, "x", 100.0, 600.0), - aux(2, "consumer", 300.0, 100.0), - alias_of(3, 1, 300.0 + dx, 100.0), - ]); - let m_long = compute_layout_metrics(&long_name, &cfg()); - let m_short = compute_layout_metrics(&short_name, &cfg()); - assert!( - m_long.label_overlap > m_short.label_overlap, - "a long source name must widen the alias label and collide: long {} vs short {}", - m_long.label_overlap, - m_short.label_overlap, - ); - } - - #[test] - fn test_alias_with_dangling_source_is_ignored() { - // An alias whose alias_of_uid resolves to nothing (corrupt/partial - // view) must not panic and must not be charged a label. - let view = make_view(vec![ - aux(1, "real aux", 100.0, 100.0), - alias_of(2, 999, 100.0, 100.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - // The dangling alias still has a SHAPE (it renders a circle), so - // node_overlap is charged; but no label can be derived for it. - assert!(m.node_overlap > 0.0, "the alias circle still overlaps"); - assert!(m.label_overlap.is_finite()); - } - - #[test] - fn test_alias_extends_view_bounding_box() { - // A far-flung alias must extend the layout's bounding box (it is a - // drawn element), which shows up in the aspect_penalty/sprawl inputs. - // Compare a compact two-aux view against the same view plus an alias - // parked far to the right: the bounding box must widen. - let compact = make_view(vec![ - aux(1, "a", 100.0, 100.0), - aux(2, "b", 300.0, 100.0), - straight_link(10, 1, 2), - ]); - let with_far_alias = make_view(vec![ - aux(1, "a", 100.0, 100.0), - aux(2, "b", 300.0, 100.0), - straight_link(10, 1, 2), - alias_of(3, 1, 2000.0, 100.0), - ]); - let m_compact = compute_layout_metrics(&compact, &cfg()); - let m_far = compute_layout_metrics(&with_far_alias, &cfg()); - assert!( - m_far.aspect_penalty > m_compact.aspect_penalty, - "a far-flung alias must widen the bounding box and trip the aspect \ - penalty: with {} vs without {}", - m_far.aspect_penalty, - m_compact.aspect_penalty, - ); - } - - /// Scale every coordinate of a view by `s` (element centers and any - /// flow/connector points). Used by the AC1.8 scale-invariance test. - fn scale_view(view: &datamodel::StockFlow, s: f64) -> datamodel::StockFlow { - let elements = view - .elements - .iter() - .map(|e| match e { - ViewElement::Aux(a) => ViewElement::Aux(view_element::Aux { - x: a.x * s, - y: a.y * s, - ..a.clone() - }), - ViewElement::Stock(st) => ViewElement::Stock(view_element::Stock { - x: st.x * s, - y: st.y * s, - ..st.clone() - }), - ViewElement::Flow(f) => ViewElement::Flow(view_element::Flow { - x: f.x * s, - y: f.y * s, - points: f - .points - .iter() - .map(|p| view_element::FlowPoint { - x: p.x * s, - y: p.y * s, - attached_to_uid: p.attached_to_uid, - }) - .collect(), - ..f.clone() - }), - ViewElement::Module(m) => ViewElement::Module(view_element::Module { - x: m.x * s, - y: m.y * s, - ..m.clone() - }), - ViewElement::Cloud(c) => ViewElement::Cloud(view_element::Cloud { - x: c.x * s, - y: c.y * s, - ..c.clone() - }), - ViewElement::Alias(a) => ViewElement::Alias(view_element::Alias { - x: a.x * s, - y: a.y * s, - ..a.clone() - }), - other => other.clone(), - }) - .collect(); - datamodel::StockFlow { - elements, - ..view.clone() + // 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); } - } + }; - // --- AC1.1: node_overlap equals known overlap / total node area --- - - #[test] - fn test_node_overlap_known_overlap_fraction() { - // Two stocks (45x35) whose centers are 20px apart horizontally and at - // the same y. node_overlap is computed on the bare SHAPE boxes (not the - // label-merged boxes), so the expected value comes from - // `stock_shape_bounds` and is normalized by the total SHAPE-box area. - let s1 = stock(1, "a", 100.0, 100.0); - let s2 = stock(2, "b", 120.0, 100.0); - let view = make_view(vec![s1.clone(), s2.clone()]); - - let m = compute_layout_metrics(&view, &cfg()); - - // Expected: compute directly from the two bare shape boxes the renderer - // draws (the rects, label-free). - let b1 = node_shape_box(&s1).unwrap(); - let b2 = node_shape_box(&s2).unwrap(); - let expected_overlap = rect_overlap_area(&b1, &b2); - let expected_total = rect_area(&b1) + rect_area(&b2); - assert!(expected_overlap > 0.0, "fixture must actually overlap"); - let expected = expected_overlap / expected_total; - assert!( - (m.node_overlap - expected).abs() < 1e-9, - "node_overlap {} != expected {}", - m.node_overlap, - expected - ); + for e in &view.elements { + match e { + ViewElement::Link(link) => { + add_edge(link.from_uid, link.to_uid, ¢ers); + } + ViewElement::Flow(flow) => { + // Consecutive attached points define stock->flow and flow->stock + // edges through the flow's own valve uid. + let attached: Vec = flow + .points + .iter() + .filter_map(|p| p.attached_to_uid) + .collect(); + for w in attached.windows(2) { + add_edge(w[0], flow.uid, ¢ers); + add_edge(flow.uid, w[1], ¢ers); + } + } + _ => {} + } } - #[test] - fn test_node_overlap_simple_hand_computed() { - // Two stocks with exactly one stock-width of horizontal center - // separation. node_overlap is a sum over the bare SHAPE boxes, so only - // the rects matter (labels are irrelevant to this term now). - let s1 = stock(1, "a", 0.0, 0.0); - let s2 = stock(2, "b", STOCK_WIDTH, 0.0); // centers exactly one width apart - let view = make_view(vec![s1, s2]); - let m = compute_layout_metrics(&view, &cfg()); - // Centers one full width apart -> the 45-wide shape boxes just touch in - // x (right edge of #1 at +22.5, left edge of #2 at +22.5): zero shape - // overlap. So node_overlap == 0. - assert_eq!(m.node_overlap, 0.0); - } - - // --- AC1.2: pairwise-disjoint nodes => node_overlap == 0 --- - - #[test] - fn test_node_overlap_disjoint_is_zero() { - let view = make_view(vec![ - stock(1, "a", 0.0, 0.0), - stock(2, "b", 500.0, 500.0), - aux(3, "c", 1000.0, 0.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.node_overlap, 0.0); - } - - // node_overlap is computed on the bare SHAPE boxes, NOT the label-merged - // boxes. The user cares about node shapes overlapping other node shapes; - // a label landing on another node's shape (or another label) is the - // province of `label_overlap`. This test distinguishes the two regimes and - // would FAIL against the prior label-merged-box implementation. - - #[test] - fn test_node_overlap_labels_overlap_shapes_disjoint_is_zero() { - // Two `LabelSide::Bottom` auxes named "samename" (8 chars), 40px apart - // horizontally at the same y -- the same fixture as the label_overlap - // double-count regression test: - // aux1 @ (0,0): shape [-9,9]x[-9,9], label [-29,29]x[13,27] - // aux2 @ (40,0): shape [31,49]x[-9,9], label [11,69]x[13,27] - // The SHAPE boxes are disjoint (9 < 31), so node_overlap == 0. The - // LABEL boxes overlap, but that collision belongs to label_overlap, not - // node_overlap. Under the old label-merged boxes node_overlap would be - // > 0 (the merged boxes [-29,29]x[-9,27] and [11,69]x[-9,27] overlap), - // so this assertion pins the new shape-only behavior. - let view = make_view(vec![ - aux(1, "samename", 0.0, 0.0), - aux(2, "samename", 40.0, 0.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!( - m.node_overlap, 0.0, - "node_overlap must ignore label-only overlap (shapes are disjoint)" - ); - // Sanity: the label collision IS captured by label_overlap, confirming - // the overlap was not simply lost. - assert!( - m.label_overlap > 0.0, - "the label-vs-label overlap must still be charged by label_overlap" - ); - } - - // --- AC1.3: node_connector_overlap --- - - #[test] - fn test_node_connector_overlap_through_third_node() { - // Connector from aux #1 (far left) to aux #2 (far right), passing - // horizontally through a stock #3 sitting on the line at the middle. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let mid = stock(3, "s", 200.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, mid, link]); - - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.node_connector_overlap > 0.0, - "connector passing through a non-incident stock must contribute" - ); + let adj: BTreeMap> = edge_sets + .into_iter() + .map(|(k, set)| (k, set.into_iter().collect())) + .collect(); + LoopGraph { adj, centers } +} - // Expected = clipped length inside the stock SHAPE box / total polyline - // len. node_connector_overlap charges against the bare shape box, not - // the label-merged box. (The connector is horizontal at y=0, so the - // clipped length happens to be identical to the label-merged box here; - // the SHAPE box is the contract regardless.) - let connectors = collect_connector_geometry(&view); - assert_eq!(connectors.len(), 1); - let c = &connectors[0]; - let stock_box = node_shape_box(&stock(3, "s", 200.0, 0.0)).unwrap(); - let mut inside = 0.0; - for seg in c.polyline.windows(2) { - inside += segment_length_in_rect(&seg[0], &seg[1], &stock_box); +/// Enumerate simple directed cycles (each >= 2 nodes), bounded by +/// `MAX_CYCLE_LEN` and `MAX_CYCLES`, canonicalized and de-duplicated so the same +/// directed cycle is returned exactly once regardless of where the search +/// started. A bounded DFS suffices: SD diagrams are tiny, and the caps keep it +/// O(small) on the rare dense graph. +/// +/// Each returned cycle is a `Vec` of uids in traversal order, rotated so +/// its smallest uid is first (canonical form), and the set of returned cycles is +/// itself sorted for a fully deterministic result. +fn enumerate_simple_cycles(graph: &LoopGraph) -> Vec> { + let mut found: BTreeSet> = BTreeSet::new(); + // Start a DFS from each node in sorted uid order. To avoid re-finding the + // same cycle from each of its members we still canonicalize+dedup, but we + // also restrict each search to cycles whose minimum node is the start node, + // which prunes the bulk of the duplicate work. + let starts: Vec = graph.adj.keys().copied().collect(); + let mut path: Vec = Vec::new(); + let mut on_path: HashSet = HashSet::new(); + for &start in &starts { + path.clear(); + on_path.clear(); + dfs_cycles(graph, start, start, &mut path, &mut on_path, &mut found); + if found.len() >= MAX_CYCLES { + break; } - let expected = inside / c.length; - assert!( - (m.node_connector_overlap - expected).abs() < 1e-9, - "got {} expected {}", - m.node_connector_overlap, - expected - ); - } - - #[test] - fn test_node_connector_overlap_avoids_all_is_zero() { - // Connector between two auxes with a third node well off the line. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let off = stock(3, "s", 200.0, 500.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, off, link]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.node_connector_overlap, 0.0); - } - - // node_connector_overlap charges a connector for the length it spends - // inside a non-incident node's bare SHAPE box, NOT its label-merged box. - // The user reads a connector passing under a node SHAPE as a false causal - // connection (high priority); a connector passing only under a node's LABEL - // is mild noise (labels are semi-transparent, no connector starts/ends on a - // label) and must NOT be charged. These two tests pin that distinction; the - // first would FAIL against the prior label-merged-box implementation. - - #[test] - fn test_node_connector_overlap_under_label_only_is_zero() { - // Connector from aux #1 (0,0) to aux #2 (400,0): a horizontal line at - // y=0 (clipped to the 9px aux radii, so drawn x in [9, 391]). A - // non-incident `LabelSide::Bottom` stock #3 named "s" (1 char) is placed - // ABOVE the line so its SHAPE box clears y=0 but its label (which hangs - // BELOW the shape) reaches down across y=0: - // stock #3 @ (200,-25): - // shape box x [177.5, 222.5], y [-42.5, -7.5] (does NOT cross 0) - // label box x [192, 208], y [-3.5, 10.5] (DOES cross 0) - // The connector at y=0 passes through the label band but never enters - // the shape box, so node_connector_overlap == 0. Under the old - // label-merged box (which unions the label, y [-42.5, 10.5]) the line - // WOULD be charged, so this assertion is the load-bearing distinction. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let label_only = stock(3, "s", 200.0, -25.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, label_only, link]); - - // Confirm the fixture geometry is what we claim before asserting on the - // metric: shape box clears the line, merged box does not. - let shape = node_shape_box(&stock(3, "s", 200.0, -25.0)).unwrap(); - let merged = node_box(&stock(3, "s", 200.0, -25.0)).unwrap(); - assert!( - shape.bottom < 0.0, - "shape box must clear the connector line (bottom {} < 0)", - shape.bottom - ); - assert!( - merged.bottom > 0.0, - "merged box must cross the connector line via the label (bottom {} > 0)", - merged.bottom - ); - - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!( - m.node_connector_overlap, 0.0, - "a connector passing only under a node's LABEL must not be charged" - ); } + found.into_iter().take(MAX_CYCLES).collect() +} - #[test] - fn test_node_connector_overlap_under_shape_is_positive() { - // Same connector, but the non-incident stock sits ON the line so the - // connector crosses its SHAPE box -- the false-causal-connection case - // the user cares about. node_connector_overlap > 0. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let on_line = stock(3, "s", 200.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, on_line, link]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.node_connector_overlap > 0.0, - "a connector passing under a node SHAPE must be charged" - ); +/// Depth-first walk that records every simple cycle returning to `start` and +/// composed only of nodes whose uid is >= `start` (so each cycle is discovered +/// from its smallest member). `path`/`on_path` track the current simple path. +fn dfs_cycles( + graph: &LoopGraph, + start: i32, + current: i32, + path: &mut Vec, + on_path: &mut HashSet, + found: &mut BTreeSet>, +) { + if found.len() >= MAX_CYCLES { + return; } + path.push(current); + on_path.insert(current); - // node_connector_overlap is documented as a "fraction of total connector - // length", so it must count each physical sub-length of connector covered by - // ANY non-incident node shape box AT MOST ONCE. When two non-incident shape - // boxes overlap, the prior implementation summed the per-box clipped lengths, - // double-counting the connector segment that lies in the overlap region; the - // normalized value could then exceed 1.0 and over-inflate weighted_cost. The - // correct value is the UNION length covered by (box A OR box B) over the total - // connector length. These two tests pin the union contract. - - /// Length of segment p0->p1 covered by the UNION of `rects` (each physical - /// sub-length counted once). Independent reference implementation used by the - /// union tests: collect each rect's Liang-Barsky clip interval, merge, sum. - fn union_segment_length_in_rects(p0: &Point, p1: &Point, rects: &[Rect]) -> f64 { - let seg_len = { - let dx = p1.x - p0.x; - let dy = p1.y - p0.y; - (dx * dx + dy * dy).sqrt() - }; - if seg_len == 0.0 { - return 0.0; - } - let mut intervals: Vec<(f64, f64)> = Vec::new(); - for r in rects { - // Recover [t0, t1] from segment_length_in_rect's reported length: the - // tests use axis-aligned horizontal segments, so the clipped length is - // an exact multiple of seg_len. We instead build intervals from the - // covered length by reconstructing endpoints via the rect bounds for a - // horizontal segment at constant y (the only geometry these tests use). - let covered = segment_length_in_rect(p0, p1, r); - if covered <= 0.0 { - continue; - } - // For a horizontal segment (y constant) inside [left,right], the - // covered x-range is [max(min_x,left), min(max_x,right)]. Convert to t. - let (xa, xb) = (p0.x.min(p1.x), p0.x.max(p1.x)); - let lo_x = xa.max(r.left); - let hi_x = xb.min(r.right); - let span = p1.x - p0.x; - let t_lo = ((lo_x - p0.x) / span).clamp(0.0, 1.0); - let t_hi = ((hi_x - p0.x) / span).clamp(0.0, 1.0); - let (t0, t1) = if t_lo <= t_hi { - (t_lo, t_hi) - } else { - (t_hi, t_lo) - }; - intervals.push((t0, t1)); - } - intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - let mut total = 0.0; - let mut cur: Option<(f64, f64)> = None; - for (t0, t1) in intervals { - match cur { - None => cur = Some((t0, t1)), - Some((c0, c1)) => { - if t0 <= c1 { - cur = Some((c0, c1.max(t1))); - } else { - total += c1 - c0; - cur = Some((t0, t1)); + if let Some(succs) = graph.adj.get(¤t) { + for &next in succs { + if next == start { + // Closed a cycle back to the start. Record it (>= 2 nodes by + // construction; self-loops were never added as edges). + if path.len() >= 2 { + found.insert(canonicalize_cycle(path)); + if found.len() >= MAX_CYCLES { + break; } } + continue; } - } - if let Some((c0, c1)) = cur { - total += c1 - c0; - } - total * seg_len - } - - #[test] - fn test_node_connector_overlap_union_of_overlapping_boxes() { - // A horizontal Link between aux #1 (0,0) and aux #2 (400,0) at y=0. Two - // NON-incident stocks straddle the line AND overlap each other: - // stock #3 @ (200,0): shape x [177.5, 222.5] - // stock #4 @ (210,0): shape x [187.5, 232.5] - // Their shape boxes overlap in x [187.5, 222.5]. The OLD code charged the - // connector for box A (length 45) PLUS box B (length 45) = 90, but the - // physical connector length under (A OR B) is the union x [177.5, 232.5] - // = 55. The new metric must equal union/total, and the old sum/total - // strictly exceeds it. - let a = aux(1, "a", 0.0, 0.0); - let b = aux(2, "b", 400.0, 0.0); - let s3 = stock(3, "s3", 200.0, 0.0); - let s4 = stock(4, "s4", 210.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, s3.clone(), s4.clone(), link]); - - let m = compute_layout_metrics(&view, &cfg()); - - let connectors = collect_connector_geometry(&view); - assert_eq!(connectors.len(), 1); - let c = &connectors[0]; - let box3 = node_shape_box(&s3).unwrap(); - let box4 = node_shape_box(&s4).unwrap(); - - // Independent union reference and the old (double-counting) sum. - let mut union_len = 0.0; - let mut old_sum_len = 0.0; - for seg in c.polyline.windows(2) { - union_len += union_segment_length_in_rects(&seg[0], &seg[1], &[box3, box4]); - old_sum_len += segment_length_in_rect(&seg[0], &seg[1], &box3) - + segment_length_in_rect(&seg[0], &seg[1], &box4); - } - let expected = union_len / c.length; - let old_value = old_sum_len / c.length; - - // The fixture must actually overlap so the old sum strictly exceeds the - // union (otherwise the test proves nothing). - assert!( - old_value > expected + 1e-9, - "fixture must double-count: old {old_value} should exceed union {expected}" - ); - assert!( - (m.node_connector_overlap - expected).abs() < 1e-9, - "node_connector_overlap must equal the union fraction: got {} expected {} \ - (old double-counted value was {})", - m.node_connector_overlap, - expected, - old_value - ); - assert!( - m.node_connector_overlap <= 1.0, - "node_connector_overlap is a fraction and must be <= 1.0, got {}", - m.node_connector_overlap - ); - } - - #[test] - fn test_node_connector_overlap_coincident_boxes_counted_once() { - // Starker variant: a connector sub-length fully inside TWO COINCIDENT - // non-incident boxes is counted ONCE, not twice. Two stocks at the same - // position (200,0) each fully contain the connector segment x [177.5, - // 222.5]. The OLD code would count that length twice (~2x); the union - // counts it once. We also build the fixture so the total connector length - // is small enough that the OLD value EXCEEDS 1.0 -- impossible for a - // documented fraction. Auxes are placed close in (x 180 and 220) so the - // drawn connector is short and lies entirely within the coincident boxes. - let a = aux(1, "a", 180.0, 0.0); - let b = aux(2, "b", 220.0, 0.0); - let s3 = stock(3, "s3", 200.0, 0.0); - let s4 = stock(4, "s4", 200.0, 0.0); - let link = straight_link(10, 1, 2); - let view = make_view(vec![a, b, s3.clone(), s4.clone(), link]); - - let m = compute_layout_metrics(&view, &cfg()); - - let connectors = collect_connector_geometry(&view); - assert_eq!(connectors.len(), 1); - let c = &connectors[0]; - let box3 = node_shape_box(&s3).unwrap(); - let box4 = node_shape_box(&s4).unwrap(); - - let mut union_len = 0.0; - let mut old_sum_len = 0.0; - for seg in c.polyline.windows(2) { - union_len += union_segment_length_in_rects(&seg[0], &seg[1], &[box3, box4]); - old_sum_len += segment_length_in_rect(&seg[0], &seg[1], &box3) - + segment_length_in_rect(&seg[0], &seg[1], &box4); - } - let expected = union_len / c.length; - let old_value = old_sum_len / c.length; - - // With two coincident boxes both covering the whole drawn connector, the - // union fraction is 1.0 and the old value is ~2.0 (> 1.0, impossible for a - // fraction). - assert!( - old_value > 1.0, - "coincident-box fixture must drive the OLD value above 1.0 (got {old_value})" - ); - assert!( - (expected - 1.0).abs() < 1e-9, - "union of two coincident boxes covering the whole connector is the full \ - length (fraction 1.0), got {expected}" - ); - assert!( - (m.node_connector_overlap - expected).abs() < 1e-9, - "coincident non-incident boxes must be counted once: got {} expected {} \ - (old double-counted value was {})", - m.node_connector_overlap, - expected, - old_value - ); - assert!( - m.node_connector_overlap <= 1.0 + 1e-9, - "node_connector_overlap is a fraction and must be <= 1.0, got {}", - m.node_connector_overlap - ); - } - - // --- AC1.4: label_overlap (per-label obscuration) --- - // - // label_overlap is the SUM over labeled elements of each label's obscured - // fraction: the area of the label box covered by any OTHER label box or any - // OTHER element's bare shape box, capped at the label's own area and divided - // by it (so each term is in [0,1]). 0 = no label obscured. A small overlap - // registers at its true per-label obscuration fraction rather than being - // diluted by the corpus's total label area (the old area/total definition's - // under-counting; see `test_label_overlap_small_clip_is_sensitive`). - - #[test] - fn test_label_overlap_overlapping_labels() { - // Two auxes at the same position -> their labels (Bottom) coincide - // exactly. Each label is fully covered by the other (capped at its own - // area), so each obscured fraction is 1.0 and the sum is 2.0. - let view = make_view(vec![ - aux(1, "samename", 100.0, 100.0), - aux(2, "samename", 100.0, 100.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - (m.label_overlap - 2.0).abs() < 1e-9, - "two coincident labels are each fully obscured: expected 2.0, got {}", - m.label_overlap - ); - } - - #[test] - fn test_label_overlap_disjoint_is_zero() { - // Two auxes far apart -> no label is covered by anything. Sum of - // obscured fractions is 0.0. - let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 1000.0, 1000.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.label_overlap, 0.0); - } - - #[test] - fn test_label_overlap_counts_label_pair_exactly_once() { - // The Phase-1 double-count guard, restated for per-label obscuration: a - // label is never charged against its OWN element's shape box, and a - // label-vs-label collision is counted from each label's own perspective - // (both labels are unreadable -- that is intended), not via the other - // node's label-merged bounds. - // - // Fixture: two `LabelSide::Bottom` auxes named "samename" (8 chars). - // AUX_RADIUS = 9; label editor width = 8*6 + 10 = 58, height = 14. - // With Bottom labels, label top = cy + 9 + LABEL_PADDING(4) = cy + 13, - // bottom = cy + 27, left = cx - 29, right = cx + 29. - // - // Place them 40px apart horizontally, same y: - // aux1 @ (0,0): shape [-9,9]x[-9,9], label [-29,29]x[13,27] - // aux2 @ (40,0): shape [31,49]x[-9,9], label [11,69]x[13,27] - // - // SHAPE boxes do NOT overlap (9 < 31), and each label clears the OTHER - // aux's bare shape box entirely (label y [13,27] vs shape y [-9,9]). The - // LABELS overlap by x:[11,29]=18, y:[13,27]=14 -> 252. Each label box has - // area 58*14 = 812 and is covered only by the other label (252 < 812, no - // cap), so each obscured fraction is 252/812 and the sum is 504/812. - let view = make_view(vec![ - aux(1, "samename", 0.0, 0.0), - aux(2, "samename", 40.0, 0.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - - let label_area = 58.0 * 14.0; // 812.0 - let overlap = 18.0 * 14.0; // 252.0, the single label-label intersection - let expected = (overlap / label_area) + (overlap / label_area); // 504/812 - assert!( - (m.label_overlap - expected).abs() < 1e-9, - "per-label obscuration should sum each label's fraction once: got {} expected {}", - m.label_overlap, - expected - ); - } - - #[test] - fn test_label_overlap_never_charged_against_own_shape() { - // A single labeled aux: its Bottom label sits adjacent to (and partly - // within the merged bounds of) its OWN shape. A label is never charged - // against its own element's shape, and there is no other element, so the - // obscured fraction is 0 and label_overlap is exactly 0.0. - let view = make_view(vec![aux(1, "samename", 0.0, 0.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!( - m.label_overlap, 0.0, - "a label must never be charged against its own element's shape box" - ); - } - - #[test] - fn test_label_overlap_small_clip_is_sensitive() { - // A small node SHAPE clipping a few characters of a short label must - // register at its true per-label obscuration fraction, NOT be diluted to - // ~0 by the corpus's total label area (the old area/total under-count). - // - // L: aux "ab" (2 chars) @ (0,0), Bottom label. - // editor_width = 2*6 + 10 = 22, height 14 -> label area 308. - // label box: left -11, right 11, top 13, bottom 27. - // O: a cloud (no label) @ (18, 20). cloud_bounds (CLOUD_RADIUS 13.5): - // x [4.5, 31.5], y [6.5, 33.5]. - // Overlap with L's label: x [4.5,11]=6.5, y [13,27]=14 -> 91. - // obscured_fraction(L) = 91/308 ~= 0.2955; the cloud has no label, so - // the sum is exactly 91/308. - // Plus 15 far-apart auxes with long (20-char) labels: each label area - // 20*6+10 = 130 wide * 14 = 1820, none overlapping anything. They add - // nothing to the per-label SUM (obscured fraction 0 each) but bloat the - // OLD denominator (total label area), so the OLD area/total score for - // the same clip collapses to ~0.003 -- the under-count this fixes. - let mut elements = vec![aux(1, "ab", 0.0, 0.0), cloud(2, 18.0, 20.0)]; - for k in 0..15 { - // Far apart on a 1000px grid so nothing overlaps; 20-char names. - elements.push(aux( - 100 + k, - "abcdefghijklmnopqrst", - 3000.0 + f64::from(k) * 1000.0, - 3000.0, - )); - } - let view = make_view(elements); - let m = compute_layout_metrics(&view, &cfg()); - - let label_area = 22.0 * 14.0; // 308.0 - let clip_area = 6.5 * 14.0; // 91.0 - let expected = clip_area / label_area; // ~0.2955 - assert!( - (m.label_overlap - expected).abs() < 1e-9, - "small clip must score its per-label obscuration fraction: got {} expected {}", - m.label_overlap, - expected - ); - assert!( - m.label_overlap > 0.1, - "a readability-killing clip must register clearly (> 0.1), got {}", - m.label_overlap - ); - - // Confirm the OLD area/total definition would have under-counted this to - // near-zero: the same clip area divided by the corpus total label area. - let total_label_area = label_area + 15.0 * (130.0 * 14.0); // 308 + 27300 - let old_score = clip_area / total_label_area; // ~0.0033 - assert!( - old_score < 0.01, - "fixture must demonstrate the old under-count (< 0.01), got {}", - old_score - ); - assert!( - m.label_overlap > old_score * 50.0, - "new per-label score {} must be far larger than the old {}", - m.label_overlap, - old_score - ); - } - - // --- AC1.5: aspect_penalty --- - - #[test] - fn test_aspect_penalty_thin_box_positive() { - // Two auxes stacked far apart vertically and close horizontally -> the - // node bounding box is tall and thin (ar >> target), so penalty > 0. - let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 0.0, 1000.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.aspect_penalty > 0.0, - "a tall thin bbox must be penalized, got {}", - m.aspect_penalty - ); - - // Verify it equals exactly `ar - TARGET_AR_MAX` for the computed bbox. - let node_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| node_box(e).map(|r| (e.get_uid(), r))) - .collect(); - let bbox = view_bounding_box(&node_boxes).unwrap(); - let w = common::rect_width(&bbox); - let h = common::rect_height(&bbox); - let (long, short) = if w >= h { (w, h) } else { (h, w) }; - let expected = (long / short - TARGET_AR_MAX).max(0.0); - assert!((m.aspect_penalty - expected).abs() < 1e-9); - } - - #[test] - fn test_aspect_penalty_balanced_box_zero() { - // Four auxes placed so the bounding box is ~4:3 (well inside the 16:9 - // band) -> zero penalty. Width 400, height 300 between centers; the - // fixed node radii add a small symmetric margin that keeps ar < 16/9. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 400.0, 0.0), - aux(3, "c", 0.0, 300.0), - aux(4, "d", 400.0, 300.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - - // Confirm the bbox aspect ratio really is inside the band for this - // fixture, then assert the penalty is exactly zero. - let node_boxes: Vec<(i32, Rect)> = view - .elements - .iter() - .filter_map(|e| node_box(e).map(|r| (e.get_uid(), r))) - .collect(); - let bbox = view_bounding_box(&node_boxes).unwrap(); - let w = common::rect_width(&bbox); - let h = common::rect_height(&bbox); - let ar = w.max(h) / w.min(h); - assert!(ar <= TARGET_AR_MAX, "fixture bbox ar {} not in band", ar); - assert_eq!(m.aspect_penalty, 0.0); - } - - // --- AC1.6: weighted_cost is the exact linear combination --- - - #[test] - fn test_weighted_cost_exact_linear_combination() { - let m = LayoutMetrics { - node_overlap: 1.5, - node_connector_overlap: 2.0, - label_overlap: 0.5, - crossings: 3.0, - sprawl: 4.0, - edge_length_cv: 0.25, - aspect_penalty: 6.0, - chain_straightness: 7.0, - loop_compactness: 8.0, - flow_bends: 9.0, - loop_straightness: 11.0, - }; - let w = MetricWeights { - node_overlap: 10.0, - node_connector_overlap: 20.0, - label_overlap: 30.0, - crossings: 40.0, - sprawl: 50.0, - edge_length_cv: 60.0, - aspect_penalty: 70.0, - chain_straightness: 80.0, - loop_compactness: 90.0, - flow_bends: 100.0, - loop_straightness: 110.0, - }; - let expected = 1.5 * 10.0 - + 2.0 * 20.0 - + 0.5 * 30.0 - + 3.0 * 40.0 - + 4.0 * 50.0 - + 0.25 * 60.0 - + 6.0 * 70.0 - + 7.0 * 80.0 - + 8.0 * 90.0 - + 9.0 * 100.0 - + 11.0 * 110.0; - assert!((m.weighted_cost(&w) - expected).abs() < 1e-9); - } - - // --- AC5.1: the committed calibrated default expresses readability dominance --- - // - // The Phase-1 placeholder default was all-zeros (so a pre-calibration - // `weighted_cost` was inert). Phase 4 commits real, user-signed-off weights - // (2026-05-23), so the default is no longer all-zeros and `weighted_cost` - // under it is now meaningful. This test pins the DOMINANCE ORDERING the - // committed weights encode -- relationships rather than magic numbers, so it - // documents the intent and survives minor retuning -- and re-confirms that - // `weighted_cost` applies the default exactly as Σ wᵢ·termᵢ. It replaces the - // old "default is all-zeros so cost is inert" assertion, which is no longer - // true by design. - - #[test] - fn test_default_weights_readability_dominant_ordering() { - let w = MetricWeights::default(); - - // The dominant "overlap + crossings" family: each term that hurts - // readability (shapes overlapping shapes, connectors under shapes, labels - // obscured, edges crossing) must outweigh every compactness/aspect term. - let dominant = [ - w.node_overlap, - w.node_connector_overlap, - w.label_overlap, - w.crossings, - ]; - let compactness = [w.sprawl, w.edge_length_cv, w.aspect_penalty]; - for &d in &dominant { - for &c in &compactness { - assert!( - d > c, - "every readability term ({d}) must strictly exceed every \ - compactness/aspect term ({c})" - ); + // Only extend through nodes strictly greater than the start (so the + // start is the minimum), not already on the path, within the length + // cap. + if next > start && !on_path.contains(&next) && path.len() < MAX_CYCLE_LEN { + dfs_cycles(graph, start, next, path, on_path, found); + if found.len() >= MAX_CYCLES { + break; + } } } - - // `sprawl` is a GENTLE compactness counterweight: strictly positive (so - // unbounded inflation is penalized and the cost has a finite optimum at - // "spread just enough"), but far below the dominant readability family - // (checked by the dominant>compactness loop above), so readability still - // wins decisively over compactness. - assert!( - w.sprawl > 0.0, - "sprawl must be a positive compactness counterweight, got {}", - w.sprawl - ); - assert!( - w.sprawl < 0.5 * w.label_overlap, - "sprawl ({}) must stay well below the readability terms ({})", - w.sprawl, - w.label_overlap - ); - // Edge-length uniformity and aspect ratio remain non-goals. - assert_eq!( - w.edge_length_cv, 0.0, - "edge-length uniformity is not a goal" - ); - assert_eq!(w.aspect_penalty, 0.0, "aspect ratio is not a goal"); - - // chain_straightness is reserved (not yet computed), so it carries no - // weight. - assert_eq!( - w.chain_straightness, 0.0, - "chain_straightness is reserved and must stay zero" - ); - - // loop_compactness rewards visible feedback-loop circles, but only as a - // gentle nudge: a low, non-dominant weight strictly between zero and the - // dominant family. - assert!( - w.loop_compactness > 0.0, - "loop_compactness should gently reward visible loops, got {}", - w.loop_compactness - ); - assert!( - w.loop_compactness < w.node_overlap, - "loop_compactness ({}) must stay below the dominant node_overlap ({})", - w.loop_compactness, - w.node_overlap - ); - - // flow_bends nudges toward straight pipes (aligned stocks), a convention - // aid: positive but well below the dominant family. - assert!( - w.flow_bends > 0.0, - "flow_bends should nudge toward straight flows, got {}", - w.flow_bends - ); - assert!( - w.flow_bends < w.node_overlap, - "flow_bends ({}) must stay below the dominant node_overlap ({})", - w.flow_bends, - w.node_overlap - ); - - // loop_straightness is a Goodhart guard for loop curvature: positive but - // well below the dominant family. - assert!( - w.loop_straightness > 0.0, - "loop_straightness should guard loop curvature, got {}", - w.loop_straightness - ); - assert!( - w.loop_straightness < w.node_overlap, - "loop_straightness ({}) must stay below the dominant node_overlap ({})", - w.loop_straightness, - w.node_overlap - ); - - // `weighted_cost` under the default is still the exact linear combination - // (the default is now meaningful, not inert): verify against an explicit - // Σ wᵢ·termᵢ over a hand-set metrics value. - let m = LayoutMetrics { - node_overlap: 0.3, - node_connector_overlap: 0.1, - label_overlap: 0.7, - crossings: 2.0, - sprawl: 5.0, - edge_length_cv: 0.4, - aspect_penalty: 1.5, - chain_straightness: 0.0, - loop_compactness: 0.8, - flow_bends: 1.0, - loop_straightness: 0.6, - }; - let expected = m.node_overlap * w.node_overlap - + m.node_connector_overlap * w.node_connector_overlap - + m.label_overlap * w.label_overlap - + m.crossings * w.crossings - + m.sprawl * w.sprawl - + m.edge_length_cv * w.edge_length_cv - + m.aspect_penalty * w.aspect_penalty - + m.chain_straightness * w.chain_straightness - + m.loop_compactness * w.loop_compactness - + m.flow_bends * w.flow_bends - + m.loop_straightness * w.loop_straightness; - assert!( - (m.weighted_cost(&w) - expected).abs() < 1e-12, - "weighted_cost under the default must equal Σ wᵢ·termᵢ: got {} expected {}", - m.weighted_cost(&w), - expected - ); } - // --- AC1.7: empty / single-element views are all-zero and finite --- - - fn assert_all_finite(m: &LayoutMetrics) { - assert!(m.node_overlap.is_finite()); - assert!(m.node_connector_overlap.is_finite()); - assert!(m.label_overlap.is_finite()); - assert!(m.crossings.is_finite()); - assert!(m.sprawl.is_finite()); - assert!(m.edge_length_cv.is_finite()); - assert!(m.aspect_penalty.is_finite()); - assert!(m.chain_straightness.is_finite()); - assert!(m.loop_compactness.is_finite()); - assert!(m.flow_bends.is_finite()); - assert!(m.loop_straightness.is_finite()); - } - - fn assert_all_zero(m: &LayoutMetrics) { - assert_eq!(m.node_overlap, 0.0); - assert_eq!(m.node_connector_overlap, 0.0); - assert_eq!(m.label_overlap, 0.0); - assert_eq!(m.crossings, 0.0); - assert_eq!(m.sprawl, 0.0); - assert_eq!(m.edge_length_cv, 0.0); - assert_eq!(m.aspect_penalty, 0.0); - assert_eq!(m.chain_straightness, 0.0); - assert_eq!(m.loop_compactness, 0.0); - assert_eq!(m.flow_bends, 0.0); - assert_eq!(m.loop_straightness, 0.0); - } - - #[test] - fn test_empty_view_all_zero_finite() { - let view = make_view(vec![]); - let m = compute_layout_metrics(&view, &cfg()); - assert_all_finite(&m); - assert_all_zero(&m); - } - - #[test] - fn test_single_element_view_all_zero_finite() { - let view = make_view(vec![aux(1, "only", 100.0, 100.0)]); - let m = compute_layout_metrics(&view, &cfg()); - assert_all_finite(&m); - // A single node has no overlaps, no connectors, and a degenerate (zero - // short-side? no -- a real box) bounding box. Its aspect ratio is the - // single aux box's own ar, which for a square-ish aux box is ~1 (inside - // the band), so aspect_penalty is 0; all connector terms are 0. - assert_eq!(m.node_overlap, 0.0); - assert_eq!(m.node_connector_overlap, 0.0); - assert_eq!(m.crossings, 0.0); - assert_eq!(m.sprawl, 0.0); - assert_eq!(m.edge_length_cv, 0.0); - } - - // --- AC1.8 (scoped): scale invariance under uniform coordinate scaling --- - // - // SCOPING (correction to the AC1.8 plan note, 2026-05-22): the plan listed - // `node_connector_overlap`, `crossings`, `edge_length_cv`, and - // `aspect_penalty` as scale-free. After implementing the metric against the - // ACTUAL renderer geometry (the design's load-bearing invariant: metrics - // are computed on the same geometry the renderer draws), only `crossings` - // is exactly scale-invariant -- and even then only for crossings that lie - // INTERIOR to both connectors, away from the fixed-size node boundaries the - // polylines are clipped to (a crossing grazing a node boundary near a - // segment endpoint can flip; see the detailed note at the assertion below). - // This fixture's crossing is at the center of the square the two links form, - // squarely in that interior regime. The reason the other terms are not - // exactly invariant is the same fixed-pixel element geometry the plan - // already cites for node_overlap/label_overlap/sprawl, and it propagates - // further than the plan anticipated: - // - // * Connectors are clipped to fixed-radius element boundaries, so a - // straight link's drawn length is `s*center_dist - r_from - r_to` - // (AFFINE in `s`, not linear). Hence `edge_length_cv = stddev/mean` of - // those affine lengths is only ASYMPTOTICALLY invariant (the fixed - // offset shrinks relative to the scaled spread), not exactly. - // * `node_connector_overlap` divides an inside-fixed-box overlap length - // (which does NOT scale) by total connector length (which does), so it - // shrinks like ~1/s -- scale-SENSITIVE, like `sprawl`. - // * The view bounding box is `union(fixed boxes around scaled centers)`, - // so its width/height are each `s*span + fixed_box_size`; the aspect - // ratio is therefore only asymptotically invariant. - // - // The principled resolution keeps renderer-faithful geometry (the whole - // point of the phase) and accepts that only the topological `crossings` - // term is exactly scale-invariant. This test asserts that exactly, and - // additionally pins the documented scale-SENSITIVITY of - // `node_connector_overlap` (clean ~1/s) so the scoping is non-vacuous. The - // mismatch with the plan's term list is surfaced in the executor report and - // tracked for the calibration phase. - // - // The fixture has zero node-overlap and zero label-overlap so those - // scale-sensitive area terms are trivially 0 before and after scaling. - #[test] - fn test_scale_invariance_of_scale_free_terms() { - // A small connected, well-separated view: three auxes and two stocks, - // far enough apart that there is no node-overlap and no label-overlap, - // with two straight links (one of which passes through a non-incident - // node so node_connector_overlap is nonzero and meaningful). - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 400.0, 0.0), - stock(3, "s", 200.0, 0.0), // on the a->b line: nonzero conn overlap - aux(4, "c", 0.0, 300.0), - stock(5, "t", 400.0, 320.0), - straight_link(10, 1, 2), // passes through stock #3 - straight_link(11, 4, 5), - ]); - - let base = compute_layout_metrics(&view, &cfg()); - // Sanity: the fixture must have zero node/label overlap (so the - // scale-sensitive area terms are trivially scale-equal) and a nonzero - // conn-overlap (so the documented scale-SENSITIVITY check is - // non-vacuous). - assert_eq!(base.node_overlap, 0.0, "fixture must have no node overlap"); - assert_eq!( - base.label_overlap, 0.0, - "fixture must have no label overlap" - ); - assert!( - base.node_connector_overlap > 0.0, - "fixture must have a connector through a non-incident node" - ); - - let s = 3.0; - let scaled = compute_layout_metrics(&scale_view(&view, s), &cfg()); - - // The one exactly scale-invariant term here: edge crossings. - // - // Crossings are NOT *universally* scale-invariant. A crossing is counted - // on the drawn polylines, which are clipped to the same fixed-pixel node - // boxes (the connector endpoints sit on element boundaries that do not - // scale). A crossing that merely grazes a node boundary near a segment - // endpoint can therefore appear or disappear under uniform scale. - // Crossings that lie comfortably INTERIOR to both connectors (away from - // those fixed-size boundaries) are exactly preserved, because the - // interior of each polyline is an exact affine image of itself under - // uniform scale and an intersection of two segments is invariant under a - // shared affine map. This fixture's crossing is at the center of the - // square the two links form -- maximally far from every node box -- so - // it is squarely in the scale-invariant interior regime and the count is - // preserved exactly. - assert!( - (scaled.crossings - base.crossings).abs() < 1e-9, - "crossings not scale-invariant: {} vs {}", - scaled.crossings, - base.crossings - ); - - // Documented scale-SENSITIVITY of node_connector_overlap: with - // fixed-size node boxes, scaling the coordinates by `s` leaves the - // inside-box overlap length essentially unchanged (the box and the - // line's center crossing are fixed) while total connector length grows - // with `s`, so the ratio strictly DECREASES under up-scaling. (It does - // not drop by exactly 1/s because the denominator -- connector length - // clipped to fixed-radius element boundaries -- is affine in `s`, not - // linear; we assert the robust direction rather than a brittle factor.) - assert!( - scaled.node_connector_overlap < base.node_connector_overlap, - "node_connector_overlap should DROP under up-scaling (fixed boxes): \ - scaled {} should be < base {}", - scaled.node_connector_overlap, - base.node_connector_overlap - ); - } + on_path.remove(¤t); + path.pop(); +} - // --- Property test: node_overlap is symmetric under element shuffle --- - - proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - - /// node_overlap is a sum over unordered element pairs, so it must be - /// invariant under any permutation of the element list. - #[test] - fn prop_node_overlap_shuffle_invariant( - // four stocks at small integer-ish coordinates so some overlap and - // some don't; coordinates kept modest to stay fast. - xs in prop::collection::vec(-50.0f64..50.0, 4), - ys in prop::collection::vec(-50.0f64..50.0, 4), - perm in prop::sample::subsequence(vec![0usize, 1, 2, 3], 4), - ) { - let elems: Vec = (0..4) - .map(|i| stock(i as i32 + 1, "n", xs[i], ys[i])) - .collect(); - - let base = compute_layout_metrics(&make_view(elems.clone()), &cfg()); - - // `perm` is a random ordering of [0,1,2,3]; reorder accordingly. - let shuffled: Vec = perm.iter().map(|&i| elems[i].clone()).collect(); - let other = compute_layout_metrics(&make_view(shuffled), &cfg()); - - prop_assert!( - (base.node_overlap - other.node_overlap).abs() < 1e-9, - "node_overlap changed under shuffle: {} vs {}", - base.node_overlap, - other.node_overlap - ); - } +/// Rotate a cycle so its smallest uid is first, preserving traversal direction. +/// The DFS already guarantees the start (= minimum) is element 0, but rotating +/// defensively keeps the canonical form correct for any caller. +/// +/// Note: this canonicalizes rotation (start at min uid) but NOT traversal +/// direction, so a directed cycle and its reverse canonicalize to distinct +/// entries. That is harmless: a reverse-direction duplicate (essentially never +/// present for directed SD feedback loops, which would require both directed +/// edge sets in the graph) would compute the same isoperimetric penalty because +/// the shoelace polygon area in `cycle_penalty` is direction-invariant. +fn canonicalize_cycle(cycle: &[i32]) -> Vec { + if cycle.is_empty() { + return Vec::new(); } - - // --- loop_compactness (isoperimetric loop quality) --- - - /// The center of a node's bare shape box (which is symmetric about the - /// element position, so this is the element center). Mirrors the centers the - /// metric uses to build each loop polygon. - fn shape_center(e: &ViewElement) -> Point { - let r = node_shape_box(e).unwrap(); - Point { - x: (r.left + r.right) / 2.0, - y: (r.top + r.bottom) / 2.0, - } + let min_idx = cycle + .iter() + .enumerate() + .min_by_key(|&(_, v)| *v) + .map(|(i, _)| i) + .unwrap_or(0); + let mut out = Vec::with_capacity(cycle.len()); + for k in 0..cycle.len() { + out.push(cycle[(min_idx + k) % cycle.len()]); } + out +} - /// Hand-computed isoperimetric penalty `1 - Q` for a polygon over the given - /// centers in order (shoelace area, summed-edge perimeter, Q clamped to - /// [0,1]). The test's independent oracle for `loop_compactness`. - fn expected_loop_penalty(centers: &[Point]) -> f64 { - let n = centers.len(); - let mut area2 = 0.0; - let mut perim = 0.0; - for i in 0..n { - let a = centers[i]; - let b = centers[(i + 1) % n]; - area2 += a.x * b.y - b.x * a.y; - let dx = b.x - a.x; - let dy = b.y - a.y; - perim += (dx * dx + dy * dy).sqrt(); - } - let area = area2.abs() / 2.0; - let q = (4.0 * std::f64::consts::PI * area / (perim * perim)).clamp(0.0, 1.0); - 1.0 - q +/// Isoperimetric penalty `1 - Q` for one cycle's node-box centers, or `None` if +/// the cycle does not qualify (fewer than 3 distinct positioned nodes, or a +/// degenerate zero-perimeter polygon). `Q = 4*PI*Area / Perimeter^2` is clamped +/// to [0, 1]; `Area` is the shoelace area (absolute value) and `Perimeter` the +/// summed edge length over the closed polygon. +fn cycle_penalty(cycle: &[i32], centers: &BTreeMap) -> Option { + // Distinct positioned nodes only: a polygon needs >= 3 vertices. + let distinct: BTreeSet = cycle.iter().copied().collect(); + if distinct.len() < 3 { + return None; } - - #[test] - fn test_loop_compactness_circle_loop_near_zero() { - // Eight stocks placed on a circle of radius 300, wired into a directed - // 8-cycle by links 1->2->...->8->1. A well-spread loop reads as a clean - // circle, so its isoperimetric quotient Q is close to 1 and the penalty - // (1 - Q) is small. - let n: i32 = 8; - let radius = 300.0; - let mut elements: Vec = Vec::new(); - let mut centers: Vec = Vec::new(); - for i in 0..n { - let theta = 2.0 * std::f64::consts::PI * f64::from(i) / f64::from(n); - let x = radius * theta.cos(); - let y = radius * theta.sin(); - let e = stock(i + 1, "n", x, y); - centers.push(shape_center(&e)); - elements.push(e); - } - for i in 0..n { - let from = i + 1; - let to = (i + 1) % n + 1; - elements.push(straight_link(100 + i, from, to)); - } - let view = make_view(elements); - let m = compute_layout_metrics(&view, &cfg()); - - let expected = expected_loop_penalty(¢ers); - assert!( - (m.loop_compactness - expected).abs() < 1e-9, - "loop_compactness {} != hand-computed penalty {}", - m.loop_compactness, - expected - ); - // A regular octagon's penalty is ~0.05 -- "near 0" (a clean circle). - assert!( - m.loop_compactness < 0.1, - "a well-spread circular loop should score near 0, got {}", - m.loop_compactness - ); + let pts: Vec = cycle + .iter() + .filter_map(|uid| centers.get(uid).copied()) + .collect(); + if pts.len() < 3 { + return None; } - #[test] - fn test_loop_compactness_collapsed_loop_higher() { - // The SAME directed 8-cycle, but the nodes are squished onto a nearly - // straight line (a collapsed/collinear loop). The polygon area shrinks - // toward zero while the perimeter stays large, so Q -> 0 and the penalty - // (1 - Q) -> 1: clearly higher than the circular placement. - let n: i32 = 8; - let mut elements: Vec = Vec::new(); - let mut centers: Vec = Vec::new(); - for i in 0..n { - // Spread along x, with a tiny alternating y wobble so the polygon is - // non-degenerate (nonzero perimeter) but nearly collinear. - let x = f64::from(i) * 100.0; - let y = if i % 2 == 0 { 0.0 } else { 1.0 }; - let e = stock(i + 1, "n", x, y); - centers.push(shape_center(&e)); - elements.push(e); - } - for i in 0..n { - let from = i + 1; - let to = (i + 1) % n + 1; - elements.push(straight_link(100 + i, from, to)); - } - let view = make_view(elements); - let m = compute_layout_metrics(&view, &cfg()); - - let expected = expected_loop_penalty(¢ers); - assert!( - (m.loop_compactness - expected).abs() < 1e-9, - "loop_compactness {} != hand-computed penalty {}", - m.loop_compactness, - expected - ); - // A nearly-collinear loop scores near 1 (squished). - assert!( - m.loop_compactness > 0.9, - "a collapsed/collinear loop should score near 1, got {}", - m.loop_compactness - ); + let n = pts.len(); + let mut area2 = 0.0; + let mut perimeter = 0.0; + for i in 0..n { + let a = pts[i]; + let b = pts[(i + 1) % n]; + area2 += a.x * b.y - b.x * a.y; + let dx = b.x - a.x; + let dy = b.y - a.y; + perimeter += (dx * dx + dy * dy).sqrt(); } - - #[test] - fn test_loop_compactness_no_cycle_is_zero() { - // A pure chain a -> b -> c (no feedback) has no directed cycle, so there - // is nothing to score: loop_compactness == 0.0. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 200.0, 0.0), - aux(3, "c", 400.0, 0.0), - straight_link(10, 1, 2), - straight_link(11, 2, 3), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.loop_compactness, 0.0); + if perimeter <= 0.0 { + // All centers coincide: no polygon. Guarded so the division below is + // never NaN; such a degenerate cycle simply does not contribute. + return None; } + let area = area2.abs() / 2.0; + let q = (4.0 * std::f64::consts::PI * area / (perimeter * perimeter)).clamp(0.0, 1.0); + Some(1.0 - q) +} - // --- loop_straightness (loop connectors drawn as visible curves) --- - - /// A square 4-node loop wired with STRAIGHT links scores high - /// loop_straightness: the loop's causal connectors are drawn as flat chords, - /// so the loop reads as a zig-zag, not a circle. - #[test] - fn test_loop_straightness_straight_loop_is_high() { - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 300.0, 0.0), - aux(3, "c", 300.0, 300.0), - aux(4, "d", 0.0, 300.0), - straight_link(11, 1, 2), - straight_link(12, 2, 3), - straight_link(13, 3, 4), - straight_link(14, 4, 1), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.loop_straightness > 0.9, - "a loop drawn with straight chords should score near 1, got {}", - m.loop_straightness - ); +/// `loop_compactness`: mean isoperimetric penalty `1 - Q` over the view's +/// bounded simple directed cycles of >= 3 positioned nodes. 0.0 when there is no +/// qualifying cycle. Deterministic for a given view regardless of element order +/// (see the module comment above). PURE. +fn compute_loop_compactness(view: &datamodel::StockFlow) -> f64 { + let graph = build_loop_graph(view); + let cycles = enumerate_simple_cycles(&graph); + let penalties: Vec = cycles + .iter() + .filter_map(|c| cycle_penalty(c, &graph.centers)) + .collect(); + if penalties.is_empty() { + 0.0 + } else { + penalties.iter().sum::() / penalties.len() as f64 } +} - /// The SAME square loop wired with ARC links that bow well outward scores - /// near zero: every loop connector is drawn as a visible curve. - #[test] - fn test_loop_straightness_curved_loop_is_low() { - // 45deg takeoff arcs bow ~0.2 (a quarter-circle), above the target bow. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 300.0, 0.0), - aux(3, "c", 300.0, 300.0), - aux(4, "d", 0.0, 300.0), - arc_link(11, 1, 2, 45.0), - arc_link(12, 2, 3, 45.0), - arc_link(13, 3, 4, 45.0), - arc_link(14, 4, 1, 45.0), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.loop_straightness < m.loop_compactness.max(0.5), - "a curved loop should score lower loop_straightness than a straight one" - ); - assert!( - m.loop_straightness < 0.5, - "a loop drawn with well-bowed arcs should score low, got {}", - m.loop_straightness - ); - } +// --- loop_straightness (are feedback-loop connectors drawn as visible curves) - +// +// loop_compactness above scores how circular a loop's NODE arrangement is, but +// not whether the connectors between those nodes are actually drawn as arcs. A +// loop can have well-spread node centers yet still read as a zig-zag if its +// causal connectors are straight chords. This term measures exactly that: the +// shortfall of each loop connector's drawn curvature below a target bow. It is +// primarily a Goodhart guard -- `apply_loop_curvature` curves loop connectors +// deterministically, so a healthy layout scores ~0; if any future change flattens +// loop connectors, this term (and the metric) rises, so the optimizer can never +// trade away the curvature that makes a loop legible. Flow pipes in a loop are +// exempt: they are orthogonal by convention, never arced. - /// A pure chain (no cycle) has no loop connector, so loop_straightness is 0. - #[test] - fn test_loop_straightness_no_loop_is_zero() { - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 200.0, 0.0), - aux(3, "c", 400.0, 0.0), - straight_link(10, 1, 2), - straight_link(11, 2, 3), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.loop_straightness, 0.0); - } +/// Target bow ratio (max perpendicular deviation / chord length) for a loop's +/// causal connectors. A quarter-circle arc -- a clearly visible loop curve -- +/// has a bow of ~0.21; 0.15 treats moderate curvature as "enough" so the term +/// only fires on connectors drawn (near-)straight. +const LOOP_LINK_TARGET_BOW: f64 = 0.15; - #[test] - fn test_loop_compactness_two_node_mutual_pair_is_zero() { - // A 2-node mutual pair (a -> b -> a) is a cycle, but two points form no - // polygon (fewer than 3 distinct nodes), so it contributes nothing. - let view = make_view(vec![ - aux(1, "a", 0.0, 0.0), - aux(2, "b", 200.0, 0.0), - straight_link(10, 1, 2), - straight_link(11, 2, 1), - ]); - let m = compute_layout_metrics(&view, &cfg()); - assert_eq!(m.loop_compactness, 0.0); +/// Maximum perpendicular deviation of a polyline from its straight chord +/// (first -> last point), divided by the chord length. 0 for a straight two-point +/// line; ~0.21 for a quarter-circle arc. Returns 0 for a degenerate (near-zero) +/// chord so the ratio is always finite. +fn polyline_bow_ratio(polyline: &[Point]) -> f64 { + if polyline.len() < 3 { + return 0.0; } - - #[test] - fn test_loop_compactness_flow_feedback_path_is_a_cycle() { - // A stock--flow--stock feedback path must enter the loop graph: stock #1 - // and stock #2 connected by flow #3 (so #1 -> #3 -> #2), plus a link - // #2 -> #1 closing the loop. The cycle is {#1, #3, #2}: three distinct - // positioned nodes -> a real polygon -> a positive penalty. - let s1 = stock(1, "a", 0.0, 0.0); - let s2 = stock(2, "b", 300.0, 0.0); - let f = flow_between(3, "f", 150.0, 200.0, 1, 2); - let link = straight_link(10, 2, 1); - let view = make_view(vec![s1, s2, f, link]); - let m = compute_layout_metrics(&view, &cfg()); - assert!( - m.loop_compactness > 0.0, - "a stock--flow--stock feedback path must form a scored loop, got {}", - m.loop_compactness - ); + let a = polyline[0]; + let b = polyline[polyline.len() - 1]; + let cx = b.x - a.x; + let cy = b.y - a.y; + let chord = (cx * cx + cy * cy).sqrt(); + if chord < 1e-9 { + return 0.0; } - - /// A stock--flow--stock loop whose flow has an extra pipe point placed far - /// from the valve, plus a closing link. The flow valve sits at `valve`; an - /// interior pipe point at `bend` (between the two attached endpoints) bends - /// the drawn pipe. `loop_compactness` must score the loop on the flow's - /// VALVE (its visual center), NOT on `flow_shape_bounds`' pipe-extent bbox - /// center, so the result must depend only on `valve` -- never on `bend`. - fn bent_flow_loop_view(valve: Point, bend: Point) -> datamodel::StockFlow { - let s1 = stock(1, "a", 0.0, 0.0); - let s2 = stock(2, "b", 300.0, 0.0); - let f = ViewElement::Flow(view_element::Flow { - name: "f".to_string(), - uid: 3, - x: valve.x, - y: valve.y, - label_side: LabelSide::Bottom, - points: vec![ - view_element::FlowPoint { - x: 0.0, - y: 0.0, - attached_to_uid: Some(1), - }, - // An interior pipe point that bends the drawn pipe and stretches - // `flow_shape_bounds`' bbox, but is NOT the valve. - view_element::FlowPoint { - x: bend.x, - y: bend.y, - attached_to_uid: None, - }, - view_element::FlowPoint { - x: 300.0, - y: 0.0, - attached_to_uid: Some(2), - }, - ], - compat: None, - label_compat: None, - }); - let link = straight_link(10, 2, 1); - make_view(vec![s1, s2, f, link]) + let mut max_perp = 0.0_f64; + for p in &polyline[1..polyline.len() - 1] { + // Perpendicular distance from p to the infinite line through a,b. + let perp = (cx * (a.y - p.y) - cy * (a.x - p.x)).abs() / chord; + max_perp = max_perp.max(perp); } + max_perp / chord +} - #[test] - fn test_loop_compactness_scored_on_flow_valve_not_pipe_extent() { - // The loop vertex for a flow must be its VALVE (the renderer's visual - // center), not the center of `flow_shape_bounds` (which unions the valve - // box with every pipe point). Extending the pipe with a far interior - // point moves the pipe-extent bbox center but leaves the valve fixed, so - // `loop_compactness` -- which scores the feedback-loop polygon -- must be - // UNCHANGED. On the buggy (shape-box-midpoint) implementation it changes. - let valve = Point { x: 150.0, y: 200.0 }; - - // A pipe bend near the valve vs. one stretched far away. The valve is - // identical in both, so the loop polygon (stock--valve--stock) is too. - let near = compute_layout_metrics( - &bent_flow_loop_view(valve, Point { x: 150.0, y: 210.0 }), - &cfg(), - ); - let far = compute_layout_metrics( - &bent_flow_loop_view( - valve, - Point { - x: 150.0, - y: 2000.0, - }, - ), - &cfg(), - ); - - assert!( - near.loop_compactness > 0.0, - "fixture must form a real (positive-penalty) loop, got {}", - near.loop_compactness - ); - assert!( - (near.loop_compactness - far.loop_compactness).abs() < 1e-12, - "loop_compactness must score the flow VALVE, not the pipe-extent bbox \ - center: stretching the pipe changed it from {} to {}", - near.loop_compactness, - far.loop_compactness - ); - - // Non-vacuous guard: MOVING the valve (with the same pipe bend) DOES - // change the loop polygon, so the metric is not trivially constant. - let moved_valve = compute_layout_metrics( - &bent_flow_loop_view(Point { x: 150.0, y: 400.0 }, Point { x: 150.0, y: 210.0 }), - &cfg(), - ); - assert!( - (near.loop_compactness - moved_valve.loop_compactness).abs() > 1e-9, - "moving the valve must change loop_compactness (test is not trivially \ - constant): {} vs {}", - near.loop_compactness, - moved_valve.loop_compactness - ); +/// Map each directed causal connector (Link) `from_uid -> to_uid` to the polyline +/// the renderer draws for it, so loop-straightness can look up the drawn +/// curvature of a loop edge. Flows are not included (loop edges through a flow +/// valve have no Link and are correctly skipped). +fn link_polylines( + view: &datamodel::StockFlow, +) -> std::collections::HashMap<(i32, i32), Vec> { + let mut uid_elements: std::collections::HashMap = + std::collections::HashMap::new(); + for elem in &view.elements { + uid_elements.insert(elem.get_uid(), elem); } - - #[test] - fn test_loop_compactness_deterministic_under_shuffle() { - // loop_compactness is a mean over cycles, each computed from node-box - // centers in cycle order. It must be invariant to the order elements - // appear in the view's element list. - let n: i32 = 6; - let radius = 250.0; - let mut elements: Vec = Vec::new(); - for i in 0..n { - let theta = 2.0 * std::f64::consts::PI * f64::from(i) / f64::from(n); - elements.push(stock( - i + 1, - "n", - radius * theta.cos(), - radius * theta.sin(), - )); - } - for i in 0..n { - let from = i + 1; - let to = (i + 1) % n + 1; - elements.push(straight_link(100 + i, from, to)); + let not_arrayed = |_: &str| false; + let mut out: std::collections::HashMap<(i32, i32), Vec> = + std::collections::HashMap::new(); + for elem in &view.elements { + if let ViewElement::Link(link) = elem + && let (Some(&from), Some(&to)) = ( + uid_elements.get(&link.from_uid), + uid_elements.get(&link.to_uid), + ) + { + let polyline = connector_polyline(link, from, to, ¬_arrayed, ARC_POLYLINE_SAMPLES); + if polyline.len() >= 2 { + out.insert((link.from_uid, link.to_uid), polyline); + } } - let base = compute_layout_metrics(&make_view(elements.clone()), &cfg()); - - // Reverse the element order (links before nodes, nodes reversed); the - // graph and its cycles are unchanged. - let mut shuffled = elements.clone(); - shuffled.reverse(); - let other = compute_layout_metrics(&make_view(shuffled), &cfg()); - - assert!( - (base.loop_compactness - other.loop_compactness).abs() < 1e-12, - "loop_compactness changed under element shuffle: {} vs {}", - base.loop_compactness, - other.loop_compactness - ); - assert!(base.loop_compactness > 0.0); } + out +} - // --- AC5.2: human-vs-auto reference-pair ordering under the committed weights --- - // - // The committed `MetricWeights::default()` must agree with the user's visual - // taste: on the agreed reference pairs the SHIPPED, hand-authored ("human") - // layout must score a lower `weighted_cost` than a machine-generated - // ("auto") layout of the SAME model. This is the objective validation of the - // calibration (Phase 4, AC5.2): if the metric and the weights did not agree - // with human taste on an obvious pair, the metric or the pair would be wrong. - // - // Construction (b) -- "human view vs generated layout" (design glossary): the - // four `default_projects` models each ship a hand-authored main view. We - // score that as-loaded view (human) and a fixed-seed `generate_layout_with_config` - // layout (auto) of the same model, and assert `human < auto`. - // - // Determinism + budget: layout is deterministic per seed (fix #633), so ONE - // fixed seed (not `generate_best_layout`'s multi-seed search) makes the test - // reproducible AND fast. The four default_projects are small (<= 42 - // elements), so a single layout generation each is well under the per-test - // budget. - // - // Anchors: reliability, fishbanks, population, dp(logistic-growth). These all - // flip the right way under the committed weights (verified during - // calibration). `sir` is deliberately NOT a human datamodel::Project { - let path = format!( - "{}/../../default_projects/{}/model.xmile", - env!("CARGO_MANIFEST_DIR"), - dir - ); - let file = - std::fs::File::open(&path).unwrap_or_else(|e| panic!("failed to open {path}: {e}")); - let mut reader = std::io::BufReader::new(file); - crate::compat::open_xmile(&mut reader) - .unwrap_or_else(|e| panic!("failed to parse {path}: {e:?}")) +/// `loop_straightness`: mean bow shortfall over the causal connectors that +/// participate in a feedback loop. 0.0 = every loop connector is drawn with at +/// least the target curvature (the loop reads as a visible circle); 1.0 = loop +/// connectors are straight (the loop collapses to a zig-zag). 0.0 when the view +/// has no loop with a causal connector. Deterministic and PURE; reuses the same +/// loop graph / cycle enumeration as loop_compactness. +fn compute_loop_straightness(view: &datamodel::StockFlow) -> f64 { + let graph = build_loop_graph(view); + let cycles = enumerate_simple_cycles(&graph); + if cycles.is_empty() { + return 0.0; } - - /// The model's as-loaded, hand-authored main `StockFlow` view (the "human" - /// reference). Panics if the model has no such view -- every chosen anchor - /// ships one, so its absence is a fixture regression. - fn human_view(project: &datamodel::Project) -> datamodel::StockFlow { - let model = project - .get_model("main") - .expect("anchor model must have a 'main' model"); - match model.views.first() { - Some(datamodel::View::StockFlow(sf)) if !sf.elements.is_empty() => sf.clone(), - _ => panic!("anchor model must ship a non-empty hand-authored main view"), + let polys = link_polylines(view); + let mut seen: HashSet<(i32, i32)> = HashSet::new(); + let mut total = 0.0; + let mut count = 0usize; + for cycle in &cycles { + let n = cycle.len(); + for k in 0..n { + let edge = (cycle[k], cycle[(k + 1) % n]); + let Some(poly) = polys.get(&edge) else { + continue; // a flow-pipe edge (no Link): exempt + }; + if !seen.insert(edge) { + continue; // count each loop connector once + } + let bow = polyline_bow_ratio(poly); + let shortfall = (LOOP_LINK_TARGET_BOW - bow).max(0.0) / LOOP_LINK_TARGET_BOW; + total += shortfall; + count += 1; } } - - /// `weighted_cost` of the shipped human layout under the committed default - /// weights. - fn human_cost(project: &datamodel::Project) -> f64 { - let view = human_view(project); - compute_layout_metrics(&view, &LayoutConfig::default()) - .weighted_cost(&MetricWeights::default()) - } - - /// `weighted_cost` of a single fixed-seed generated layout under the committed - /// default weights. Deterministic per seed, so the score is reproducible. - fn auto_cost(project: &datamodel::Project) -> f64 { - let cfg = LayoutConfig { - annealing_random_seed: REF_PAIR_SEED, - ..LayoutConfig::default() - }; - let view = crate::layout::generate_layout_with_config(project, "main", cfg.clone(), None) - .expect("auto layout generation must succeed for the anchor model"); - compute_layout_metrics(&view, &cfg).weighted_cost(&MetricWeights::default()) - } - - /// Assert the human reference beats the auto layout for one anchor model, - /// naming the model and both costs on failure (so a calibration regression is - /// immediately legible). - fn assert_human_beats_auto(dir: &str) { - let project = load_default_project(dir); - let human = human_cost(&project); - let auto = auto_cost(&project); - assert!( - human < auto, - "reference pair {dir}: expected human_cost ({human}) < auto_cost ({auto}) \ - under MetricWeights::default()" - ); - } - - #[test] - fn test_reference_pair_reliability_human_beats_auto() { - assert_human_beats_auto("reliability"); - } - - #[test] - fn test_reference_pair_fishbanks_human_beats_auto() { - assert_human_beats_auto("fishbanks"); - } - - // Population is a MARGINAL taste anchor: under the committed default weights - // its human cost (~0.0521) beats auto (~0.0533) by only ~2.3%, far thinner - // than the other anchors (reliability ~8.5%, fishbanks ~12%, - // logistic-growth ~58%). The layout is deterministic per seed, so the - // assertion is not flaky -- but if it ever fails it should be read as - // "population sits near the boundary" rather than necessarily a real metric - // regression. The robust signal lives in reliability/fishbanks/logistic-growth. - #[test] - fn test_reference_pair_population_human_beats_auto() { - assert_human_beats_auto("population"); - } - - #[test] - fn test_reference_pair_dp_logistic_growth_human_beats_auto() { - assert_human_beats_auto("logistic-growth"); - } - - #[test] - fn test_sir_auto_beats_reference_under_default_weights() { - // The documented NON-anchor: SIR's shipped reference obscures more labels - // than the auto layout, so the metric correctly prefers the auto. This - // pins that direction so the asymmetry (why SIR is excluded from the - // human ViewElement { + ViewElement::Stock(view_element::Stock { + name: name.to_string(), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +fn aux(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { + aux_side(uid, name, x, y, LabelSide::Bottom) +} + +fn aux_side(uid: i32, name: &str, x: f64, y: f64, side: LabelSide) -> ViewElement { + ViewElement::Aux(view_element::Aux { + name: name.to_string(), + uid, + x, + y, + label_side: side, + compat: None, + }) +} + +/// A cloud at `(x, y)`: a 27x27 shape box and NO label, the cleanest +/// "obscuring shape" fixture for label terms. +fn cloud(uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Cloud(view_element::Cloud { + uid, + flow_uid: -1, + x, + y, + compat: None, + }) +} + +fn straight_link(uid: i32, from_uid: i32, to_uid: i32) -> ViewElement { + ViewElement::Link(view_element::Link { + uid, + from_uid, + to_uid, + shape: LinkShape::Straight, + polarity: None, + }) +} + +fn arc_link(uid: i32, from_uid: i32, to_uid: i32, angle: f64) -> ViewElement { + ViewElement::Link(view_element::Link { + uid, + from_uid, + to_uid, + shape: LinkShape::Arc(angle), + polarity: None, + }) +} + +/// A flow valve at `(x, y)` with a two-point polyline through the valve whose +/// endpoints attach to `from_uid` and `to_uid`. +fn flow_between(uid: i32, name: &str, x: f64, y: f64, from_uid: i32, to_uid: i32) -> ViewElement { + flow_with_points( + uid, + name, + (x, y), + vec![(x, y, Some(from_uid)), (x, y, Some(to_uid))], + ) +} + +fn flow_with_points( + uid: i32, + name: &str, + valve: (f64, f64), + points: Vec<(f64, f64, Option)>, +) -> ViewElement { + ViewElement::Flow(view_element::Flow { + name: name.to_string(), + uid, + x: valve.0, + y: valve.1, + label_side: LabelSide::Bottom, + points: points + .into_iter() + .map(|(x, y, attached_to_uid)| view_element::FlowPoint { + x, + y, + attached_to_uid, + }) + .collect(), + compat: None, + label_compat: None, + }) +} + +fn make_view(elements: Vec) -> datamodel::StockFlow { + datamodel::StockFlow { + name: None, + elements, + view_box: datamodel::Rect { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }, + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + } +} + +fn cfg() -> LayoutConfig { + LayoutConfig::default() +} + +/// An alias (ghost) of the element with uid `alias_of_uid`, at `(x, y)`. +fn alias_of(uid: i32, alias_of_uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Alias(view_element::Alias { + uid, + alias_of_uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +/// The label box an element's own name occupies, measured exactly as the +/// metric measures it. +fn label_box(e: &ViewElement) -> Rect { + let side = element_label_side(e).expect("labeled element"); + label_bounds(&element_label_props_for(e, side).expect("labeled element")) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +// --- the drawn scene --- + +#[test] +fn test_flow_valve_is_scored_at_its_drawn_radius() { + // `render_flow` draws the valve circle at AUX_RADIUS (9px). A stock whose + // edge sits 7px from the valve center overlaps the drawn circle; scoring the + // valve at the 6px bounds radius would miss it. + let valve_x = 100.0; + let stock_center_x = valve_x + 7.0 + STOCK_WIDTH / 2.0; + let view = make_view(vec![ + flow_with_points( + 1, + "f", + (valve_x, 100.0), + vec![(40.0, 100.0, None), (valve_x - 20.0, 100.0, None)], + ), + stock(2, "s", stock_center_x, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + m.node_overlap > 0.0, + "a stock 7px from the valve center covers the drawn 9px valve circle" + ); + let shape = node_shape_box(&view.elements[0]).unwrap(); + assert!(close(common::rect_width(&shape), 2.0 * AUX_RADIUS)); +} + +#[test] +fn test_pipe_through_a_label_obscures_it_but_own_pipe_does_not() { + // A horizontal pipe (flow #1, valve far to the right) runs straight through + // aux #2's Bottom label. That pipe obscures the label; the flow's own label + // is never charged against its own pipe. + let a = aux(2, "a fairly long name", 200.0, 100.0); + let lbl = label_box(&a); + let pipe_y = (lbl.top + lbl.bottom) / 2.0; + let view = make_view(vec![ + flow_with_points( + 1, + "f", + (600.0, pipe_y), + vec![(0.0, pipe_y, None), (700.0, pipe_y, None)], + ), + a, + ]); + let m = compute_layout_metrics(&view, &cfg()); + let covered = (2.0 * PIPE_HALF_WIDTH) / common::rect_height(&lbl); + // Two labels in the view: the aux's (covered by the pipe band) and the + // flow's own (clear of anything). + assert!( + close(m.label_overlap, covered / 2.0), + "label_overlap {} expected {}", + m.label_overlap, + covered / 2.0 + ); +} + +// --- alias scoring --- + +#[test] +fn test_alias_node_overlap_charged() { + // An alias stacked exactly on an aux vs the same alias far away: the + // stacked layout must score strictly worse on node_overlap. + let stacked = make_view(vec![ + aux(1, "source variable", 100.0, 100.0), + aux(2, "another aux", 300.0, 100.0), + alias_of(3, 1, 300.0, 100.0), + ]); + let apart = make_view(vec![ + aux(1, "source variable", 100.0, 100.0), + aux(2, "another aux", 300.0, 100.0), + alias_of(3, 1, 600.0, 100.0), + ]); + let m_stacked = compute_layout_metrics(&stacked, &cfg()); + let m_apart = compute_layout_metrics(&apart, &cfg()); + assert!(m_stacked.node_overlap > m_apart.node_overlap); + assert!(m_apart.node_overlap.abs() < 1e-9); +} + +#[test] +fn test_alias_label_sized_by_source_name() { + // The alias's label box is the SOURCE element's name: a long source name + // collides with a nearby aux's label where a short one does not. + let dx = 80.0; + let long_name = make_view(vec![ + aux(1, "an extremely long variable name here", 100.0, 600.0), + aux(2, "consumer", 300.0, 100.0), + alias_of(3, 1, 300.0 + dx, 100.0), + ]); + let short_name = make_view(vec![ + aux(1, "x", 100.0, 600.0), + aux(2, "consumer", 300.0, 100.0), + alias_of(3, 1, 300.0 + dx, 100.0), + ]); + let m_long = compute_layout_metrics(&long_name, &cfg()); + let m_short = compute_layout_metrics(&short_name, &cfg()); + assert!(m_long.label_overlap > m_short.label_overlap); +} + +#[test] +fn test_alias_with_dangling_source_is_ignored() { + // An alias whose source resolves to nothing still draws its circle (so it + // can overlap) but has no derivable label; nothing panics. + let view = make_view(vec![ + aux(1, "real aux", 100.0, 100.0), + alias_of(2, 999, 100.0, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(m.node_overlap > 0.0); + assert!(m.label_overlap.is_finite()); +} + +#[test] +fn test_alias_extends_view_bounding_box() { + // A far-flung alias is a drawn element: it widens the bounding box. + let compact = make_view(vec![ + aux(1, "a", 100.0, 100.0), + aux(2, "b", 300.0, 100.0), + straight_link(10, 1, 2), + ]); + let with_far_alias = make_view(vec![ + aux(1, "a", 100.0, 100.0), + aux(2, "b", 300.0, 100.0), + straight_link(10, 1, 2), + alias_of(3, 1, 2000.0, 100.0), + ]); + let m_compact = compute_layout_metrics(&compact, &cfg()); + let m_far = compute_layout_metrics(&with_far_alias, &cfg()); + assert!(m_far.aspect_penalty > m_compact.aspect_penalty); +} + +/// Scale every coordinate of a view by `s` (element centers and flow points). +fn scale_view(view: &datamodel::StockFlow, s: f64) -> datamodel::StockFlow { + let elements = view + .elements + .iter() + .map(|e| match e { + ViewElement::Aux(a) => ViewElement::Aux(view_element::Aux { + x: a.x * s, + y: a.y * s, + ..a.clone() + }), + ViewElement::Stock(st) => ViewElement::Stock(view_element::Stock { + x: st.x * s, + y: st.y * s, + ..st.clone() + }), + ViewElement::Flow(f) => ViewElement::Flow(view_element::Flow { + x: f.x * s, + y: f.y * s, + points: f + .points + .iter() + .map(|p| view_element::FlowPoint { + x: p.x * s, + y: p.y * s, + attached_to_uid: p.attached_to_uid, + }) + .collect(), + ..f.clone() + }), + ViewElement::Module(m) => ViewElement::Module(view_element::Module { + x: m.x * s, + y: m.y * s, + ..m.clone() + }), + ViewElement::Cloud(c) => ViewElement::Cloud(view_element::Cloud { + x: c.x * s, + y: c.y * s, + ..c.clone() + }), + ViewElement::Alias(a) => ViewElement::Alias(view_element::Alias { + x: a.x * s, + y: a.y * s, + ..a.clone() + }), + other => other.clone(), + }) + .collect(); + datamodel::StockFlow { + elements, + ..view.clone() + } +} + +// --- node_overlap: mean covered fraction of each node's shape --- + +#[test] +fn test_node_overlap_known_overlap_fraction() { + // Two stocks whose centers are 20px apart: each shape is covered over a + // 25x35 band of its 45x35 area, so both nodes are 25/45 covered and the + // mean over the two nodes is 25/45. + let view = make_view(vec![ + stock(1, "a", 100.0, 100.0), + stock(2, "b", 120.0, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let expected = 25.0 / 45.0; + assert!( + close(m.node_overlap, expected), + "node_overlap {} != {expected}", + m.node_overlap + ); +} + +#[test] +fn test_node_overlap_is_a_rate_over_nodes() { + // The same stacked pair plus eight far-away stocks: the two covered nodes + // are now two of ten, so the rate drops to a fifth of the pair's. + let pair = make_view(vec![ + stock(1, "a", 100.0, 100.0), + stock(2, "b", 120.0, 100.0), + ]); + let mut elements = vec![stock(1, "a", 100.0, 100.0), stock(2, "b", 120.0, 100.0)]; + for k in 0..8 { + elements.push(stock(10 + k, "far", 1000.0 + f64::from(k) * 200.0, 1000.0)); + } + let diluted = make_view(elements); + let m_pair = compute_layout_metrics(&pair, &cfg()); + let m_diluted = compute_layout_metrics(&diluted, &cfg()); + assert!(close(m_diluted.node_overlap, m_pair.node_overlap / 5.0)); +} + +#[test] +fn test_node_overlap_touching_shapes_is_zero() { + let view = make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", STOCK_WIDTH, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_overlap, 0.0); +} + +#[test] +fn test_node_overlap_disjoint_is_zero() { + let view = make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", 500.0, 500.0), + aux(3, "c", 1000.0, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_overlap, 0.0); +} + +#[test] +fn test_node_overlap_labels_overlap_shapes_disjoint_is_zero() { + // Two Bottom-labeled auxes 40px apart: shapes disjoint, labels overlapping. + // node_overlap ignores labels; label_overlap charges them. + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_overlap, 0.0); + assert!(m.label_overlap > 0.0); +} + +// --- node_connector_overlap --- + +#[test] +fn test_node_connector_overlap_through_third_node() { + // A link between two far-apart auxes passes horizontally through a stock. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + stock(3, "s", 200.0, 0.0), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let connectors = collect_connector_geometry(&view); + assert_eq!(connectors.len(), 1); + let c = &connectors[0]; + let stock_box = node_shape_box(&stock(3, "s", 200.0, 0.0)).unwrap(); + let inside: f64 = c + .polyline + .windows(2) + .map(|seg| segment_length_in_rect(&seg[0], &seg[1], &stock_box)) + .sum(); + assert!(close(m.node_connector_overlap, inside / c.length)); +} + +#[test] +fn test_node_connector_overlap_avoids_all_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + stock(3, "s", 200.0, 500.0), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_connector_overlap, 0.0); +} + +#[test] +fn test_node_connector_overlap_under_label_only_is_zero() { + // The link at y=0 passes under stock #3's label (which hangs below its + // shape) but never under the shape itself: not charged here (the label + // term charges it). + let label_only = stock(3, "s", 200.0, -25.0); + let shape = node_shape_box(&label_only).unwrap(); + let lbl = label_box(&label_only); + assert!(shape.bottom < 0.0, "fixture: the shape clears the line"); + assert!( + lbl.bottom > 0.0 && lbl.top < 0.0, + "fixture: the label spans the line" + ); + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + label_only, + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.node_connector_overlap, 0.0); + assert!(m.label_connector_overlap > 0.0); +} + +#[test] +fn test_link_along_a_foreign_pipe_is_charged() { + // A link runs along a pipe it has nothing to do with: it reads as part of + // the flow. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + flow_with_points( + 3, + "f", + (200.0, 300.0), + vec![(100.0, 0.0, None), (300.0, 0.0, None)], + ), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(m.node_connector_overlap > 0.0); +} + +/// Length of segment p0->p1 covered by the UNION of `rects`, for horizontal +/// segments only: the independent oracle for the union tests. +fn union_segment_length_in_rects(p0: &Point, p1: &Point, rects: &[Rect]) -> f64 { + let seg_len = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt(); + if seg_len == 0.0 { + return 0.0; + } + let mut intervals: Vec<(f64, f64)> = Vec::new(); + for r in rects { + if segment_length_in_rect(p0, p1, r) <= 0.0 { + continue; + } + let (xa, xb) = (p0.x.min(p1.x), p0.x.max(p1.x)); + let span = p1.x - p0.x; + let t_lo = ((xa.max(r.left) - p0.x) / span).clamp(0.0, 1.0); + let t_hi = ((xb.min(r.right) - p0.x) / span).clamp(0.0, 1.0); + intervals.push((t_lo.min(t_hi), t_lo.max(t_hi))); + } + merged_interval_length(&mut intervals) * seg_len +} + +#[test] +fn test_node_connector_overlap_union_of_overlapping_boxes() { + // Two overlapping non-incident stocks straddle the link: the covered length + // is their UNION, not the sum. + let s3 = stock(3, "s3", 200.0, 0.0); + let s4 = stock(4, "s4", 210.0, 0.0); + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + s3.clone(), + s4.clone(), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let connectors = collect_connector_geometry(&view); + let c = &connectors[0]; + let boxes = [node_shape_box(&s3).unwrap(), node_shape_box(&s4).unwrap()]; + let union_len: f64 = c + .polyline + .windows(2) + .map(|seg| union_segment_length_in_rects(&seg[0], &seg[1], &boxes)) + .sum(); + assert!(close(m.node_connector_overlap, union_len / c.length)); + assert!(m.node_connector_overlap <= 1.0); +} + +#[test] +fn test_node_connector_overlap_coincident_boxes_counted_once() { + // A short link fully inside two coincident stocks: the fraction is exactly + // 1.0, never the 2.0 a per-box sum would report. + let view = make_view(vec![ + aux(1, "a", 180.0, 0.0), + aux(2, "b", 220.0, 0.0), + stock(3, "s3", 200.0, 0.0), + stock(4, "s4", 200.0, 0.0), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.node_connector_overlap, 1.0), + "{}", + m.node_connector_overlap + ); +} + +// --- label_overlap: mean covered fraction of each label --- + +#[test] +fn test_label_overlap_coincident_labels_are_fully_obscured() { + let view = make_view(vec![ + aux(1, "samename", 100.0, 100.0), + aux(2, "samename", 100.0, 100.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.label_overlap, 1.0), "{}", m.label_overlap); +} + +#[test] +fn test_label_overlap_disjoint_is_zero() { + let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 1000.0, 1000.0)]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.label_overlap, 0.0); +} + +#[test] +fn test_label_overlap_hand_computed_pair() { + // Two Bottom-labeled "samename" auxes 40px apart. Each label box is 58x14 + // (8*6+10 wide), the labels overlap over 18x14, and neither label reaches + // the other aux's shape. Each label is 252/812 covered: the mean over the + // two labels is 252/812. + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.label_overlap, 252.0 / 812.0), "{}", m.label_overlap); +} + +#[test] +fn test_label_overlap_never_charged_against_own_shape() { + let view = make_view(vec![aux(1, "samename", 0.0, 0.0)]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.label_overlap, 0.0); +} + +#[test] +fn test_label_overlap_normalizes_by_label_count_not_area() { + // A cloud clips 6.5x14 of a 22x14 label (91/308 covered). Fifteen far-away + // labels dilute the rate by COUNT -- identically whether their names are + // long or short, so a big label elsewhere never hides a small collision. + let build = |filler: &str| { + let mut elements = vec![aux(1, "ab", 0.0, 0.0), cloud(2, 18.0, 20.0)]; + for k in 0..15 { + elements.push(aux(100 + k, filler, 3000.0 + f64::from(k) * 1000.0, 3000.0)); + } + make_view(elements) + }; + let expected = (91.0 / 308.0) / 16.0; + for filler in ["abcdefghijklmnopqrst", "x"] { + let m = compute_layout_metrics(&build(filler), &cfg()); + assert!( + close(m.label_overlap, expected), + "filler {filler:?}: {} expected {expected}", + m.label_overlap + ); + } +} + +// --- label_connector_overlap: lines through names --- + +#[test] +fn test_link_through_a_label_strikes_it_out() { + // A horizontal link through the middle of aux #3's single-line label: the + // run inside the (inset) text box far exceeds the box's height, so that + // label is fully struck (1.0). Three labels -> a rate of 1/3. + let target = aux(3, "a long label name", 200.0, -30.0); + let lbl = label_box(&target); + let y = (lbl.top + lbl.bottom) / 2.0; + let view = make_view(vec![ + aux(1, "a", 0.0, y), + aux(2, "b", 400.0, y), + target, + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.label_connector_overlap, 1.0 / 3.0), + "{}", + m.label_connector_overlap + ); +} + +#[test] +fn test_a_link_into_its_own_node_through_the_name_counts_at_the_own_link_factor() { + // An arrow from below into aux #1 passes vertically through #1's Bottom + // label on the way in. The run inside the inset text box is the box's + // height, so a foreign line would strike the label fully; the node's own + // link counts at OWN_LINK_STRIKE_FACTOR. Two labels -> a rate of half that. + let view = make_view(vec![ + aux(1, "a long label name", 200.0, 0.0), + aux(2, "source", 200.0, 300.0), + straight_link(10, 2, 1), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.label_connector_overlap, OWN_LINK_STRIKE_FACTOR / 2.0), + "{}", + m.label_connector_overlap + ); +} + +#[test] +fn test_link_grazing_label_padding_is_not_charged() { + // A link just inside the label box's top edge (within LABEL_INSET) passes + // through padding, not text. + let target = aux(3, "a long label name", 200.0, -30.0); + let lbl = label_box(&target); + let y = lbl.top + LABEL_INSET / 2.0; + let view = make_view(vec![ + aux(1, "a", 0.0, y), + aux(2, "b", 400.0, y), + target, + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.label_connector_overlap, 0.0); +} + +// --- crowding: clearance deficits --- + +/// Two Right-labeled auxes on one row, `gap` px between the first's label box +/// and the second's shape. +fn crowded_pair(gap: f64) -> datamodel::StockFlow { + let first = aux_side(1, "name", 0.0, 0.0, LabelSide::Right); + let lbl = label_box(&first); + let second_x = lbl.right + gap + AUX_RADIUS; + make_view(vec![ + first, + aux_side(2, "name", second_x, 0.0, LabelSide::Right), + ]) +} + +#[test] +fn test_crowding_charges_the_squared_clearance_deficit() { + let gap = 3.0; + let m = compute_layout_metrics(&crowded_pair(gap), &cfg()); + let deficit = (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + // One crowded pair over two nodes. + assert!( + close(m.crowding, deficit / 2.0), + "{} vs {}", + m.crowding, + deficit / 2.0 + ); +} + +#[test] +fn test_crowding_is_zero_beyond_the_clearance_and_monotone_within_it() { + assert_eq!( + compute_layout_metrics(&crowded_pair(COMFORTABLE_CLEARANCE + 1.0), &cfg()).crowding, + 0.0 + ); + let gaps = [0.0, 1.5, 3.0, 5.0, 7.0]; + let costs: Vec = gaps + .iter() + .map(|&g| compute_layout_metrics(&crowded_pair(g), &cfg()).crowding) + .collect(); + assert!( + costs.windows(2).all(|w| w[0] > w[1]), + "crowding must fall as the gap grows: {costs:?}" + ); +} + +#[test] +fn test_a_link_too_short_to_show_its_arrow_is_crowding() { + // Two auxes whose circles are 8px apart: the straight link between them is + // drawn 8px long, below MIN_VISIBLE_LINK, so crowding includes that link's + // deficit (one short link over one link) on top of the pair's clearance + // deficit. + let view = make_view(vec![ + aux_side(1, "a", 0.0, 0.0, LabelSide::Top), + aux_side(2, "b", 2.0 * AUX_RADIUS + 8.0, 0.0, LabelSide::Bottom), + straight_link(10, 1, 2), + ]); + let m = compute_layout_metrics(&view, &cfg()); + let connectors = collect_connector_geometry(&view); + let visible = connectors[0].length; + assert!( + visible < MIN_VISIBLE_LINK, + "fixture link must be short: {visible}" + ); + let far = make_view(vec![ + aux_side(1, "a", 0.0, 0.0, LabelSide::Top), + aux_side(2, "b", 300.0, 0.0, LabelSide::Bottom), + straight_link(10, 1, 2), + ]); + let m_far = compute_layout_metrics(&far, &cfg()); + assert_eq!(m_far.crowding, 0.0); + assert!( + m.crowding >= (1.0 - visible / MIN_VISIBLE_LINK).powi(2) - 1e-9, + "the short link's deficit must be charged: {}", + m.crowding + ); +} + +/// A stock with a flow leaving its right face, the valve `valve_offset` px +/// past the face; labels on the given sides. +fn stock_with_outflow( + valve_offset: f64, + stock_side: LabelSide, + flow_side: LabelSide, +) -> datamodel::StockFlow { + let edge = 100.0 + STOCK_WIDTH / 2.0; + let mut s = stock(1, "stock", 100.0, 100.0); + if let ViewElement::Stock(st) = &mut s { + st.label_side = stock_side; + } + let mut f = flow_with_points( + 2, + "f", + (edge + valve_offset, 100.0), + vec![(edge, 100.0, Some(1)), (edge + 300.0, 100.0, None)], + ); + if let ViewElement::Flow(fl) = &mut f { + fl.label_side = flow_side; + } + make_view(vec![s, f]) +} + +#[test] +fn test_a_flow_is_not_crowded_by_the_stock_its_pipe_attaches_to() { + // The valve circle sits 16px past the stock face (7px of clearance, less + // than COMFORTABLE_CLEARANCE): joined by construction, not jammed together. + // Labels on opposite sides stay clear of each other and of both shapes. + let m = compute_layout_metrics( + &stock_with_outflow(16.0, LabelSide::Top, LabelSide::Right), + &cfg(), + ); + assert_eq!(m.crowding, 0.0); +} + +#[test] +fn test_labels_of_an_attached_flow_and_stock_still_crowd() { + // The same pair with both labels Bottom: the flow's name runs into the + // stock's. Attachment excuses the shapes, never the labels. + let m = compute_layout_metrics( + &stock_with_outflow(12.0, LabelSide::Bottom, LabelSide::Bottom), + &cfg(), + ); + assert!(m.crowding > 0.0); +} + +// --- long_connectors --- + +#[test] +fn test_one_parameter_across_the_diagram_is_a_long_connector() { + // Five short links (drawn length 100: centers 118 apart, minus both 9px + // radii) and one drawn 1182 long. Median 100, threshold 300: the long link + // exceeds it by 1182/300 - 1. Mean over six links. + let mut elements = Vec::new(); + for k in 0..5 { + let base = f64::from(k) * 1000.0; + elements.push(aux(10 + k, "x", base, 0.0)); + elements.push(aux(20 + k, "y", base + 118.0, 0.0)); + elements.push(straight_link(30 + k, 10 + k, 20 + k)); + } + elements.push(aux(40, "far", 0.0, 1000.0)); + elements.push(aux(41, "consumer", 1200.0, 1000.0)); + elements.push(straight_link(42, 40, 41)); + let m = compute_layout_metrics(&make_view(elements), &cfg()); + let expected = (1182.0 / 300.0 - 1.0) / 6.0; + assert!( + (m.long_connectors - expected).abs() < 1e-6, + "{} expected {expected}", + m.long_connectors + ); +} + +#[test] +fn test_uniformly_scaled_links_are_not_long() { + // Every link the same length: nothing stands out, whatever the scale. + let mut elements = Vec::new(); + for k in 0..4 { + let base = f64::from(k) * 2000.0; + elements.push(aux(10 + k, "x", base, 0.0)); + elements.push(aux(20 + k, "y", base + 900.0, 0.0)); + elements.push(straight_link(30 + k, 10 + k, 20 + k)); + } + let m = compute_layout_metrics(&make_view(elements), &cfg()); + assert_eq!(m.long_connectors, 0.0); +} + +// --- misalignment --- + +#[test] +fn test_misalignment_counts_nodes_sharing_no_row_or_column() { + // Three auxes on one row are aligned; a fourth off every row and column + // within reach is not. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 100.0, 0.0), + aux(3, "c", 200.0, 0.0), + aux(4, "d", 50.0, 70.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.misalignment, 0.25), "{}", m.misalignment); +} + +// --- aspect_penalty --- + +#[test] +fn test_aspect_penalty_thin_box_positive() { + let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 0.0, 1000.0)]); + let m = compute_layout_metrics(&view, &cfg()); + assert!(m.aspect_penalty > 0.0); + let boxes: Vec = build_scene_nodes(&view) + .iter() + .map(SceneNode::footprint_box) + .collect(); + let bbox = view_bounding_box(&boxes).unwrap(); + let (w, h) = (common::rect_width(&bbox), common::rect_height(&bbox)); + let expected = (w.max(h) / w.min(h) - TARGET_AR_MAX).max(0.0); + assert!(close(m.aspect_penalty, expected)); +} + +#[test] +fn test_aspect_penalty_balanced_box_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + aux(3, "c", 0.0, 300.0), + aux(4, "d", 400.0, 300.0), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert_eq!(m.aspect_penalty, 0.0); +} + +// --- weighted_cost --- + +#[test] +fn test_weighted_cost_exact_linear_combination() { + let m = LayoutMetrics { + node_overlap: 1.5, + node_connector_overlap: 2.0, + label_overlap: 0.5, + label_connector_overlap: 0.75, + crossings: 3.0, + crowding: 0.3, + sprawl: 4.0, + long_connectors: 0.2, + edge_length_cv: 0.25, + aspect_penalty: 6.0, + misalignment: 0.4, + loop_compactness: 8.0, + flow_bends: 9.0, + loop_straightness: 11.0, + }; + let w = MetricWeights { + node_overlap: 10.0, + node_connector_overlap: 20.0, + label_overlap: 30.0, + label_connector_overlap: 35.0, + crossings: 40.0, + crowding: 45.0, + sprawl: 50.0, + long_connectors: 55.0, + edge_length_cv: 60.0, + aspect_penalty: 70.0, + misalignment: 75.0, + loop_compactness: 90.0, + flow_bends: 100.0, + loop_straightness: 110.0, + }; + let expected: f64 = m + .terms() + .iter() + .zip(w.terms().iter()) + .map(|((name_m, v), (name_w, wt))| { + assert_eq!(name_m, name_w, "terms() and weights terms() must align"); + v * wt + }) + .sum(); + assert!(close(m.weighted_cost(&w), expected)); + // And spelled out, so `terms()` itself is checked against the fields. + let spelled = 1.5 * 10.0 + + 2.0 * 20.0 + + 0.5 * 30.0 + + 0.75 * 35.0 + + 3.0 * 40.0 + + 0.3 * 45.0 + + 4.0 * 50.0 + + 0.2 * 55.0 + + 0.25 * 60.0 + + 6.0 * 70.0 + + 0.4 * 75.0 + + 8.0 * 90.0 + + 9.0 * 100.0 + + 11.0 * 110.0; + assert!(close(m.weighted_cost(&w), spelled)); +} + +#[test] +fn test_default_weights_encode_illegibility_dominance() { + let w = MetricWeights::default(); + // Information-destroying defects outweigh crossings and spacing. + for illegible in [w.node_overlap, w.label_overlap] { + assert!(illegible > w.crossings); + assert!(illegible > w.crowding); + } + // Spacing has a finite optimum: both directions carry weight, gently. + assert!(w.crowding > 0.0 && w.sprawl > 0.0); + assert!(w.sprawl < w.crowding); + // Diagnostics carry none. + assert_eq!(w.edge_length_cv, 0.0); + assert_eq!(w.aspect_penalty, 0.0); + // Conventions are nudges below every defect weight. + for convention in [ + w.loop_compactness, + w.flow_bends, + w.loop_straightness, + w.misalignment, + ] { + assert!(convention > 0.0 && convention < w.crossings); + } +} + +// --- degenerate views --- + +fn assert_all_finite(m: &LayoutMetrics) { + for (name, v) in m.terms() { + assert!(v.is_finite(), "{name} is not finite: {v}"); + } +} + +#[test] +fn test_empty_view_all_zero_finite() { + let m = compute_layout_metrics(&make_view(vec![]), &cfg()); + assert_all_finite(&m); + for (name, v) in m.terms() { + assert_eq!(v, 0.0, "{name}"); + } +} + +#[test] +fn test_single_element_view_all_zero_finite() { + let m = compute_layout_metrics(&make_view(vec![aux(1, "only", 100.0, 100.0)]), &cfg()); + assert_all_finite(&m); + for (name, v) in m.terms() { + assert_eq!(v, 0.0, "{name}"); + } +} + +// --- scale behavior --- + +#[test] +fn test_scale_invariance_of_scale_free_terms() { + // Crossings interior to both connectors are exactly scale-invariant; the + // fraction of connector length under a fixed-size shape DROPS as the view + // is scaled up (the shape does not scale, the connector does). + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 400.0, 0.0), + stock(3, "s", 200.0, 0.0), + aux(4, "c", 0.0, 300.0), + stock(5, "t", 400.0, 320.0), + straight_link(10, 1, 2), + straight_link(11, 4, 5), + ]); + let base = compute_layout_metrics(&view, &cfg()); + assert_eq!(base.node_overlap, 0.0); + assert!(base.node_connector_overlap > 0.0); + let scaled = compute_layout_metrics(&scale_view(&view, 3.0), &cfg()); + assert!(close(scaled.crossings, base.crossings)); + assert!(scaled.node_connector_overlap < base.node_connector_overlap); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Every term is a function of the view's geometry, not the order its + /// elements are listed in. + #[test] + fn prop_metrics_shuffle_invariant( + xs in prop::collection::vec(-80.0f64..80.0, 4), + ys in prop::collection::vec(-80.0f64..80.0, 4), + perm in prop::sample::subsequence(vec![0usize, 1, 2, 3], 4), + ) { + let elems: Vec = (0..4) + .map(|i| if i % 2 == 0 { + stock(i as i32 + 1, "n", xs[i], ys[i]) + } else { + aux(i as i32 + 1, "an aux", xs[i], ys[i]) + }) + .collect(); + let base = compute_layout_metrics(&make_view(elems.clone()), &cfg()); + let shuffled: Vec = perm.iter().map(|&i| elems[i].clone()).collect(); + let other = compute_layout_metrics(&make_view(shuffled), &cfg()); + for ((name, a), (_, b)) in base.terms().iter().zip(other.terms().iter()) { + prop_assert!((a - b).abs() < 1e-9, "{} changed under shuffle: {} vs {}", name, a, b); + } + } +} + +// --- analyze_layout: defects agree with the score --- + +#[test] +fn test_analyze_layout_metrics_equal_compute_layout_metrics() { + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + aux(3, "c", 0.0, 300.0), + stock(4, "t", 400.0, 320.0), + straight_link(10, 1, 4), + straight_link(11, 3, 2), + ]); + let analysis = analyze_layout(&view); + assert_eq!(analysis.metrics, compute_layout_metrics(&view, &cfg())); +} + +#[test] +fn test_analyze_layout_locates_a_crossing_at_the_intersection() { + // Two links crossing at the center of the square their endpoints form. + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 200.0), + aux(3, "c", 200.0, 0.0), + aux(4, "d", 0.0, 200.0), + straight_link(10, 1, 2), + straight_link(11, 3, 4), + ]); + let analysis = analyze_layout(&view); + let crossings: Vec<&Defect> = analysis + .defects + .iter() + .filter(|d| d.kind == DefectKind::Crossing) + .collect(); + assert_eq!(crossings.len(), 1); + let [l, t, r, b] = crossings[0].region; + assert!((l - 100.0).abs() < 1e-6 && (t - 100.0).abs() < 1e-6); + assert!(close(l, r) && close(t, b), "a crossing is a point"); + assert!( + close(analysis.metrics.crossings, 0.5), + "one crossing over two connectors" + ); +} + +#[test] +fn test_analyze_layout_reports_each_obscured_label_with_its_fraction() { + let view = make_view(vec![ + aux(1, "samename", 0.0, 0.0), + aux(2, "samename", 40.0, 0.0), + ]); + let analysis = analyze_layout(&view); + let obscured: Vec<&Defect> = analysis + .defects + .iter() + .filter(|d| d.kind == DefectKind::LabelObscured) + .collect(); + assert_eq!(obscured.len(), 2); + for d in obscured { + assert!(close(d.severity, 252.0 / 812.0)); + } +} + +// --- loop_compactness (isoperimetric loop quality) --- + +fn shape_center(e: &ViewElement) -> Point { + let r = node_shape_box(e).unwrap(); + Point { + x: (r.left + r.right) / 2.0, + y: (r.top + r.bottom) / 2.0, + } +} + +/// Hand-computed isoperimetric penalty `1 - Q` for a polygon over `centers`. +fn expected_loop_penalty(centers: &[Point]) -> f64 { + let n = centers.len(); + let mut area2 = 0.0; + let mut perim = 0.0; + for i in 0..n { + let a = centers[i]; + let b = centers[(i + 1) % n]; + area2 += a.x * b.y - b.x * a.y; + perim += ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt(); + } + let area = area2.abs() / 2.0; + 1.0 - (4.0 * std::f64::consts::PI * area / (perim * perim)).clamp(0.0, 1.0) +} + +fn cycle_view(positions: &[(f64, f64)]) -> (datamodel::StockFlow, Vec) { + let n = positions.len() as i32; + let mut elements: Vec = Vec::new(); + let mut centers = Vec::new(); + for (i, &(x, y)) in positions.iter().enumerate() { + let e = stock(i as i32 + 1, "n", x, y); + centers.push(shape_center(&e)); + elements.push(e); + } + for i in 0..n { + elements.push(straight_link(100 + i, i + 1, (i + 1) % n + 1)); + } + (make_view(elements), centers) +} + +#[test] +fn test_loop_compactness_circle_loop_near_zero() { + let positions: Vec<(f64, f64)> = (0..8) + .map(|i| { + let theta = 2.0 * std::f64::consts::PI * f64::from(i) / 8.0; + (300.0 * theta.cos(), 300.0 * theta.sin()) + }) + .collect(); + let (view, centers) = cycle_view(&positions); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.loop_compactness, expected_loop_penalty(¢ers))); + assert!(m.loop_compactness < 0.1); +} + +#[test] +fn test_loop_compactness_collapsed_loop_higher() { + let positions: Vec<(f64, f64)> = (0..8) + .map(|i| (f64::from(i) * 100.0, if i % 2 == 0 { 0.0 } else { 1.0 })) + .collect(); + let (view, centers) = cycle_view(&positions); + let m = compute_layout_metrics(&view, &cfg()); + assert!(close(m.loop_compactness, expected_loop_penalty(¢ers))); + assert!(m.loop_compactness > 0.9); +} + +#[test] +fn test_loop_compactness_no_cycle_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 0.0), + aux(3, "c", 400.0, 0.0), + straight_link(10, 1, 2), + straight_link(11, 2, 3), + ]); + assert_eq!(compute_layout_metrics(&view, &cfg()).loop_compactness, 0.0); +} + +#[test] +fn test_loop_straightness_straight_loop_is_high() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 300.0, 0.0), + aux(3, "c", 300.0, 300.0), + aux(4, "d", 0.0, 300.0), + straight_link(11, 1, 2), + straight_link(12, 2, 3), + straight_link(13, 3, 4), + straight_link(14, 4, 1), + ]); + assert!(compute_layout_metrics(&view, &cfg()).loop_straightness > 0.9); +} + +#[test] +fn test_loop_straightness_curved_loop_is_low() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 300.0, 0.0), + aux(3, "c", 300.0, 300.0), + aux(4, "d", 0.0, 300.0), + arc_link(11, 1, 2, 45.0), + arc_link(12, 2, 3, 45.0), + arc_link(13, 3, 4, 45.0), + arc_link(14, 4, 1, 45.0), + ]); + assert!(compute_layout_metrics(&view, &cfg()).loop_straightness < 0.5); +} + +#[test] +fn test_loop_straightness_no_loop_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 0.0), + aux(3, "c", 400.0, 0.0), + straight_link(10, 1, 2), + straight_link(11, 2, 3), + ]); + assert_eq!(compute_layout_metrics(&view, &cfg()).loop_straightness, 0.0); +} + +#[test] +fn test_loop_compactness_two_node_mutual_pair_is_zero() { + let view = make_view(vec![ + aux(1, "a", 0.0, 0.0), + aux(2, "b", 200.0, 0.0), + straight_link(10, 1, 2), + straight_link(11, 2, 1), + ]); + assert_eq!(compute_layout_metrics(&view, &cfg()).loop_compactness, 0.0); +} + +#[test] +fn test_loop_compactness_flow_feedback_path_is_a_cycle() { + let view = make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", 300.0, 0.0), + flow_between(3, "f", 150.0, 200.0, 1, 2), + straight_link(10, 2, 1), + ]); + assert!(compute_layout_metrics(&view, &cfg()).loop_compactness > 0.0); +} + +fn bent_flow_loop_view(valve: Point, bend: Point) -> datamodel::StockFlow { + make_view(vec![ + stock(1, "a", 0.0, 0.0), + stock(2, "b", 300.0, 0.0), + flow_with_points( + 3, + "f", + (valve.x, valve.y), + vec![ + (0.0, 0.0, Some(1)), + (bend.x, bend.y, None), + (300.0, 0.0, Some(2)), + ], + ), + straight_link(10, 2, 1), + ]) +} + +#[test] +fn test_loop_compactness_scored_on_flow_valve_not_pipe_extent() { + // The loop vertex for a flow is its valve: stretching the pipe with a far + // interior point must not change the loop polygon; moving the valve must. + let valve = Point { x: 150.0, y: 200.0 }; + let near = compute_layout_metrics( + &bent_flow_loop_view(valve, Point { x: 150.0, y: 210.0 }), + &cfg(), + ); + let far = compute_layout_metrics( + &bent_flow_loop_view( + valve, + Point { + x: 150.0, + y: 2000.0, + }, + ), + &cfg(), + ); + assert!(near.loop_compactness > 0.0); + assert!((near.loop_compactness - far.loop_compactness).abs() < 1e-12); + let moved = compute_layout_metrics( + &bent_flow_loop_view(Point { x: 150.0, y: 400.0 }, Point { x: 150.0, y: 210.0 }), + &cfg(), + ); + assert!((near.loop_compactness - moved.loop_compactness).abs() > 1e-9); +} + +// --- metric validity: visibly worse diagrams must cost more --- +// +// The shipped default projects are hand-drawn, clean diagrams. Every +// degradation in the taste battery is an edit a modeler would call a +// regression; the committed metric must penalize each one on each of these +// exemplars, or an optimizer driving the metric is free to produce that very +// defect. `StraightenLinks` is excluded: flattening a non-loop connector is a +// matter of style, and only loops care (`loop_straightness`, a gentle nudge). +// +// The eval harness runs the same battery over the whole corpus, references +// and generated layouts alike; this test pins the exemplars a regression can +// never be allowed to reach. + +/// A shipped default project's hand-drawn main view. +fn default_project_view(dir: &str) -> datamodel::StockFlow { + let path = format!( + "{}/../../default_projects/{}/model.xmile", + env!("CARGO_MANIFEST_DIR"), + dir + ); + let file = std::fs::File::open(&path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(file)) + .unwrap_or_else(|e| panic!("parse {path}: {e:?}")); + match project.get_model("main").and_then(|m| m.views.first()) { + Some(datamodel::View::StockFlow(sf)) => sf.clone(), + _ => panic!("{dir} ships no main view"), + } +} + +#[test] +fn test_metric_penalizes_every_degradation_of_the_exemplars() { + let weights = MetricWeights::default(); + let mut misses = Vec::new(); + for dir in ["logistic-growth", "population", "fishbanks", "reliability"] { + let view = default_project_view(dir); + let base_metrics = compute_layout_metrics(&view, &cfg()); + let base = base_metrics.weighted_cost(&weights); + for degradation in Degradation::battery() { + if degradation == Degradation::StraightenLinks { + continue; + } + let Some(degraded) = degrade(&view, degradation) else { + continue; + }; + let metrics = compute_layout_metrics(°raded, &cfg()); + let cost = metrics.weighted_cost(&weights); + if cost <= base * 1.01 { + // Name the weighted terms that moved, so a miss is diagnosable. + let moved: Vec = metrics + .terms() + .iter() + .zip(base_metrics.terms().iter()) + .zip(weights.terms().iter()) + .filter(|((_, _), (_, w))| *w > 0.0) + .filter(|(((_, after), (_, before)), _)| (after - before).abs() > 1e-6) + .map(|(((name, after), (_, before)), (_, w))| { + format!("{name} {:+.4}", (after - before) * w) + }) + .collect(); + misses.push(format!( + "{dir}/{}: {base:.4} -> {cost:.4} [{}]", + degradation.name(), + moved.join(", ") + )); + } + } + } + assert!( + misses.is_empty(), + "the metric failed to penalize visibly worse diagrams:\n {}", + misses.join("\n ") + ); +} + +// --- human-vs-auto reference pairs under the committed weights --- +// +// On the shipped default projects, the hand-authored ("human") layout must +// score a lower cost than a fixed-seed generated ("auto") layout of the same +// model: if the metric preferred the generator's output over these exemplars, +// the metric -- not the exemplar -- would be wrong. + +const REF_PAIR_SEED: u64 = 42; + +fn human_cost(dir: &str) -> f64 { + compute_layout_metrics(&default_project_view(dir), &cfg()) + .weighted_cost(&MetricWeights::default()) +} + +fn auto_cost(dir: &str) -> f64 { + let path = format!( + "{}/../../default_projects/{}/model.xmile", + env!("CARGO_MANIFEST_DIR"), + dir + ); + let file = std::fs::File::open(&path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(file)) + .unwrap_or_else(|e| panic!("parse {path}: {e:?}")); + let config = LayoutConfig { + annealing_random_seed: REF_PAIR_SEED, + ..LayoutConfig::default() + }; + let view = crate::layout::generate_layout_with_config(&project, "main", config.clone(), None) + .expect("auto layout generation must succeed for the anchor model"); + compute_layout_metrics(&view, &config).weighted_cost(&MetricWeights::default()) +} + +fn assert_human_beats_auto(dir: &str) { + let human = human_cost(dir); + let auto = auto_cost(dir); + assert!( + human < auto, + "reference pair {dir}: expected human_cost ({human}) < auto_cost ({auto})" + ); +} + +#[test] +fn test_reference_pair_reliability_human_beats_auto() { + assert_human_beats_auto("reliability"); +} + +#[test] +fn test_reference_pair_fishbanks_human_beats_auto() { + assert_human_beats_auto("fishbanks"); +} + +#[test] +fn test_reference_pair_population_human_beats_auto() { + assert_human_beats_auto("population"); +} + +#[test] +fn test_reference_pair_logistic_growth_human_beats_auto() { + assert_human_beats_auto("logistic-growth"); +} diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index 2efad46b1..b8c98d413 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -19,6 +19,8 @@ mod objective; mod orthogonal; pub mod placement; pub mod sfdp; +#[cfg(any(test, feature = "layout_eval"))] +pub mod taste; pub mod text; pub mod uid; diff --git a/src/simlin-engine/src/layout/taste.rs b/src/simlin-engine/src/layout/taste.rs new file mode 100644 index 000000000..23edee0f4 --- /dev/null +++ b/src/simlin-engine/src/layout/taste.rs @@ -0,0 +1,361 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// Metamorphic taste checks for the layout-quality metric. +// +// A metric that is supposed to capture diagram taste must, at minimum, get +// WORSE when a diagram is visibly degraded. Each `Degradation` below is an edit +// every modeler would call a regression -- crowd the diagram, scatter its +// parameters, drop a node on another -- applied to a view that was fine. A +// degradation the metric does not penalize is a measured blind spot: an +// optimizer driving the metric is free to produce exactly that defect. +// +// The edits move only what a person would move by hand: free-floating nodes +// (auxiliaries, modules, aliases) are displaced individually; the stock-flow +// backbone moves only under a uniform transform, with flow endpoints re-snapped +// to the fixed-size stocks. A moved connector keeps its bow relative to its +// chord, so the edit changes WHERE nodes sit, not how curved their links are. +// +// PURE: every function takes a view and returns a new view; no I/O, and the +// seeded randomness is deterministic. + +use std::collections::{HashMap, HashSet}; + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +use crate::datamodel::view_element::LinkShape; +use crate::datamodel::{StockFlow, ViewElement}; +use crate::diagram::connector::get_visual_center; + +use super::declutter::resnap_flow_endpoints_to_stocks; + +/// A visibly-worse edit to a diagram. Every variant is expected to RAISE the +/// weighted cost of a view that was not already degraded in that way. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Degradation { + /// Shrink every position toward the view centroid by `factor` (< 1): labels + /// crowd each other and connectors shorten toward invisibility. + Cramp(f64), + /// Spread every position away from the view centroid by `factor` (> 1): the + /// diagram sprawls and every connector lengthens. + Inflate(f64), + /// Displace each free-floating node by a seeded random offset of up to + /// `amplitude` on each axis: alignment breaks and neighbors collide. + Jitter { amplitude: f64, seed: u64 }, + /// Permute the positions of the free-floating nodes: every node lands away + /// from what it connects to. + Shuffle { seed: u64 }, + /// Move the most-used parameter (the free node with the most outgoing + /// links) far outside the diagram: its connectors cross everything. + Exile, + /// Drop one free node exactly onto another free node. + Stack, + /// Draw every curved connector straight: feedback loops read as zig-zags. + StraightenLinks, +} + +impl Degradation { + /// A short stable name for reports. + pub fn name(&self) -> &'static str { + match self { + Degradation::Cramp(_) => "cramp", + Degradation::Inflate(_) => "inflate", + Degradation::Jitter { .. } => "jitter", + Degradation::Shuffle { .. } => "shuffle", + Degradation::Exile => "exile", + Degradation::Stack => "stack", + Degradation::StraightenLinks => "straighten", + } + } + + /// The standard battery the eval harness runs, in report order. + pub fn battery() -> [Degradation; 7] { + [ + Degradation::Cramp(0.6), + Degradation::Inflate(2.0), + Degradation::Jitter { + amplitude: 40.0, + seed: 7, + }, + Degradation::Shuffle { seed: 11 }, + Degradation::Exile, + Degradation::Stack, + Degradation::StraightenLinks, + ] + } +} + +/// Whether an element is a free-floating node a person drags individually. +fn is_free_node(e: &ViewElement) -> bool { + matches!( + e, + ViewElement::Aux(_) | ViewElement::Module(_) | ViewElement::Alias(_) + ) +} + +fn position(e: &ViewElement) -> Option<(f64, f64)> { + match e { + ViewElement::Aux(a) => Some((a.x, a.y)), + ViewElement::Stock(s) => Some((s.x, s.y)), + ViewElement::Flow(f) => Some((f.x, f.y)), + ViewElement::Module(m) => Some((m.x, m.y)), + ViewElement::Alias(a) => Some((a.x, a.y)), + ViewElement::Cloud(c) => Some((c.x, c.y)), + ViewElement::Link(_) | ViewElement::Group(_) => None, + } +} + +fn set_position(e: &mut ViewElement, x: f64, y: f64) { + match e { + ViewElement::Aux(a) => (a.x, a.y) = (x, y), + ViewElement::Module(m) => (m.x, m.y) = (x, y), + ViewElement::Alias(a) => (a.x, a.y) = (x, y), + ViewElement::Stock(s) => (s.x, s.y) = (x, y), + ViewElement::Cloud(c) => (c.x, c.y) = (x, y), + ViewElement::Flow(f) => { + let (dx, dy) = (x - f.x, y - f.y); + f.x = x; + f.y = y; + for p in &mut f.points { + p.x += dx; + p.y += dy; + } + } + ViewElement::Link(_) | ViewElement::Group(_) => {} + } +} + +/// The takeoff angle an Arc link has RELATIVE to its chord (degrees), keyed by +/// link uid, so a moved connector can be redrawn with the same bow. +fn arc_offsets(view: &StockFlow) -> HashMap { + let by_uid: HashMap = + view.elements.iter().map(|e| (e.get_uid(), e)).collect(); + let not_arrayed = |_: &str| false; + view.elements + .iter() + .filter_map(|e| match e { + ViewElement::Link(link) => { + let LinkShape::Arc(takeoff) = link.shape else { + return None; + }; + let from = by_uid.get(&link.from_uid)?; + let to = by_uid.get(&link.to_uid)?; + let (fx, fy) = get_visual_center(from, ¬_arrayed); + let (tx, ty) = get_visual_center(to, ¬_arrayed); + let chord = (ty - fy).atan2(tx - fx).to_degrees(); + Some((link.uid, takeoff - chord)) + } + _ => None, + }) + .collect() +} + +/// Re-apply each Arc link's pre-edit chord-relative takeoff to the post-edit +/// chord, so an edit that moves nodes does not also re-curve their links. +fn restore_arc_offsets(view: &mut StockFlow, offsets: &HashMap) { + let centers: HashMap = view + .elements + .iter() + .filter(|e| !matches!(e, ViewElement::Link(_) | ViewElement::Group(_))) + .map(|e| (e.get_uid(), get_visual_center(e, &|_: &str| false))) + .collect(); + for e in &mut view.elements { + let ViewElement::Link(link) = e else { continue }; + let Some(offset) = offsets.get(&link.uid) else { + continue; + }; + let (Some(&(fx, fy)), Some(&(tx, ty))) = + (centers.get(&link.from_uid), centers.get(&link.to_uid)) + else { + continue; + }; + let chord = (ty - fy).atan2(tx - fx).to_degrees(); + link.shape = LinkShape::Arc(chord + offset); + } +} + +/// Mean position of every positioned element. +fn centroid(view: &StockFlow) -> Option<(f64, f64)> { + let pts: Vec<(f64, f64)> = view.elements.iter().filter_map(position).collect(); + if pts.is_empty() { + return None; + } + let n = pts.len() as f64; + Some(( + pts.iter().map(|p| p.0).sum::() / n, + pts.iter().map(|p| p.1).sum::() / n, + )) +} + +/// Scale every position (flow pipe points included) about `center` by `s`, +/// then re-snap flow endpoints to the fixed-size stocks. +fn scale_about(view: &mut StockFlow, center: (f64, f64), s: f64) { + for e in &mut view.elements { + match e { + ViewElement::Flow(f) => { + f.x = center.0 + (f.x - center.0) * s; + f.y = center.1 + (f.y - center.1) * s; + for p in &mut f.points { + p.x = center.0 + (p.x - center.0) * s; + p.y = center.1 + (p.y - center.1) * s; + } + } + _ => { + if let Some((x, y)) = position(e) { + set_position( + e, + center.0 + (x - center.0) * s, + center.1 + (y - center.1) * s, + ); + } + } + } + } + resnap_flow_endpoints_to_stocks(&mut view.elements); +} + +/// Indices of free-floating nodes, in uid order (deterministic). +fn free_node_indices(view: &StockFlow) -> Vec { + let mut idx: Vec = view + .elements + .iter() + .enumerate() + .filter(|(_, e)| is_free_node(e)) + .map(|(i, _)| i) + .collect(); + idx.sort_by_key(|&i| view.elements[i].get_uid()); + idx +} + +/// Apply `degradation` to `view`. Returns `None` when the edit does not apply +/// (no free-floating nodes to move, fewer than two to stack or shuffle, no +/// curved link to straighten, an empty view), so a report can say "n/a" +/// instead of scoring an unchanged copy. +pub fn degrade(view: &StockFlow, degradation: Degradation) -> Option { + let offsets = arc_offsets(view); + let mut out = view.clone(); + let free = free_node_indices(view); + match degradation { + Degradation::Cramp(factor) | Degradation::Inflate(factor) => { + let center = centroid(view)?; + scale_about(&mut out, center, factor); + } + Degradation::Jitter { amplitude, seed } => { + if free.is_empty() { + return None; + } + let mut rng = StdRng::seed_from_u64(seed); + for &i in &free { + let (x, y) = position(&out.elements[i])?; + let dx = rng.random_range(-amplitude..=amplitude); + let dy = rng.random_range(-amplitude..=amplitude); + set_position(&mut out.elements[i], x + dx, y + dy); + } + } + Degradation::Shuffle { seed } => { + if free.len() < 2 { + return None; + } + let positions: Vec<(f64, f64)> = free + .iter() + .filter_map(|&i| position(&view.elements[i])) + .collect(); + // Fisher-Yates, then rotate if the permutation left everything in + // place so the edit always moves something. + let mut order: Vec = (0..positions.len()).collect(); + let mut rng = StdRng::seed_from_u64(seed); + for k in (1..order.len()).rev() { + let j = rng.random_range(0..=k); + order.swap(k, j); + } + if order.iter().enumerate().all(|(k, &j)| k == j) { + order.rotate_left(1); + } + for (k, &i) in free.iter().enumerate() { + let (x, y) = positions[order[k]]; + set_position(&mut out.elements[i], x, y); + } + } + Degradation::Exile => { + let outgoing = |uid: i32| { + view.elements + .iter() + .filter(|e| matches!(e, ViewElement::Link(l) if l.from_uid == uid)) + .count() + }; + let &target = free + .iter() + .filter(|&&i| outgoing(view.elements[i].get_uid()) > 0) + .max_by_key(|&&i| { + ( + outgoing(view.elements[i].get_uid()), + -view.elements[i].get_uid(), + ) + })?; + let (minx, miny, maxx, maxy) = view.elements.iter().filter_map(position).fold( + ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ), + |(a, b, c, d), (x, y)| (a.min(x), b.min(y), c.max(x), d.max(y)), + ); + let diag = ((maxx - minx).powi(2) + (maxy - miny).powi(2)) + .sqrt() + .max(200.0); + set_position(&mut out.elements[target], maxx + diag, maxy + diag); + } + Degradation::Stack => { + if free.len() < 2 { + return None; + } + let (x, y) = position(&view.elements[free[0]])?; + set_position(&mut out.elements[free[1]], x, y); + } + Degradation::StraightenLinks => { + let mut any = false; + for e in &mut out.elements { + if let ViewElement::Link(link) = e + && matches!(link.shape, LinkShape::Arc(_)) + { + link.shape = LinkShape::Straight; + any = true; + } + } + if !any { + return None; + } + return Some(out); + } + } + restore_arc_offsets(&mut out, &offsets); + Some(out) +} + +/// The uids a degradation moved, for tests and reports: every positioned +/// element whose position changed. +pub fn moved_uids(before: &StockFlow, after: &StockFlow) -> HashSet { + let old: HashMap = before + .elements + .iter() + .filter_map(|e| position(e).map(|p| (e.get_uid(), p))) + .collect(); + after + .elements + .iter() + .filter_map(|e| { + let p = position(e)?; + let q = old.get(&e.get_uid())?; + ((p.0 - q.0).abs() > 1e-9 || (p.1 - q.1).abs() > 1e-9).then_some(e.get_uid()) + }) + .collect() +} + +#[cfg(test)] +#[path = "taste_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/layout/taste_tests.rs b/src/simlin-engine/src/layout/taste_tests.rs new file mode 100644 index 000000000..7cf036db4 --- /dev/null +++ b/src/simlin-engine/src/layout/taste_tests.rs @@ -0,0 +1,230 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +use super::*; +use crate::datamodel; +use crate::diagram::constants::{STOCK_HEIGHT, STOCK_WIDTH}; + +/// A shipped, hand-authored default project's main view -- the realistic input +/// the eval harness degrades (the as-loaded view production would score). +fn default_project_view(dir: &str) -> StockFlow { + let path = format!( + "{}/../../default_projects/{}/model.xmile", + env!("CARGO_MANIFEST_DIR"), + dir + ); + let file = std::fs::File::open(&path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(file)) + .unwrap_or_else(|e| panic!("parse {path}: {e:?}")); + match project.get_model("main").and_then(|m| m.views.first()) { + Some(datamodel::View::StockFlow(sf)) => sf.clone(), + _ => panic!("{dir} ships no main view"), + } +} + +fn free_uids(view: &StockFlow) -> HashSet { + view.elements + .iter() + .filter(|e| is_free_node(e)) + .map(|e| e.get_uid()) + .collect() +} + +/// Every flow endpoint attached to a stock lies on that stock's boundary. +fn assert_flows_attached(view: &StockFlow) { + let stocks: HashMap = view + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y))), + _ => None, + }) + .collect(); + for e in &view.elements { + let ViewElement::Flow(f) = e else { continue }; + for p in &f.points { + let Some(&(sx, sy)) = p.attached_to_uid.and_then(|u| stocks.get(&u)) else { + continue; + }; + let on_vertical = ((p.x - sx).abs() - STOCK_WIDTH / 2.0).abs() < 1e-6 + && (p.y - sy).abs() <= STOCK_HEIGHT / 2.0 + 1e-6; + let on_horizontal = ((p.y - sy).abs() - STOCK_HEIGHT / 2.0).abs() < 1e-6 + && (p.x - sx).abs() <= STOCK_WIDTH / 2.0 + 1e-6; + assert!( + on_vertical || on_horizontal, + "flow {} endpoint ({}, {}) detached from stock at ({sx}, {sy})", + f.uid, + p.x, + p.y + ); + } + } +} + +#[test] +fn cramp_and_inflate_scale_about_the_centroid_and_keep_flows_attached() { + let view = default_project_view("fishbanks"); + let center = centroid(&view).unwrap(); + for (degradation, factor) in [ + (Degradation::Cramp(0.6), 0.6), + (Degradation::Inflate(2.0), 2.0), + ] { + let out = degrade(&view, degradation).expect("applies to any non-empty view"); + for (before, after) in view.elements.iter().zip(&out.elements) { + let (Some(p), Some(q)) = (position(before), position(after)) else { + continue; + }; + assert!( + ((q.0 - center.0) - (p.0 - center.0) * factor).abs() < 1e-6 + && ((q.1 - center.1) - (p.1 - center.1) * factor).abs() < 1e-6, + "{} moved {:?} -> {:?}, not a {factor}x scale about {center:?}", + degradation.name(), + p, + q + ); + } + assert_flows_attached(&out); + } +} + +#[test] +fn jitter_moves_only_free_nodes() { + let view = default_project_view("reliability"); + let out = degrade( + &view, + Degradation::Jitter { + amplitude: 40.0, + seed: 7, + }, + ) + .unwrap(); + let moved = moved_uids(&view, &out); + assert!(!moved.is_empty(), "jitter must move something"); + assert!( + moved.is_subset(&free_uids(&view)), + "jitter moved a backbone element: {:?}", + moved.difference(&free_uids(&view)).collect::>() + ); + assert_flows_attached(&out); +} + +#[test] +fn shuffle_permutes_free_node_positions() { + let view = default_project_view("reliability"); + let out = degrade(&view, Degradation::Shuffle { seed: 11 }).unwrap(); + let positions = |v: &StockFlow| { + let mut ps: Vec<(i64, i64)> = v + .elements + .iter() + .filter(|e| is_free_node(e)) + .filter_map(position) + .map(|(x, y)| ((x * 1000.0).round() as i64, (y * 1000.0).round() as i64)) + .collect(); + ps.sort(); + ps + }; + assert_eq!( + positions(&view), + positions(&out), + "a shuffle keeps the same set of free-node positions" + ); + assert!(!moved_uids(&view, &out).is_empty()); + assert!(moved_uids(&view, &out).is_subset(&free_uids(&view))); +} + +#[test] +fn exile_moves_one_parameter_outside_the_diagram() { + let view = default_project_view("population"); + let out = degrade(&view, Degradation::Exile).unwrap(); + let moved = moved_uids(&view, &out); + assert_eq!(moved.len(), 1, "exile moves exactly one node"); + let uid = *moved.iter().next().unwrap(); + assert!(free_uids(&view).contains(&uid)); + let maxx = view + .elements + .iter() + .filter_map(position) + .map(|p| p.0) + .fold(f64::NEG_INFINITY, f64::max); + let exiled = out.elements.iter().find(|e| e.get_uid() == uid).unwrap(); + assert!(position(exiled).unwrap().0 > maxx + 100.0); +} + +#[test] +fn stack_drops_one_free_node_on_another() { + let view = default_project_view("population"); + let out = degrade(&view, Degradation::Stack).unwrap(); + let free: Vec<(f64, f64)> = out + .elements + .iter() + .filter(|e| is_free_node(e)) + .filter_map(position) + .collect(); + let coincident = free.iter().enumerate().any(|(i, p)| { + free[i + 1..] + .iter() + .any(|q| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) + }); + assert!(coincident, "two free nodes must coincide after stacking"); + assert_eq!(moved_uids(&view, &out).len(), 1); +} + +#[test] +fn moving_nodes_preserves_each_arc_links_bow() { + let view = default_project_view("logistic-growth"); + let before = arc_offsets(&view); + assert!(!before.is_empty(), "fixture needs curved links"); + let out = degrade( + &view, + Degradation::Jitter { + amplitude: 40.0, + seed: 3, + }, + ) + .unwrap(); + let after = arc_offsets(&out); + for (uid, offset) in &before { + let new = after[uid]; + let diff = (new - offset).rem_euclid(360.0); + assert!( + diff < 1e-6 || (360.0 - diff) < 1e-6, + "link {uid} bow changed: {offset} -> {new}" + ); + } +} + +#[test] +fn straighten_turns_every_arc_straight() { + let view = default_project_view("logistic-growth"); + let out = degrade(&view, Degradation::StraightenLinks).unwrap(); + assert!( + out.elements + .iter() + .all(|e| !matches!(e, ViewElement::Link(l) if matches!(l.shape, LinkShape::Arc(_)))) + ); + assert!( + moved_uids(&view, &out).is_empty(), + "straightening moves no node" + ); +} + +#[test] +fn inapplicable_degradations_report_none() { + let empty = StockFlow { + name: None, + elements: vec![], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + }; + for degradation in Degradation::battery() { + assert!( + degrade(&empty, degradation).is_none(), + "{} applied to an empty view", + degradation.name() + ); + } +} From c42b5c97b33e57c06fff9305727a25652d35e1b6 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 10 Sep 2026 23:30:27 -0700 Subject: [PATCH 02/16] engine: replay incremental model builds in the layout eval harness Agents and notebook users rarely get a fresh layout of a finished model: MCP edit_model and pysimlin's patch sync lay out the first edit fresh and every later edit incrementally, preserving what is already placed. The harness only measured the fresh path, so the diagrams those users actually see went unevaluated. The replay builds each model from empty over a few edits (whole chains first, then other variables nearest the backbone), syncs the diagram after each through the same production calls, and scores, renders, and times the result. The first replay across the small and medium tiers shows the incremental path far behind the fresh one: chains added by later edits land on top of each other (reliability's two side chains, delays' three smooth chains) and labels pile up, at up to six times the fresh layout's cost. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- docs/design/layout-quality.md | 19 +- .../examples/layout_eval/knobs.rs | 11 ++ .../examples/layout_eval/main.rs | 30 ++- .../examples/layout_eval/replay.rs | 186 ++++++++++++++++++ .../examples/layout_eval/report.rs | 15 ++ 5 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 src/simlin-engine/examples/layout_eval/replay.rs diff --git a/docs/design/layout-quality.md b/docs/design/layout-quality.md index b39759597..06050b971 100644 --- a/docs/design/layout-quality.md +++ b/docs/design/layout-quality.md @@ -99,7 +99,8 @@ 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_WRITE_BASELINE`, `LAYOUT_EVAL_DECLUTTER=0`, +`LAYOUT_EVAL_REPLAY_STEPS` (0 skips the replay). For each corpus model it: @@ -107,10 +108,18 @@ For each corpus model it: summarized benchstat-style in `layout::eval_stats`); - runs `generate_best_layout` once, timed -- the layout a user gets and what it costs; -- renders the hand-drawn reference, the production layout, and the median and - worst seeds to PNG (small diagrams upscaled so labels are legible), with - `*_defects.png` overlays for the reference and production and a - `*.view.json` of every rendered view; +- 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), diff --git a/src/simlin-engine/examples/layout_eval/knobs.rs b/src/simlin-engine/examples/layout_eval/knobs.rs index b60bf5c0c..d8312ff40 100644 --- a/src/simlin-engine/examples/layout_eval/knobs.rs +++ b/src/simlin-engine/examples/layout_eval/knobs.rs @@ -11,6 +11,10 @@ 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>, @@ -29,6 +33,9 @@ pub struct Knobs { 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> { @@ -99,6 +106,10 @@ impl Knobs { 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 index 0c1f28c1a..7d196185e 100644 --- a/src/simlin-engine/examples/layout_eval/main.rs +++ b/src/simlin-engine/examples/layout_eval/main.rs @@ -7,8 +7,10 @@ //! 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), -//! and render the hand-authored reference, the production layout, and the -//! median and worst seeds to PNG. Writes `metrics.json`, `corpus.json`, and an +//! 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 @@ -31,6 +33,8 @@ //! 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 @@ -43,6 +47,7 @@ mod corpus; mod knobs; mod render; +mod replay; mod report; mod sweep; mod taste; @@ -150,9 +155,23 @@ fn process_model( ) }) }; + 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), }; @@ -168,6 +187,12 @@ fn process_model( }, 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. @@ -189,6 +214,7 @@ fn process_model( }, 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, }; 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 index 3265ba87d..e3122db6a 100644 --- a/src/simlin-engine/examples/layout_eval/report.rs +++ b/src/simlin-engine/examples/layout_eval/report.rs @@ -22,6 +22,7 @@ use crate::taste::{TasteCheck, tally}; pub struct ModelRenders { pub reference: Option, pub production: Option, + pub incremental: Option, pub median: Option, pub worst: Option, } @@ -33,6 +34,8 @@ pub struct ModelFacts { 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. @@ -65,11 +68,15 @@ pub struct ModelReport { 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, } @@ -136,10 +143,12 @@ pub fn build_report( 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), }) @@ -460,6 +469,12 @@ pub fn render_index_html(report: &EvalReport, before: Option<&EvalReport>) -> St 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", From 369a2c4709089ec19efd43b9262a2587f3890cc3 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 10 Sep 2026 23:53:21 -0700 Subject: [PATCH 03/16] engine: give each side flow its own stock face A stock face is 35-45px long and a valve is drawn 18px across, so two cloud flows sharing a face at the 1/3 and 2/3 offsets drew their valves, clouds, and names on top of each other. The layout eval renders showed this as the worst defect in cross_element, bathtub, catastrophe, and mortgage_econ (sweep medians -36%, -35%, -9%, -7% once fixed; no other model moved). classify_flow_sides now seats side flows one per face in preference order (outflows right, bottom, top; inflows left, top, bottom) and never on a face a chain flow holds, sharing a face only when every allowed face is taken. On the incremental path it receives the faces the stock's side flows are already drawn on, and each keeps its face unless it sits on a chain face or its preferred face has come free, so adding a sibling never moves a flow the patch did not touch; a flow moved to the opposite face is rebuilt with its label side kept. The three copies of the side-flow geometry (layout_chain, attachment_based_flow_position, create_flow_view_element) now share one valve-position and one pipe-points helper covering all four faces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- .../src/layout/layout_flow_side_tests.rs | 658 ++++++++++++++++++ .../src/layout/layout_label_tests.rs | 26 +- .../src/layout/layout_review_tests.rs | 132 ++-- src/simlin-engine/src/layout/layout_tests.rs | 437 +----------- src/simlin-engine/src/layout/mod.rs | 528 +++++++------- 5 files changed, 1001 insertions(+), 780 deletions(-) create mode 100644 src/simlin-engine/src/layout/layout_flow_side_tests.rs 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_tests.rs b/src/simlin-engine/src/layout/layout_tests.rs index 7e537cba4..3ed16a337 100644 --- a/src/simlin-engine/src/layout/layout_tests.rs +++ b/src/simlin-engine/src/layout/layout_tests.rs @@ -5437,223 +5437,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 +5463,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/mod.rs b/src/simlin-engine/src/layout/mod.rs index b8c98d413..f3e625058 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -1514,20 +1514,34 @@ pub fn diff_clouds(state: &mut LayoutState, metadata: &ComputedMetadata) { } } -/// Classify which stock edge each flow should attach to and compute -/// even spacing offsets. +/// Classify which stock face each flow attaches to and where along it. /// -/// When a stock has a chain flow (stock-to-stock) going right, non-chain -/// outflows (stock-to-cloud) exit from the bottom. Symmetrically, when a -/// stock has a chain inflow from the left, non-chain inflows enter from -/// the top. Multiple flows on the same side are distributed using the -/// `(i+1)/(n+1)` formula (matching the TS editor's `computeFlowOffsets`). +/// Chain flows (stock-to-stock) run along the chain: outflows leave the right +/// face, inflows enter the left face, and several on one face are spread with +/// the `(i+1)/(n+1)` formula (matching the TS editor's `computeFlowOffsets`) -- +/// their valves sit between different stock pairs, so they do not collide. +/// +/// Side flows (to or from a cloud) each get a face of their OWN whenever one is +/// free. A face is only 35-45px long and a valve is drawn 18px across, so two +/// side flows sharing a face at 1/3 and 2/3 put their valves, clouds, and names +/// on top of each other. Outflows prefer right, then bottom, then top; inflows +/// prefer left, then top, then bottom -- the way modelers draw births in from +/// the left and deaths out to the right, with a second outflow dropping below. +/// A face holding a chain flow is never offered to a side flow, and only when +/// every remaining allowed face is taken do side flows share one. +/// +/// `existing` gives the faces of side flows already drawn on this stock (the +/// incremental path): each keeps its face -- shared or not, hand-placed or +/// not -- unless it sits on a chain face, or its preferred face has come free +/// (the chain flow that pushed it below was deleted). Adding a sibling +/// therefore never moves a flow to another face. /// /// Returns a map from flow ident to its attachment info for all flows /// connected to this stock. fn classify_flow_sides( stock_ident: &str, metadata: &ComputedMetadata, + existing: &HashMap, ) -> HashMap { let mut result = HashMap::new(); @@ -1566,72 +1580,149 @@ fn classify_flow_sides( } } - // Outflow placement: if chain outflows exist, side outflows go to Bottom - let side_outflow_side = if !chain_outflows.is_empty() { - StockAttachSide::Bottom - } else { - StockAttachSide::Right - }; - - // Inflow placement: if chain inflows exist, side inflows go to Top - let side_inflow_side = if !chain_inflows.is_empty() { - StockAttachSide::Top - } else { - StockAttachSide::Left - }; - - // Group all outflows by their assigned side - let mut right_flows: Vec = Vec::new(); - let mut bottom_flows: Vec = Vec::new(); - for flow in &chain_outflows { - right_flows.push(flow.clone()); + // A face holding a chain flow belongs to the chain: a side flow there would + // run its pipe along the chain's, so side flows never take one. + let mut chain_faces: HashSet = HashSet::new(); + let mut faces: HashMap> = HashMap::new(); + if !chain_outflows.is_empty() { + chain_faces.insert(StockAttachSide::Right); + faces.insert(StockAttachSide::Right, chain_outflows); } - for flow in &side_outflows { - match side_outflow_side { - StockAttachSide::Bottom => bottom_flows.push(flow.clone()), - StockAttachSide::Right => right_flows.push(flow.clone()), - StockAttachSide::Top | StockAttachSide::Left => right_flows.push(flow.clone()), - } + if !chain_inflows.is_empty() { + chain_faces.insert(StockAttachSide::Left); + faces.insert(StockAttachSide::Left, chain_inflows); } - // Group all inflows by their assigned side - let mut left_flows: Vec = Vec::new(); - let mut top_flows: Vec = Vec::new(); - for flow in &chain_inflows { - left_flows.push(flow.clone()); - } - for flow in &side_inflows { - match side_inflow_side { - StockAttachSide::Top => top_flows.push(flow.clone()), - StockAttachSide::Left => left_flows.push(flow.clone()), - StockAttachSide::Bottom | StockAttachSide::Right => left_flows.push(flow.clone()), - } + const OUTFLOW_FACES: [StockAttachSide; 3] = [ + StockAttachSide::Right, + StockAttachSide::Bottom, + StockAttachSide::Top, + ]; + const INFLOW_FACES: [StockAttachSide; 3] = [ + StockAttachSide::Left, + StockAttachSide::Top, + StockAttachSide::Bottom, + ]; + + side_outflows.sort(); + side_inflows.sort(); + let side_flows: Vec<(String, &[StockAttachSide; 3])> = side_outflows + .into_iter() + .map(|f| (f, &OUTFLOW_FACES)) + .chain(side_inflows.into_iter().map(|f| (f, &INFLOW_FACES))) + .collect(); + let load = |faces: &HashMap>, side: StockAttachSide| { + faces.get(&side).map_or(0, Vec::len) + }; + + // Already-drawn side flows keep their faces. Pass one seats the flows + // already on their preferred face; pass two moves a flow off a secondary + // face only onto its preferred face, and only if that has come free (the + // chain that pushed it below was deleted); a flow sitting on a chain face + // is re-placed like a new one. + let mut pending: Vec<(String, &[StockAttachSide; 3])> = Vec::new(); + let mut secondary: Vec<(String, &[StockAttachSide; 3], StockAttachSide)> = Vec::new(); + for (flow, allowed) in side_flows { + match existing.get(&flow) { + Some(&side) if chain_faces.contains(&side) => pending.push((flow, allowed)), + Some(&side) if side == allowed[0] => faces.entry(side).or_default().push(flow), + Some(&side) => secondary.push((flow, allowed, side)), + None => pending.push((flow, allowed)), + } + } + for (flow, allowed, side) in secondary { + let preferred_free = !chain_faces.contains(&allowed[0]) && load(&faces, allowed[0]) == 0; + let target = if preferred_free { allowed[0] } else { side }; + faces.entry(target).or_default().push(flow); + } + + // New side flows take the least-loaded allowed face, in preference order. + for (flow, allowed) in pending { + let side = *allowed + .iter() + .enumerate() + .filter(|(_, side)| !chain_faces.contains(*side)) + .min_by_key(|(rank, side)| (load(&faces, **side), *rank)) + .map(|(_, side)| side) + .expect("a flow's allowed faces include two that no chain can hold"); + faces.entry(side).or_default().push(flow); } - // Distribute flows within each side group using (i+1)/(n+1) - let assign_side = |flows: &mut [String], - side: StockAttachSide, - result: &mut HashMap| { + // Distribute flows within each face using (i+1)/(n+1) + for (side, mut flows) in faces { flows.sort(); // deterministic ordering by ident let n = flows.len(); - for (i, flow) in flows.iter().enumerate() { + for (i, flow) in flows.into_iter().enumerate() { let offset = if n == 1 { 0.5 } else { (i as f64 + 1.0) / (n as f64 + 1.0) }; - result.insert(flow.clone(), FlowAttachment { side, offset }); + result.insert(flow, FlowAttachment { side, offset }); } - }; - - assign_side(&mut right_flows, StockAttachSide::Right, &mut result); - assign_side(&mut bottom_flows, StockAttachSide::Bottom, &mut result); - assign_side(&mut left_flows, StockAttachSide::Left, &mut result); - assign_side(&mut top_flows, StockAttachSide::Top, &mut result); + } result } +/// 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 +} + /// Pick a starting stock for chain layout. Returns the stock with the /// highest flow connectivity (inflows + outflows), breaking ties /// alphabetically for determinism. @@ -1823,38 +1914,65 @@ fn attachment_based_flow_position( let stock_name = from_stock.or(to_stock)?; let stock_uid = state.uid_manager.get_uid(stock_name)?; let stock_pos = state.positions.get(&stock_uid)?; - Some(match attachment.side { - StockAttachSide::Bottom => { - let x = stock_pos.x - config.stock_width / 2.0 + config.stock_width * attachment.offset; - Position::new( - x, - stock_pos.y + config.stock_height / 2.0 + config.horizontal_spacing / 2.0, - ) - } - StockAttachSide::Top => { - let x = stock_pos.x - config.stock_width / 2.0 + config.stock_width * attachment.offset; - Position::new( - x, - stock_pos.y - config.stock_height / 2.0 - config.horizontal_spacing / 2.0, - ) - } - StockAttachSide::Right => { - let y = - stock_pos.y - config.stock_height / 2.0 + config.stock_height * attachment.offset; - Position::new( - stock_pos.x + config.stock_width / 2.0 + config.horizontal_spacing / 2.0, - y, - ) - } - StockAttachSide::Left => { - let y = - stock_pos.y - config.stock_height / 2.0 + config.stock_height * attachment.offset; - Position::new( - stock_pos.x - config.stock_width / 2.0 - config.horizontal_spacing / 2.0, - y, - ) - } - }) + Some(side_flow_valve_position(*stock_pos, *attachment, config)) +} + +/// The valve position of a side flow attached to `stock_pos` at `attachment`: +/// a half `horizontal_spacing` off the assigned face, slid along the face to +/// the attachment offset, so the valve sits on the pipe leaving that point. +fn side_flow_valve_position( + stock_pos: Position, + attachment: FlowAttachment, + config: &LayoutConfig, +) -> Position { + let along_x = stock_pos.x - config.stock_width / 2.0 + config.stock_width * attachment.offset; + let along_y = stock_pos.y - config.stock_height / 2.0 + config.stock_height * attachment.offset; + let off_x = config.stock_width / 2.0 + config.horizontal_spacing / 2.0; + let off_y = config.stock_height / 2.0 + config.horizontal_spacing / 2.0; + match attachment.side { + StockAttachSide::Bottom => Position::new(along_x, stock_pos.y + off_y), + StockAttachSide::Top => Position::new(along_x, stock_pos.y - off_y), + StockAttachSide::Right => Position::new(stock_pos.x + off_x, along_y), + StockAttachSide::Left => Position::new(stock_pos.x - off_x, along_y), + } +} + +/// The two points of a side flow's pipe: one on `stock_pos`'s assigned face at +/// the attachment offset, one a free end past the valve at `pos` (where the +/// cloud goes), ordered source to sink. +fn side_flow_points( + stock_pos: Position, + stock_uid: i32, + attachment: FlowAttachment, + pos: Position, + is_outflow: bool, + config: &LayoutConfig, +) -> Vec { + let half_w = config.stock_width / 2.0; + let half_h = config.stock_height / 2.0; + let along_x = stock_pos.x - half_w + config.stock_width * attachment.offset; + let along_y = stock_pos.y - half_h + config.stock_height * attachment.offset; + let (on_face, free_end) = match attachment.side { + StockAttachSide::Bottom => ((along_x, stock_pos.y + half_h), (along_x, pos.y + 50.0)), + StockAttachSide::Top => ((along_x, stock_pos.y - half_h), (along_x, pos.y - 50.0)), + StockAttachSide::Right => ((stock_pos.x + half_w, along_y), (pos.x + 50.0, along_y)), + StockAttachSide::Left => ((stock_pos.x - half_w, along_y), (pos.x - 50.0, along_y)), + }; + let stock_point = FlowPoint { + x: on_face.0, + y: on_face.1, + attached_to_uid: Some(stock_uid), + }; + let free_point = FlowPoint { + x: free_end.0, + y: free_end.1, + attached_to_uid: None, + }; + if is_outflow { + vec![stock_point, free_point] + } else { + vec![free_point, stock_point] + } } /// Create a single flow view element with its flow points and clouds. @@ -1923,64 +2041,11 @@ fn create_flow_view_element( .get(&from_uid) .copied() .unwrap_or(Position::new(pos.x - 50.0, pos.y)); - match attachment { - Some(FlowAttachment { - side: StockAttachSide::Bottom, - offset, - }) => { - // Vertical flow exiting from the bottom of the stock - let attach_x = - from_pos.x - config.stock_width / 2.0 + config.stock_width * offset; - vec![ - FlowPoint { - x: attach_x, - y: from_pos.y + config.stock_height / 2.0, - attached_to_uid: Some(from_uid), - }, - FlowPoint { - x: attach_x, - y: pos.y + 50.0, - attached_to_uid: None, - }, - ] - } - Some(FlowAttachment { - side: StockAttachSide::Right, - offset, - }) => { - // Horizontal flow exiting to the right, offset along - // the right edge for multi-flow distribution - let attach_y = - from_pos.y - config.stock_height / 2.0 + config.stock_height * offset; - vec![ - FlowPoint { - x: from_pos.x + config.stock_width / 2.0, - y: attach_y, - attached_to_uid: Some(from_uid), - }, - FlowPoint { - x: pos.x + 50.0, - y: pos.y, - attached_to_uid: None, - }, - ] - } - _ => { - // Default: horizontal flow exiting to the right - vec![ - FlowPoint { - x: from_pos.x + config.stock_width / 2.0, - y: pos.y, - attached_to_uid: Some(from_uid), - }, - FlowPoint { - x: pos.x + 50.0, - y: pos.y, - attached_to_uid: None, - }, - ] - } - } + let attachment = attachment.unwrap_or(FlowAttachment { + side: StockAttachSide::Right, + offset: 0.5, + }); + side_flow_points(from_pos, from_uid, attachment, pos, true, config) } (None, Some(to)) => { let to_uid = state.get_or_alloc_uid(to); @@ -1989,64 +2054,11 @@ fn create_flow_view_element( .get(&to_uid) .copied() .unwrap_or(Position::new(pos.x + 50.0, pos.y)); - match attachment { - Some(FlowAttachment { - side: StockAttachSide::Top, - offset, - }) => { - // Vertical flow entering from the top of the stock - let attach_x = - to_pos.x - config.stock_width / 2.0 + config.stock_width * offset; - vec![ - FlowPoint { - x: attach_x, - y: pos.y - 50.0, - attached_to_uid: None, - }, - FlowPoint { - x: attach_x, - y: to_pos.y - config.stock_height / 2.0, - attached_to_uid: Some(to_uid), - }, - ] - } - Some(FlowAttachment { - side: StockAttachSide::Left, - offset, - }) => { - // Horizontal flow entering from the left, offset along - // the left edge for multi-flow distribution - let attach_y = - to_pos.y - config.stock_height / 2.0 + config.stock_height * offset; - vec![ - FlowPoint { - x: pos.x - 50.0, - y: pos.y, - attached_to_uid: None, - }, - FlowPoint { - x: to_pos.x - config.stock_width / 2.0, - y: attach_y, - attached_to_uid: Some(to_uid), - }, - ] - } - _ => { - // Default: horizontal flow entering from the left - vec![ - FlowPoint { - x: pos.x - 50.0, - y: pos.y, - attached_to_uid: None, - }, - FlowPoint { - x: to_pos.x - config.stock_width / 2.0, - y: pos.y, - attached_to_uid: Some(to_uid), - }, - ] - } - } + let attachment = attachment.unwrap_or(FlowAttachment { + side: StockAttachSide::Left, + offset: 0.5, + }); + side_flow_points(to_pos, to_uid, attachment, pos, false, config) } (None, None) => { vec![ @@ -2179,7 +2191,7 @@ fn layout_chain( // This avoids redundant calls to classify_flow_sides during BFS. let mut flow_attachments: HashMap = HashMap::new(); for stock_ident in stocks { - let sides = classify_flow_sides(stock_ident, metadata); + let sides = classify_flow_sides(stock_ident, metadata, &HashMap::new()); flow_attachments.extend(sides); } @@ -2357,79 +2369,24 @@ fn layout_chain( chain::stock_pair_valve_position(a_pos, b_pos, idx, count) } } - (Some(_), None) => { - // Outflow to cloud: check if it should go downward - match flow_attachments.get(&item.id).copied() { - Some(FlowAttachment { - side: StockAttachSide::Bottom, - offset, - }) => { - let x_offset = item.position.x - config.stock_width / 2.0 - + config.stock_width * offset; - Position::new( - x_offset, - item.position.y - + config.stock_height / 2.0 - + config.horizontal_spacing / 2.0, - ) - } - Some(FlowAttachment { - side: StockAttachSide::Right, - offset, - }) => { - let y_offset = item.position.y - config.stock_height / 2.0 - + config.stock_height * offset; - Position::new( - item.position.x - + config.stock_width / 2.0 - + config.horizontal_spacing / 2.0, - y_offset, - ) - } - _ => Position::new( - item.position.x - + config.stock_width / 2.0 - + config.horizontal_spacing / 2.0, - item.position.y, - ), - } - } - (None, Some(_)) => { - // Inflow from cloud: check if it should come from above - match flow_attachments.get(&item.id).copied() { - Some(FlowAttachment { - side: StockAttachSide::Top, - offset, - }) => { - let x_offset = item.position.x - config.stock_width / 2.0 - + config.stock_width * offset; - Position::new( - x_offset, - item.position.y - - config.stock_height / 2.0 - - config.horizontal_spacing / 2.0, - ) - } - Some(FlowAttachment { - side: StockAttachSide::Left, - offset, - }) => { - let y_offset = item.position.y - config.stock_height / 2.0 - + config.stock_height * offset; - Position::new( - item.position.x - - config.stock_width / 2.0 - - config.horizontal_spacing / 2.0, - y_offset, - ) - } - _ => Position::new( - item.position.x - - config.stock_width / 2.0 - - config.horizontal_spacing / 2.0, - item.position.y, - ), - } + (Some(_), None) | (None, Some(_)) => { + // A side flow to or from a cloud: its valve sits a + // half spacing off the face `classify_flow_sides` + // assigned it. + let is_outflow = from_stock.is_some(); + let attachment = + flow_attachments + .get(&item.id) + .copied() + .unwrap_or(FlowAttachment { + side: if is_outflow { + StockAttachSide::Right + } else { + StockAttachSide::Left + }, + offset: 0.5, + }); + side_flow_valve_position(item.position, attachment, config) } (None, None) => { // Cloud-to-cloud @@ -5393,9 +5350,13 @@ pub fn incremental_layout( } } + // 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 sides = classify_flow_sides(stock, &metadata); + 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 @@ -5450,6 +5411,15 @@ pub fn incremental_layout( 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). From bd61d9605b69a07fa42847b6f7da2f0f9e55cc39 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 00:01:53 -0700 Subject: [PATCH 04/16] engine: link builtin call inputs and lookup tables in layouts compute_metadata drew a variable's links from the heads of its reads, but the parse turns `SMTH1(input, delay)` into a synthesized module instance, hoists a non-identifier argument into a helper, and captures a PREVIOUS argument, so the heads name those helpers rather than what the modeler typed; and a `LOOKUP(table, x)` table is recorded as a referenced table, not a read (#650). Every such input lost its arrow, and a variable reading only through a builtin call was classified as a constant. The layout eval corpus showed it as rows of "isolated" parameters in bathtub, catastrophe, beer_game, mortgage_econ, and wonderland that are in fact wired in. drawn_reads now replaces a read of a helper with what the helper reads, recursively, and adds every table the variable or its helpers call. Sweep costs rise on those models, as they must: the previously parked variables cost nothing without links, and now carry their real ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/layout_tests.rs | 161 +++++++++++++++++++ src/simlin-engine/src/layout/mod.rs | 58 +++++-- 2 files changed, 210 insertions(+), 9 deletions(-) diff --git a/src/simlin-engine/src/layout/layout_tests.rs b/src/simlin-engine/src/layout/layout_tests.rs index 3ed16a337..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()); diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index f3e625058..edb7f01c4 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -4365,6 +4365,54 @@ fn rendered_dependency_ident( mapped.filter(|ident| ident != dependent) } +/// The names a variable's diagram links come from: the head of every read (a +/// module read is drawn to the module box), with each read of a helper the +/// parse synthesized -- a builtin module instance such as `SMTH1`'s, a hoisted +/// call argument, a captured `PREVIOUS`/`INIT` argument -- replaced by what +/// that helper reads, plus every lookup table the variable or its helpers call. +/// A modeler writes `SMTH1(input, delay)` or `LOOKUP(table, x)` and expects an +/// arrow from each name they typed; the synthesized helpers are invisible, and +/// a table is a layout reference (not a data-flow read), so neither shows up +/// among the plain heads (#650). Sorted and deduplicated. +fn drawn_reads(var_deps: &crate::db::VariableDeps) -> Vec { + let helpers: HashMap<&str, _> = var_deps + .implicit_vars + .iter() + .map(|helper| (helper.name.as_str(), helper)) + .collect(); + let mut reads: BTreeSet = var_deps + .referenced_tables + .iter() + .map(|table| table.as_str().to_string()) + .collect(); + let mut expanded: HashSet<&str> = HashSet::new(); + let mut pending = vec![&var_deps.deps]; + while let Some(deps) = pending.pop() { + for head in deps.heads() { + let name = head.as_str(); + match helpers.get(name) { + Some(helper) => { + // A helper can be read by several siblings (an instance + // reads the argument hoisted for it); expand it once. + if expanded.insert(name) { + pending.push(&helper.deps); + reads.extend( + helper + .referenced_tables + .iter() + .map(|table| table.as_str().to_string()), + ); + } + } + None => { + reads.insert(name.to_string()); + } + } + } + } + reads.into_iter().collect() +} + /// Build feedback loops from the persisted model loop_metadata (UIDs only, /// no LTM simulation). Used as a fallback when LTM detection fails. fn build_feedback_loops_from_metadata( @@ -4542,15 +4590,7 @@ pub fn compute_metadata( let empty_inputs = crate::db::ModuleInputSet::empty(db); let var_deps = crate::db::variable_direct_dependencies(db, sv, source_project, empty_inputs); - // A module read is drawn to the module box: the read's head. - let mut combined: Vec = var_deps - .deps - .heads() - .into_iter() - .map(|head| head.as_str().to_string()) - .collect(); - combined.sort(); - combined.dedup(); + let combined = drawn_reads(var_deps); if combined.is_empty() { // Check whether the equation actually parsed. If the AST // is None, the equation has syntax errors and we fall back From b7ff853163e2dbdb521ab4784c1f6f883364258c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 00:22:36 -0700 Subject: [PATCH 05/16] engine: choose label sides by what the metric charges The declutter pass chose each label's side by the area other shapes and labels covered, so it could not see a link through a name -- the metric's most common defect in every generated corpus layout (a mean strike of 0.25-0.6 per label) -- nor a name jammed against a neighbor. It now charges each candidate side through metrics::LabelScene exactly as the metric charges the label there: covered by shapes and other labels, struck by links and pipes, and crowding its neighbors. On the layout eval corpus (small and medium tiers, 8 seeds) the sweep medians fall 27% in aggregate, significantly on 20 of 21 models; renders show names moving off their links (logistic_growth now reads as a loop) and stock names off crowded faces. The metric charged a pipe through a name by the area of its 4px band, a fifth of what a hairline link through the same name cost, so a stock's name drawn over an inflow entering that face barely registered. A pipe now strikes a name the way a link does (a flow's own pipe excepted, including for the stock it enters) and no longer counts as coverage. The pair clearance crowding measures moves into footprint_gap so the chooser and the term cannot drift. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- docs/design/layout-quality.md | 10 +- src/simlin-engine/src/layout/declutter.rs | 204 +++++++++------ src/simlin-engine/src/layout/metrics.rs | 232 +++++++++++++----- src/simlin-engine/src/layout/metrics_tests.rs | 63 +++-- 4 files changed, 353 insertions(+), 156 deletions(-) diff --git a/docs/design/layout-quality.md b/docs/design/layout-quality.md index 06050b971..44be2ad5b 100644 --- a/docs/design/layout-quality.md +++ b/docs/design/layout-quality.md @@ -22,8 +22,10 @@ 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 -- node shapes and pipes -- so what -the optimizer removes is what the score counts. +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 @@ -36,9 +38,9 @@ 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, shapes, and other flows' pipes | 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 link length through the text, relative to the box's smaller side; a node's own links count half | 1.5 | +| `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 | diff --git a/src/simlin-engine/src/layout/declutter.rs b/src/simlin-engine/src/layout/declutter.rs index a908d05bf..baf8f79e1 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,11 +27,12 @@ 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; use crate::diagram::label::label_bounds; use super::metrics::{ - alias_label_props_for, alias_source_names, element_label_props_for, node_shape_box, pipe_rects, + 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 @@ -217,61 +217,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 +275,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() } @@ -409,8 +397,8 @@ 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 -- the same obstacles the metric charges labels -/// and connectors against. +/// 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 { @@ -544,17 +532,13 @@ pub(crate) 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() - .flat_map(|(i, e)| shape_rects(e).into_iter().map(move |r| (i, r))) - .collect(); + 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. @@ -583,7 +567,18 @@ 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 chosen = choose_label_sides( + &labels, + |id, lbox, label_of| { + let current = |uid: i32| id_of_uid.get(&uid).and_then(|&other| label_of(other)); + scene.label_cost(uids[id], lbox, current, &weights) + }, + LABEL_SIDE_ROUNDS, + ); for (id, side) in chosen { set_label_side(&mut elements[id], side); } @@ -786,6 +781,7 @@ pub fn declutter_view(elements: &mut [ViewElement]) { #[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 { @@ -972,8 +968,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)); } @@ -987,7 +985,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)); } @@ -1011,7 +1009,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!( @@ -1020,6 +1022,66 @@ 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" + ); + } + // ── flow labels as side-choice obstacles ── #[test] diff --git a/src/simlin-engine/src/layout/metrics.rs b/src/simlin-engine/src/layout/metrics.rs index 232ca7044..c61dfad2a 100644 --- a/src/simlin-engine/src/layout/metrics.rs +++ b/src/simlin-engine/src/layout/metrics.rs @@ -103,12 +103,13 @@ pub struct LayoutMetrics { /// causal connection. pub node_connector_overlap: f64, /// Mean over labels of the fraction of each label box covered by other - /// labels, other nodes' shapes, and other flows' pipes (capped at 1). + /// labels and other nodes' shapes (capped at 1). pub label_overlap: f64, - /// Mean over labels of how much link polyline 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. The label's own node's links count at - /// `OWN_LINK_STRIKE_FACTOR`. + /// 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. @@ -550,10 +551,10 @@ impl SceneNode { } } -fn build_scene_nodes(view: &datamodel::StockFlow) -> Vec { - let alias_names = alias_source_names(&view.elements); +fn build_scene_nodes(elements: &[ViewElement]) -> Vec { + let alias_names = alias_source_names(elements); let not_arrayed = |_: &str| false; - view.elements + elements .iter() .filter_map(|e| { let shape = node_shape_box(e)?; @@ -597,7 +598,11 @@ enum ConnectorKind { /// 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, @@ -636,13 +641,13 @@ fn polyline_length(points: &[Point]) -> f64 { /// 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(view: &datamodel::StockFlow) -> Vec { +fn collect_connector_geometry(elements: &[ViewElement]) -> Vec { let uid_elements: HashMap = - view.elements.iter().map(|e| (e.get_uid(), e)).collect(); + 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)) = ( @@ -659,6 +664,7 @@ fn collect_connector_geometry(view: &datamodel::StockFlow) -> Vec Vec f64 { let labeled: Vec<&SceneNode> = nodes.iter().filter(|n| n.label.is_some()).collect(); if labeled.is_empty() { @@ -817,9 +827,6 @@ fn label_overlap_term(nodes: &[SceneNode], sink: &mut DefectSink) -> f64 { if let Some(other) = b.label { covered += rect_overlap_area(&lbl, &other); } - for r in &b.pipe { - covered += rect_overlap_area(&lbl, r); - } } let fraction = covered.min(area) / area; if fraction > 0.0 { @@ -830,8 +837,8 @@ fn label_overlap_term(nodes: &[SceneNode], sink: &mut DefectSink) -> f64 { total / labeled.len() as f64 } -/// `label_connector_overlap`: mean over labels of the link length through the -/// label's text box relative to the box's smaller side. +/// `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], @@ -846,37 +853,120 @@ fn label_connector_overlap_term( } let mut total = 0.0; for (owner, lbl) in &labels { - let text = inset(lbl, LABEL_INSET); - let side = common::rect_width(&text).min(common::rect_height(&text)); - if side <= 0.0 { - continue; + let fraction = label_strike_fraction(*owner, lbl, connectors); + if fraction > 0.0 { + sink.push(DefectKind::LabelCrossed, *lbl, fraction); } - let mut through = 0.0; - for c in connectors.iter().filter(|c| c.kind == ConnectorKind::Link) { + total += fraction; + } + total / labels.len() as f64 +} + +/// How struck out the label box `lbl` of node `owner` is: the connector length +/// through its (inset) text box relative to the box's smaller side, capped at +/// 1. +fn label_strike_fraction(owner: i32, lbl: &Rect, connectors: &[ConnectorGeometry]) -> f64 { + let text = inset(lbl, LABEL_INSET); + let side = common::rect_width(&text).min(common::rect_height(&text)); + if side <= 0.0 { + return 0.0; + } + let mut through = 0.0; + for c in connectors { + let factor = match c.kind { // A link into or out of the labeled node at least points at (or // leaves from) that name, the way a modeler draws an arrow to a // variable, so it strikes the name out half as badly as a line // passing through on its way somewhere else. - let factor = if c.incident_uids.contains(owner) { - OWN_LINK_STRIKE_FACTOR - } else { - 1.0 - }; - for seg in c.polyline.windows(2) { - if let Some((t0, t1)) = segment_clip_interval_in_rect(&seg[0], &seg[1], &text) { - let seg_len = - ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)).sqrt(); - through += factor * (t1 - t0) * seg_len; - } + ConnectorKind::Link if c.incident_uids.contains(&owner) => OWN_LINK_STRIKE_FACTOR, + ConnectorKind::Link => 1.0, + // A flow's name sits beside its own pipe. Every other pipe through + // a name -- one entering the named stock through the face the name + // sits on included -- writes over it. + ConnectorKind::Pipe if c.flow_uid == Some(owner) => continue, + ConnectorKind::Pipe => 1.0, + }; + for seg in c.polyline.windows(2) { + if let Some((t0, t1)) = segment_clip_interval_in_rect(&seg[0], &seg[1], &text) { + let seg_len = + ((seg[1].x - seg[0].x).powi(2) + (seg[1].y - seg[0].y).powi(2)).sqrt(); + through += factor * (t1 - t0) * seg_len; } } - let fraction = (through / side).min(1.0); - if fraction > 0.0 { - sink.push(DefectKind::LabelCrossed, *lbl, fraction); + } + (through / side).min(1.0) +} + +/// The drawn scene of a view, for a label-side chooser that must charge a +/// candidate label box as the metric would. Shapes and connectors stay put +/// while sides are chosen; the other labels' boxes are whatever the chooser +/// has picked so far, so they are supplied per call. +pub(crate) struct LabelScene { + nodes: Vec, + connectors: Vec, + /// `labels / nodes`: converts a per-node crowding deficit into the same + /// per-label units the label terms are charged in. + crowding_scale: f64, +} + +impl LabelScene { + pub(crate) fn new(elements: &[ViewElement]) -> Self { + let nodes = build_scene_nodes(elements); + let labels = nodes.iter().filter(|n| n.label.is_some()).count(); + let crowding_scale = if nodes.is_empty() { + 0.0 + } else { + labels as f64 / nodes.len() as f64 + }; + LabelScene { + connectors: collect_connector_geometry(elements), + nodes, + crowding_scale, } - total += fraction; } - total / labels.len() as f64 + + /// What the metric charges node `owner` for wearing the label box `lbl`, + /// in per-label units: `w.label_overlap` times the fraction of the box + /// covered by other nodes' shapes and labels, `w.label_connector_overlap` + /// times its strike fraction, and `w.crowding` times the clearance deficit + /// of every pair `owner` forms with another node. `label_of(uid)` is + /// another node's current label box. The part of the cost that does not + /// depend on `lbl` is the same for every side, so only differences + /// between sides mean anything. + pub(crate) fn label_cost( + &self, + owner: i32, + lbl: &Rect, + label_of: impl Fn(i32) -> Option, + w: &MetricWeights, + ) -> f64 { + let area = rect_area(lbl); + if area <= 0.0 { + return 0.0; + } + let Some(own) = self.nodes.iter().find(|n| n.uid == owner) else { + return 0.0; + }; + let mut covered = 0.0; + let mut crowding = 0.0; + for other in self.nodes.iter().filter(|n| n.uid != owner) { + let other_label = label_of(other.uid); + covered += rect_overlap_area(lbl, &other.shape); + if let Some(ol) = &other_label { + covered += rect_overlap_area(lbl, ol); + } + if own.is_cloud || other.is_cloud { + continue; + } + let (gap, _) = footprint_gap(own, Some(*lbl), other, other_label); + if gap < COMFORTABLE_CLEARANCE { + crowding += (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + } + } + w.label_overlap * covered.min(area) / area + + w.label_connector_overlap * label_strike_fraction(owner, lbl, &self.connectors) + + w.crowding * self.crowding_scale * crowding + } } /// `crossings`: crossings per connector, on the drawn polylines. @@ -950,13 +1040,6 @@ fn crowding_term( } else { short / links.len() as f64 }; - // (is_label, rect) for each node's shape and label. - let footprint = |n: &SceneNode| -> Vec<(bool, Rect)> { - let mut rects = vec![(false, n.shape)]; - rects.extend(n.label.map(|l| (true, l))); - rects - }; - let footprints: Vec> = nodes.iter().map(footprint).collect(); let boxes: Vec = nodes.iter().map(SceneNode::footprint_box).collect(); let mut total = 0.0; for i in 0..nodes.len() { @@ -968,24 +1051,8 @@ fn crowding_term( if rect_gap(&boxes[i], &boxes[j]) >= COMFORTABLE_CLEARANCE { continue; } - // A flow's valve sits a fixed short pipe away from the stock or - // cloud it attaches to by construction: their SHAPES being close is - // structure, but either one's label crowding the other is not. - let attached = nodes[i].attached_to(&nodes[j]); - let mut gap = f64::INFINITY; - let mut closest = (boxes[i], boxes[j]); - for &(a_is_label, a) in &footprints[i] { - for &(b_is_label, b) in &footprints[j] { - if attached && !a_is_label && !b_is_label { - continue; - } - let g = rect_gap(&a, &b); - if g < gap { - gap = g; - closest = (a, b); - } - } - } + let (gap, closest) = + footprint_gap(&nodes[i], nodes[i].label, &nodes[j], nodes[j].label); if gap < COMFORTABLE_CLEARANCE { let deficit = (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); total += deficit; @@ -1000,6 +1067,37 @@ fn crowding_term( total / nodes.len() as f64 + short_rate } +/// The clearance between two nodes' footprints -- each one's shape and its +/// label box `*_label` -- as `crowding` measures it, with the two rects that +/// realize it. A flow's valve sits a fixed short pipe away from the stock or +/// cloud it attaches to by construction: their SHAPES being close is structure, +/// but either one's label crowding the other is not. +fn footprint_gap( + a: &SceneNode, + a_label: Option, + b: &SceneNode, + b_label: Option, +) -> (f64, (Rect, Rect)) { + let attached = a.attached_to(b); + let a_rects = [Some((false, a.shape)), a_label.map(|l| (true, l))]; + let b_rects = [Some((false, b.shape)), b_label.map(|l| (true, l))]; + let mut gap = f64::INFINITY; + let mut closest = (a.shape, b.shape); + for &(a_is_label, ra) in a_rects.iter().flatten() { + for &(b_is_label, rb) in b_rects.iter().flatten() { + if attached && !a_is_label && !b_is_label { + continue; + } + let g = rect_gap(&ra, &rb); + if g < gap { + gap = g; + closest = (ra, rb); + } + } + } + (gap, closest) +} + /// `long_connectors`: mean excess of each link over `LONG_CONNECTOR_FACTOR` /// times the median link length. fn long_connectors_term(connectors: &[ConnectorGeometry], sink: &mut DefectSink) -> f64 { @@ -1088,8 +1186,8 @@ pub fn analyze_layout(view: &datamodel::StockFlow) -> LayoutAnalysis { } fn analyze(view: &datamodel::StockFlow, sink: &mut DefectSink) -> LayoutMetrics { - let nodes = build_scene_nodes(view); - let connectors = collect_connector_geometry(view); + let nodes = build_scene_nodes(&view.elements); + let connectors = collect_connector_geometry(&view.elements); let node_overlap = node_overlap_term(&nodes, sink); let node_connector_overlap = node_connector_overlap_term(&nodes, &connectors, sink); diff --git a/src/simlin-engine/src/layout/metrics_tests.rs b/src/simlin-engine/src/layout/metrics_tests.rs index 132b06322..1ab8d5350 100644 --- a/src/simlin-engine/src/layout/metrics_tests.rs +++ b/src/simlin-engine/src/layout/metrics_tests.rs @@ -177,10 +177,11 @@ fn test_flow_valve_is_scored_at_its_drawn_radius() { } #[test] -fn test_pipe_through_a_label_obscures_it_but_own_pipe_does_not() { +fn test_pipe_through_a_label_strikes_it_but_own_pipe_does_not() { // A horizontal pipe (flow #1, valve far to the right) runs straight through - // aux #2's Bottom label. That pipe obscures the label; the flow's own label - // is never charged against its own pipe. + // aux #2's Bottom label: a line through the name, struck out exactly as a + // link through it would be, not a thin band of covered area. The flow's own + // label is never charged against its own pipe. let a = aux(2, "a fairly long name", 200.0, 100.0); let lbl = label_box(&a); let pipe_y = (lbl.top + lbl.bottom) / 2.0; @@ -194,14 +195,48 @@ fn test_pipe_through_a_label_obscures_it_but_own_pipe_does_not() { a, ]); let m = compute_layout_metrics(&view, &cfg()); - let covered = (2.0 * PIPE_HALF_WIDTH) / common::rect_height(&lbl); - // Two labels in the view: the aux's (covered by the pipe band) and the - // flow's own (clear of anything). + // Two labels in the view: the aux's (fully struck: the run through the + // text far exceeds its height) and the flow's own (clear of anything). assert!( - close(m.label_overlap, covered / 2.0), - "label_overlap {} expected {}", - m.label_overlap, - covered / 2.0 + close(m.label_connector_overlap, 1.0 / 2.0), + "label_connector_overlap {}", + m.label_connector_overlap + ); + assert_eq!( + m.label_overlap, 0.0, + "a pipe strikes a name; it covers no area" + ); +} + +#[test] +fn test_a_pipe_into_a_stock_through_the_stocks_name_strikes_it() { + // An inflow arrives from above into stock #1 whose name sits on top: the + // pipe runs down through the name. Unlike a node's own link, which at + // least points at the name, a pipe along the face's normal simply writes + // over it, so it counts in full. Two labels -> a rate of 1/2. + let s = ViewElement::Stock(view_element::Stock { + name: "a long stock name".to_string(), + uid: 1, + x: 200.0, + y: 200.0, + label_side: LabelSide::Top, + compat: None, + }); + let top = 200.0 - crate::diagram::constants::STOCK_HEIGHT / 2.0; + let view = make_view(vec![ + s, + flow_with_points( + 2, + "f", + (500.0, 60.0), + vec![(200.0, 0.0, None), (200.0, top, Some(1))], + ), + ]); + let m = compute_layout_metrics(&view, &cfg()); + assert!( + close(m.label_connector_overlap, 1.0 / 2.0), + "label_connector_overlap {}", + m.label_connector_overlap ); } @@ -417,7 +452,7 @@ fn test_node_connector_overlap_through_third_node() { straight_link(10, 1, 2), ]); let m = compute_layout_metrics(&view, &cfg()); - let connectors = collect_connector_geometry(&view); + let connectors = collect_connector_geometry(&view.elements); assert_eq!(connectors.len(), 1); let c = &connectors[0]; let stock_box = node_shape_box(&stock(3, "s", 200.0, 0.0)).unwrap(); @@ -519,7 +554,7 @@ fn test_node_connector_overlap_union_of_overlapping_boxes() { straight_link(10, 1, 2), ]); let m = compute_layout_metrics(&view, &cfg()); - let connectors = collect_connector_geometry(&view); + let connectors = collect_connector_geometry(&view.elements); let c = &connectors[0]; let boxes = [node_shape_box(&s3).unwrap(), node_shape_box(&s4).unwrap()]; let union_len: f64 = c @@ -730,7 +765,7 @@ fn test_a_link_too_short_to_show_its_arrow_is_crowding() { straight_link(10, 1, 2), ]); let m = compute_layout_metrics(&view, &cfg()); - let connectors = collect_connector_geometry(&view); + let connectors = collect_connector_geometry(&view.elements); let visible = connectors[0].length; assert!( visible < MIN_VISIBLE_LINK, @@ -860,7 +895,7 @@ fn test_aspect_penalty_thin_box_positive() { let view = make_view(vec![aux(1, "a", 0.0, 0.0), aux(2, "b", 0.0, 1000.0)]); let m = compute_layout_metrics(&view, &cfg()); assert!(m.aspect_penalty > 0.0); - let boxes: Vec = build_scene_nodes(&view) + let boxes: Vec = build_scene_nodes(&view.elements) .iter() .map(SceneNode::footprint_box) .collect(); From dd7e3434d1528d44009ff93fc4045c8f371825c1 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 00:30:57 -0700 Subject: [PATCH 06/16] engine: move incremental layout into its own module layout/mod.rs had grown to 5925 lines, 75 under the project's file-length lint, and the incremental path is about to grow. incremental_layout and the helpers only it uses (new-element placement and settling, flow-face reclassification, connector and cloud diffing) move verbatim into layout/incremental.rs; the public ones are re-exported from layout so every caller's path is unchanged. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/incremental.rs | 1970 ++++++++++++++++ src/simlin-engine/src/layout/mod.rs | 2355 ++----------------- 2 files changed, 2171 insertions(+), 2154 deletions(-) create mode 100644 src/simlin-engine/src/layout/incremental.rs diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs new file mode 100644 index 000000000..85ca7091c --- /dev/null +++ b/src/simlin-engine/src/layout/incremental.rs @@ -0,0 +1,1970 @@ +// 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::*; + +/// 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(), + } +} + +/// 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 named element still standing at this point survived the patch + // untouched (or was merely renamed): its label side is pinned for the rest + // of the pass. Whatever gets created from here on -- new variables, + // kind-changed or endpoint-changed rebuilds, orientation-flipped flows -- + // is absent from this snapshot and has its side chosen by + // `optimize_labels_for` below. Offset-only rebuilt flows are added back + // explicitly because they were just deleted but keep their orientation. + let mut pinned_label_sides: HashMap = state + .elements + .iter() + .filter_map(|elem| match elem { + ViewElement::Stock(s) => Some((s.uid, s.label_side)), + ViewElement::Flow(f) => Some((f.uid, f.label_side)), + ViewElement::Aux(a) => Some((a.uid, a.label_side)), + ViewElement::Module(m) => Some((m.uid, m.label_side)), + _ => None, + }) + .collect(); + for (flow_ident, side) in &offset_rebuilt_label_sides { + if let Some(uid) = state.uid_manager.get_uid(flow_ident) { + pinned_label_sides.insert(uid, *side); + } + } + + // Compute positions for rebuilt flows based on their attachment info, + // falling back to the old position if the stock UID lookup fails. + for flow_ident in &flows_to_rebuild { + let pos = attachment_based_flow_position( + &state, + &config, + &metadata, + flow_ident, + &incr_flow_attachments, + ) + .or_else(|| old_flow_positions.get(flow_ident).copied()); + if let Some(pos) = pos { + let uid = state.get_or_alloc_uid(flow_ident); + create_flow_view_element( + &mut state, + &config, + &metadata, + flow_ident, + uid, + pos, + &incr_flow_attachments, + )?; + } + } + + restore_label_sides(&mut state, &pinned_label_sides); + let needs_label_placement = |uid: i32| !pinned_label_sides.contains_key(&uid); + + if new_elements.is_empty() { + // No new elements and no settlement step, so rebuilt flows + // already have correct geometry from create_flow_view_element. + // Skip resnap entirely to avoid rewriting unrelated manual or + // imported flow endpoints elsewhere in the diagram. + diff_connectors(&mut state, &metadata); + diff_clouds(&mut state, &metadata); + optimize_labels_for(&mut state, model, &metadata, needs_label_placement); + apply_loop_curvature(&mut state, &config, model, &metadata); + validate_view_completeness(&state, model)?; + return Ok(build_stock_flow_from_state(state, old_view)); + } + + let initial_positions = compute_new_element_positions(&state, &metadata, &new_elements); + + // Step 5: Create view elements for new variables and insert their + // initial positions into state so settlement can find them. + for stock_ident in &new_elements.new_stocks { + if let Some(&pos) = initial_positions.get(stock_ident) { + let uid = state.get_or_alloc_uid(stock_ident); + let name = state.display_name(stock_ident); + let formatted = format_label_with_line_breaks(&name); + state.elements.push(ViewElement::Stock(view_element::Stock { + name: formatted, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + compat: None, + })); + state.positions.insert(uid, pos); + } + } + + for flow_ident in &new_elements.new_flows { + // For stock-to-stock (chain) flows, use the generic seed position + // which places the valve between the two stocks. For cloud flows + // (one unattached end), use attachment-based position so top/bottom + // flows get their valve on the correct vertical pipe. + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + let is_stock_to_stock = from_stock.is_some() && to_stock.is_some(); + let pos = if is_stock_to_stock { + initial_positions.get(flow_ident).copied() + } else { + attachment_based_flow_position( + &state, + &config, + &metadata, + flow_ident, + &incr_flow_attachments, + ) + .or_else(|| initial_positions.get(flow_ident).copied()) + }; + if let Some(pos) = pos { + let uid = state.get_or_alloc_uid(flow_ident); + create_flow_view_element( + &mut state, + &config, + &metadata, + flow_ident, + uid, + pos, + &incr_flow_attachments, + )?; + } + } + + // create_flow_view_element calls build_clouds_for_flow which pushes Cloud elements into + // state.elements but does not add their positions to state.positions. Record those + // positions now so that settle_new_elements can seed proper initial positions for cloud + // nodes in SFDP and later update them after settling. + for elem in &state.elements { + if let ViewElement::Cloud(c) = elem { + state + .positions + .entry(c.uid) + .or_insert_with(|| Position::new(c.x, c.y)); + } + } + + for aux_ident in &new_elements.new_auxes { + if let Some(&pos) = initial_positions.get(aux_ident) { + let uid = state.get_or_alloc_uid(aux_ident); + let name = state.display_name(aux_ident); + let formatted = format_label_with_line_breaks(&name); + state.elements.push(ViewElement::Aux(view_element::Aux { + name: formatted, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + compat: None, + })); + state.positions.insert(uid, pos); + } + } + + for module_ident in &new_elements.new_modules { + if let Some(&pos) = initial_positions.get(module_ident) { + let uid = state.get_or_alloc_uid(module_ident); + let name = state.display_name(module_ident); + let formatted = format_label_with_line_breaks(&name); + state + .elements + .push(ViewElement::Module(view_element::Module { + name: formatted, + uid, + x: pos.x, + y: pos.y, + label_side: LabelSide::Bottom, + })); + state.positions.insert(uid, pos); + } + } + + // Step 6: Settle new elements with existing elements pinned + let chains_data: Vec<_> = metadata + .chains + .iter() + .map(|c| (c.stocks.clone(), c.flows.clone(), c.all_vars.clone())) + .collect(); + settle_new_elements( + &mut state, + &config, + model, + &metadata, + &new_elements, + &chains_data, + )?; + + // Update view element coordinates from settled positions + for elem in &mut state.elements { + let uid = elem.get_uid(); + if let Some(&pos) = state.positions.get(&uid) { + match elem { + ViewElement::Stock(s) => { + s.x = pos.x; + s.y = pos.y; + } + ViewElement::Flow(f) => { + let dx = pos.x - f.x; + let dy = pos.y - f.y; + f.x = pos.x; + f.y = pos.y; + for pt in &mut f.points { + pt.x += dx; + pt.y += dy; + } + } + ViewElement::Aux(a) => { + a.x = pos.x; + a.y = pos.y; + } + ViewElement::Module(m) => { + m.x = pos.x; + m.y = pos.y; + } + ViewElement::Cloud(c) => { + c.x = pos.x; + c.y = pos.y; + } + _ => {} + } + } + } + + resnap_flow_endpoints(&mut state, &config); + + // Step 7: Diff connectors and clouds + diff_connectors(&mut state, &metadata); + diff_clouds(&mut state, &metadata); + + // Step 8: Polish. Only elements created in this pass get a label side + // chosen; pinned elements keep theirs even if a new connector now runs + // through the label (hand placement wins; the human can move it). + optimize_labels_for(&mut state, model, &metadata, needs_label_placement); + apply_loop_curvature(&mut state, &config, model, &metadata); + // Guarantee flows stay orthogonal after re-snapping endpoints to moved + // stocks (only rewrites pipes that actually went diagonal; hand-routed + // orthogonal flows are left untouched). + orthogonal::orthogonalize_flow_pipes(&mut state.elements); + + validate_view_completeness(&state, model)?; + + // Step 9: Build StockFlow + Ok(build_stock_flow_from_state(state, old_view)) +} diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index edb7f01c4..c7d201c33 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -13,6 +13,7 @@ mod detect_ltm_loops; #[cfg(any(test, feature = "layout_eval"))] pub mod eval_stats; pub mod graph; +mod incremental; pub mod metadata; pub mod metrics; mod objective; @@ -43,6 +44,12 @@ use self::connector::{ }; use self::detect_ltm_loops::try_detect_ltm_loops; use self::graph::{ConstrainedGraphBuilder, Graph, GraphBuilder, Layout, Position}; +#[cfg(test)] +use self::incremental::{build_stock_flow_from_state, existing_bounding_box}; +pub use self::incremental::{ + compute_new_element_positions, diff_clouds, diff_connectors, incremental_layout, + resnap_flow_endpoints, settle_new_elements, +}; use self::metadata::{ComputedMetadata, StockFlowChain}; use self::objective::{ point_node_footprint_overlap, point_node_footprints, point_node_pileup_count, @@ -440,1359 +447,227 @@ impl NewElements { } } -/// Compute initial positions for newly-added elements based on their -/// dependency connections to existing elements. +/// Classify which stock face each flow attaches to and where along it. /// -/// 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, +/// Chain flows (stock-to-stock) run along the chain: outflows leave the right +/// face, inflows enter the left face, and several on one face are spread with +/// the `(i+1)/(n+1)` formula (matching the TS editor's `computeFlowOffsets`) -- +/// their valves sit between different stock pairs, so they do not collide. +/// +/// Side flows (to or from a cloud) each get a face of their OWN whenever one is +/// free. A face is only 35-45px long and a valve is drawn 18px across, so two +/// side flows sharing a face at 1/3 and 2/3 put their valves, clouds, and names +/// on top of each other. Outflows prefer right, then bottom, then top; inflows +/// prefer left, then top, then bottom -- the way modelers draw births in from +/// the left and deaths out to the right, with a second outflow dropping below. +/// A face holding a chain flow is never offered to a side flow, and only when +/// every remaining allowed face is taken do side flows share one. +/// +/// `existing` gives the faces of side flows already drawn on this stock (the +/// incremental path): each keeps its face -- shared or not, hand-placed or +/// not -- unless it sits on a chain face, or its preferred face has come free +/// (the chain flow that pushed it below was deleted). Adding a sibling +/// therefore never moves a flow to another face. +/// +/// Returns a map from flow ident to its attachment info for all flows +/// connected to this stock. +fn classify_flow_sides( + stock_ident: &str, metadata: &ComputedMetadata, - new_elements: &NewElements, -) -> HashMap { - let mut result: HashMap = HashMap::new(); + existing: &HashMap, +) -> HashMap { + let mut result = 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(); + let outflows: Vec = metadata + .stock_to_outflows + .get(stock_ident) + .cloned() + .unwrap_or_default(); + let inflows: Vec = metadata + .stock_to_inflows + .get(stock_ident) + .cloned() + .unwrap_or_default(); - // Compute bounding box of all existing positioned elements for periphery placement - let (bbox_min, bbox_max) = existing_bounding_box(state); + // Classify outflows: chain (stock-to-stock) vs side (stock-to-cloud) + let mut chain_outflows = Vec::new(); + let mut side_outflows = Vec::new(); + for flow in &outflows { + let (_, to_stock) = metadata.connected_stocks(flow); + if to_stock.is_some() { + chain_outflows.push(flow.clone()); + } else { + side_outflows.push(flow.clone()); + } + } - // 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, - ); + // Classify inflows: chain (stock-to-stock) vs side (cloud-to-stock) + let mut chain_inflows = Vec::new(); + let mut side_inflows = Vec::new(); + for flow in &inflows { + let (from_stock, _) = metadata.connected_stocks(flow); + if from_stock.is_some() { + chain_inflows.push(flow.clone()); + } else { + side_inflows.push(flow.clone()); + } + } - // Place new stocks and flows (chain elements) - place_new_chain_elements( - state, - metadata, - new_elements, - &new_set, - &bbox_max, - &mut result, - ); + // A face holding a chain flow belongs to the chain: a side flow there would + // run its pipe along the chain's, so side flows never take one. + let mut chain_faces: HashSet = HashSet::new(); + let mut faces: HashMap> = HashMap::new(); + if !chain_outflows.is_empty() { + chain_faces.insert(StockAttachSide::Right); + faces.insert(StockAttachSide::Right, chain_outflows); + } + if !chain_inflows.is_empty() { + chain_faces.insert(StockAttachSide::Left); + faces.insert(StockAttachSide::Left, chain_inflows); + } - result -} + const OUTFLOW_FACES: [StockAttachSide; 3] = [ + StockAttachSide::Right, + StockAttachSide::Bottom, + StockAttachSide::Top, + ]; + const INFLOW_FACES: [StockAttachSide; 3] = [ + StockAttachSide::Left, + StockAttachSide::Top, + StockAttachSide::Bottom, + ]; -/// Bounding box of variable elements (stocks, flows, auxes, modules) only. -/// Excludes aliases, groups, and clouds so that outlier non-variable elements -/// don't push new variable placement far from the actual model graph. -/// Returns ((min_x, min_y), (max_x, max_y)). -/// When no variable elements exist, returns a default origin area. -fn existing_bounding_box(state: &LayoutState) -> (Position, Position) { - let variable_uids: HashSet = state - .elements - .iter() - .filter(|e| { - matches!( - e, - ViewElement::Stock(_) - | ViewElement::Flow(_) - | ViewElement::Aux(_) - | ViewElement::Module(_) - ) - }) - .map(|e| e.get_uid()) + side_outflows.sort(); + side_inflows.sort(); + let side_flows: Vec<(String, &[StockAttachSide; 3])> = side_outflows + .into_iter() + .map(|f| (f, &OUTFLOW_FACES)) + .chain(side_inflows.into_iter().map(|f| (f, &INFLOW_FACES))) .collect(); + let load = |faces: &HashMap>, side: StockAttachSide| { + faces.get(&side).map_or(0, Vec::len) + }; - 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; + // Already-drawn side flows keep their faces. Pass one seats the flows + // already on their preferred face; pass two moves a flow off a secondary + // face only onto its preferred face, and only if that has come free (the + // chain that pushed it below was deleted); a flow sitting on a chain face + // is re-placed like a new one. + let mut pending: Vec<(String, &[StockAttachSide; 3])> = Vec::new(); + let mut secondary: Vec<(String, &[StockAttachSide; 3], StockAttachSide)> = Vec::new(); + for (flow, allowed) in side_flows { + match existing.get(&flow) { + Some(&side) if chain_faces.contains(&side) => pending.push((flow, allowed)), + Some(&side) if side == allowed[0] => faces.entry(side).or_default().push(flow), + Some(&side) => secondary.push((flow, allowed, side)), + None => pending.push((flow, allowed)), } - 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), - ); + for (flow, allowed, side) in secondary { + let preferred_free = !chain_faces.contains(&allowed[0]) && load(&faces, allowed[0]) == 0; + let target = if preferred_free { allowed[0] } else { side }; + faces.entry(target).or_default().push(flow); } - (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)); - } - } + // New side flows take the least-loaded allowed face, in preference order. + for (flow, allowed) in pending { + let side = *allowed + .iter() + .enumerate() + .filter(|(_, side)| !chain_faces.contains(*side)) + .min_by_key(|(rank, side)| (load(&faces, **side), *rank)) + .map(|(_, side)| side) + .expect("a flow's allowed faces include two that no chain can hold"); + faces.entry(side).or_default().push(flow); } - // 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)); - } + // Distribute flows within each face using (i+1)/(n+1) + for (side, mut flows) in faces { + flows.sort(); // deterministic ordering by ident + let n = flows.len(); + for (i, flow) in flows.into_iter().enumerate() { + let offset = if n == 1 { + 0.5 + } else { + (i as f64 + 1.0) / (n as f64 + 1.0) + }; + result.insert(flow, FlowAttachment { side, offset }); } } - pairs + result } -/// 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) +/// Pick a starting stock for chain layout. Returns the stock with the +/// highest flow connectivity (inflows + outflows), breaking ties +/// alphabetically for determinism. +fn pick_starting_stock<'b>(metadata: &ComputedMetadata, stocks: &'b [String]) -> Option<&'b str> { + stocks + .iter() + .max_by(|a, b| { + let a_count = metadata + .stock_to_inflows + .get(a.as_str()) + .map_or(0, |v| v.len()) + + metadata + .stock_to_outflows + .get(a.as_str()) + .map_or(0, |v| v.len()); + let b_count = metadata + .stock_to_inflows + .get(b.as_str()) + .map_or(0, |v| v.len()) + + metadata + .stock_to_outflows + .get(b.as_str()) + .map_or(0, |v| v.len()); + a_count.cmp(&b_count).then_with(|| b.cmp(a)) + }) + .map(|s| s.as_str()) } -/// 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, +/// Add cloud elements for flow endpoints that don't connect to a stock. +fn build_clouds_for_flow( + state: &mut LayoutState, metadata: &ComputedMetadata, - new_idents: &[String], - new_set: &HashSet<&str>, - bbox_min: &Position, - bbox_max: &Position, - result: &mut HashMap, + flow_ident: &str, + flow_elem: &mut view_element::Flow, ) { - 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(); + let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); + let has_from = from_stock.is_some(); + let has_to = to_stock.is_some(); - connection_groups - .entry(uid_key) - .or_default() - .push(ident.clone()); + // Source cloud (no from stock) + if !has_from && !flow_elem.points.is_empty() { + let cx = flow_elem.points[0].x; + let cy = flow_elem.points[0].y; + let cloud_uid = state.uid_manager.alloc(""); + let cloud = ViewElement::Cloud(view_element::Cloud { + uid: cloud_uid, + flow_uid: flow_elem.uid, + x: cx, + y: cy, + compat: None, + }); + state.elements.push(cloud); + flow_elem.points[0].attached_to_uid = Some(cloud_uid); } - // 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); - } - } - } -} - -/// Classify which stock face each flow attaches to and where along it. -/// -/// Chain flows (stock-to-stock) run along the chain: outflows leave the right -/// face, inflows enter the left face, and several on one face are spread with -/// the `(i+1)/(n+1)` formula (matching the TS editor's `computeFlowOffsets`) -- -/// their valves sit between different stock pairs, so they do not collide. -/// -/// Side flows (to or from a cloud) each get a face of their OWN whenever one is -/// free. A face is only 35-45px long and a valve is drawn 18px across, so two -/// side flows sharing a face at 1/3 and 2/3 put their valves, clouds, and names -/// on top of each other. Outflows prefer right, then bottom, then top; inflows -/// prefer left, then top, then bottom -- the way modelers draw births in from -/// the left and deaths out to the right, with a second outflow dropping below. -/// A face holding a chain flow is never offered to a side flow, and only when -/// every remaining allowed face is taken do side flows share one. -/// -/// `existing` gives the faces of side flows already drawn on this stock (the -/// incremental path): each keeps its face -- shared or not, hand-placed or -/// not -- unless it sits on a chain face, or its preferred face has come free -/// (the chain flow that pushed it below was deleted). Adding a sibling -/// therefore never moves a flow to another face. -/// -/// Returns a map from flow ident to its attachment info for all flows -/// connected to this stock. -fn classify_flow_sides( - stock_ident: &str, - metadata: &ComputedMetadata, - existing: &HashMap, -) -> HashMap { - let mut result = HashMap::new(); - - let outflows: Vec = metadata - .stock_to_outflows - .get(stock_ident) - .cloned() - .unwrap_or_default(); - let inflows: Vec = metadata - .stock_to_inflows - .get(stock_ident) - .cloned() - .unwrap_or_default(); - - // Classify outflows: chain (stock-to-stock) vs side (stock-to-cloud) - let mut chain_outflows = Vec::new(); - let mut side_outflows = Vec::new(); - for flow in &outflows { - let (_, to_stock) = metadata.connected_stocks(flow); - if to_stock.is_some() { - chain_outflows.push(flow.clone()); - } else { - side_outflows.push(flow.clone()); - } - } - - // Classify inflows: chain (stock-to-stock) vs side (cloud-to-stock) - let mut chain_inflows = Vec::new(); - let mut side_inflows = Vec::new(); - for flow in &inflows { - let (from_stock, _) = metadata.connected_stocks(flow); - if from_stock.is_some() { - chain_inflows.push(flow.clone()); - } else { - side_inflows.push(flow.clone()); - } - } - - // A face holding a chain flow belongs to the chain: a side flow there would - // run its pipe along the chain's, so side flows never take one. - let mut chain_faces: HashSet = HashSet::new(); - let mut faces: HashMap> = HashMap::new(); - if !chain_outflows.is_empty() { - chain_faces.insert(StockAttachSide::Right); - faces.insert(StockAttachSide::Right, chain_outflows); - } - if !chain_inflows.is_empty() { - chain_faces.insert(StockAttachSide::Left); - faces.insert(StockAttachSide::Left, chain_inflows); - } - - const OUTFLOW_FACES: [StockAttachSide; 3] = [ - StockAttachSide::Right, - StockAttachSide::Bottom, - StockAttachSide::Top, - ]; - const INFLOW_FACES: [StockAttachSide; 3] = [ - StockAttachSide::Left, - StockAttachSide::Top, - StockAttachSide::Bottom, - ]; - - side_outflows.sort(); - side_inflows.sort(); - let side_flows: Vec<(String, &[StockAttachSide; 3])> = side_outflows - .into_iter() - .map(|f| (f, &OUTFLOW_FACES)) - .chain(side_inflows.into_iter().map(|f| (f, &INFLOW_FACES))) - .collect(); - let load = |faces: &HashMap>, side: StockAttachSide| { - faces.get(&side).map_or(0, Vec::len) - }; - - // Already-drawn side flows keep their faces. Pass one seats the flows - // already on their preferred face; pass two moves a flow off a secondary - // face only onto its preferred face, and only if that has come free (the - // chain that pushed it below was deleted); a flow sitting on a chain face - // is re-placed like a new one. - let mut pending: Vec<(String, &[StockAttachSide; 3])> = Vec::new(); - let mut secondary: Vec<(String, &[StockAttachSide; 3], StockAttachSide)> = Vec::new(); - for (flow, allowed) in side_flows { - match existing.get(&flow) { - Some(&side) if chain_faces.contains(&side) => pending.push((flow, allowed)), - Some(&side) if side == allowed[0] => faces.entry(side).or_default().push(flow), - Some(&side) => secondary.push((flow, allowed, side)), - None => pending.push((flow, allowed)), - } - } - for (flow, allowed, side) in secondary { - let preferred_free = !chain_faces.contains(&allowed[0]) && load(&faces, allowed[0]) == 0; - let target = if preferred_free { allowed[0] } else { side }; - faces.entry(target).or_default().push(flow); - } - - // New side flows take the least-loaded allowed face, in preference order. - for (flow, allowed) in pending { - let side = *allowed - .iter() - .enumerate() - .filter(|(_, side)| !chain_faces.contains(*side)) - .min_by_key(|(rank, side)| (load(&faces, **side), *rank)) - .map(|(_, side)| side) - .expect("a flow's allowed faces include two that no chain can hold"); - faces.entry(side).or_default().push(flow); - } - - // Distribute flows within each face using (i+1)/(n+1) - for (side, mut flows) in faces { - flows.sort(); // deterministic ordering by ident - let n = flows.len(); - for (i, flow) in flows.into_iter().enumerate() { - let offset = if n == 1 { - 0.5 - } else { - (i as f64 + 1.0) / (n as f64 + 1.0) - }; - result.insert(flow, FlowAttachment { side, offset }); - } - } - - result -} - -/// 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 -} - -/// Pick a starting stock for chain layout. Returns the stock with the -/// highest flow connectivity (inflows + outflows), breaking ties -/// alphabetically for determinism. -fn pick_starting_stock<'b>(metadata: &ComputedMetadata, stocks: &'b [String]) -> Option<&'b str> { - stocks - .iter() - .max_by(|a, b| { - let a_count = metadata - .stock_to_inflows - .get(a.as_str()) - .map_or(0, |v| v.len()) - + metadata - .stock_to_outflows - .get(a.as_str()) - .map_or(0, |v| v.len()); - let b_count = metadata - .stock_to_inflows - .get(b.as_str()) - .map_or(0, |v| v.len()) - + metadata - .stock_to_outflows - .get(b.as_str()) - .map_or(0, |v| v.len()); - a_count.cmp(&b_count).then_with(|| b.cmp(a)) - }) - .map(|s| s.as_str()) -} - -/// Add cloud elements for flow endpoints that don't connect to a stock. -fn build_clouds_for_flow( - state: &mut LayoutState, - metadata: &ComputedMetadata, - flow_ident: &str, - flow_elem: &mut view_element::Flow, -) { - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - let has_from = from_stock.is_some(); - let has_to = to_stock.is_some(); - - // Source cloud (no from stock) - if !has_from && !flow_elem.points.is_empty() { - let cx = flow_elem.points[0].x; - let cy = flow_elem.points[0].y; - let cloud_uid = state.uid_manager.alloc(""); - let cloud = ViewElement::Cloud(view_element::Cloud { - uid: cloud_uid, - flow_uid: flow_elem.uid, - x: cx, - y: cy, - compat: None, - }); - state.elements.push(cloud); - flow_elem.points[0].attached_to_uid = Some(cloud_uid); - } - - // Sink cloud (no to stock) - if !has_to && !flow_elem.points.is_empty() { - let last_idx = flow_elem.points.len() - 1; - let cx = flow_elem.points[last_idx].x; - let cy = flow_elem.points[last_idx].y; - let cloud_uid = state.uid_manager.alloc(""); - let cloud = ViewElement::Cloud(view_element::Cloud { - uid: cloud_uid, - flow_uid: flow_elem.uid, - x: cx, - y: cy, - compat: None, - }); - state.elements.push(cloud); - flow_elem.points[last_idx].attached_to_uid = Some(cloud_uid); + // Sink cloud (no to stock) + if !has_to && !flow_elem.points.is_empty() { + let last_idx = flow_elem.points.len() - 1; + let cx = flow_elem.points[last_idx].x; + let cy = flow_elem.points[last_idx].y; + let cloud_uid = state.uid_manager.alloc(""); + let cloud = ViewElement::Cloud(view_element::Cloud { + uid: cloud_uid, + flow_uid: flow_elem.uid, + x: cx, + y: cy, + compat: None, + }); + state.elements.push(cloud); + flow_elem.points[last_idx].attached_to_uid = Some(cloud_uid); } } @@ -1808,113 +683,7 @@ fn record_flow_template(state: &mut LayoutState, flow_ident: &str, flow_elem: &v .collect(); state .flow_templates - .insert(flow_ident.to_string(), FlowTemplate { offsets }); -} - -/// Re-sort flows on each affected stock's sides by their existing -/// attachment position rather than alphabetical ident. This preserves -/// the visual left-to-right (or top-to-bottom) ordering of imported or -/// manually-edited flows when a sibling is added or removed. -/// -/// Only affects flows that already have view elements in `state`; -/// new flows without positions are placed last (sorted by ident among -/// themselves). -fn reorder_attachments_by_position( - attachments: &mut HashMap, - state: &LayoutState, - affected_stocks: &HashSet, - metadata: &ComputedMetadata, -) { - for stock_ident in affected_stocks { - let stock_uid = match state.uid_manager.get_uid(stock_ident) { - Some(uid) => uid, - None => continue, - }; - - // Group flows on this stock by side, recording each flow's - // existing attachment position (x for Top/Bottom, y for Left/Right). - let mut by_side: HashMap> = HashMap::new(); - - for (flow_ident, att) in attachments.iter() { - let (from, to) = metadata.connected_stocks(flow_ident); - // Skip stock-to-stock flows: their attachment side depends on - // which stock classified them last, so including them would - // count them on the wrong side of one stock. - if from.is_some() && to.is_some() { - continue; - } - let connected = - from.is_some_and(|s| s == stock_ident) || to.is_some_and(|s| s == stock_ident); - if !connected { - continue; - } - - let pos_key = state - .uid_manager - .get_uid(flow_ident) - .and_then(|uid| { - state.elements.iter().find_map(|e| match e { - ViewElement::Flow(f) if f.uid == uid => f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| match att.side { - StockAttachSide::Bottom | StockAttachSide::Top => pt.x, - StockAttachSide::Left | StockAttachSide::Right => pt.y, - }), - _ => None, - }) - }) - .unwrap_or(f64::MAX); // new flows sort last - - by_side - .entry(att.side) - .or_default() - .push((flow_ident.clone(), pos_key)); - } - - // Re-sort each side group by position and reassign offsets - for flows in by_side.values_mut() { - if flows.len() <= 1 { - continue; - } - flows.sort_by(|a, b| { - a.1.partial_cmp(&b.1) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.cmp(&b.0)) - }); - let n = flows.len(); - for (i, (flow_ident, _)) in flows.iter().enumerate() { - let offset = if n == 1 { - 0.5 - } else { - (i as f64 + 1.0) / (n as f64 + 1.0) - }; - if let Some(att) = attachments.get_mut(flow_ident) { - att.offset = offset; - } - } - } - } -} - -/// Compute the valve position for a flow based on its attachment info and -/// connected stock position. Returns `None` if the flow has no attachment -/// or the stock position is unknown, in which case the caller should fall -/// back to `initial_positions`. -fn attachment_based_flow_position( - state: &LayoutState, - 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)) + .insert(flow_ident.to_string(), FlowTemplate { offsets }); } /// The valve position of a side flow attached to `stock_pos` at `attachment`: @@ -3703,25 +2472,6 @@ fn optimize_labels_for( } } -/// Write `sides` back onto the named elements that carry those UIDs. Used by -/// incremental layout after a flow is rebuilt with unchanged orientation -/// (`create_flow_view_element` picks a default side) to reinstate the side -/// the element had before the rebuild. -fn restore_label_sides(state: &mut LayoutState, sides: &HashMap) { - for elem in &mut state.elements { - let Some(&side) = sides.get(&elem.get_uid()) else { - continue; - }; - match elem { - ViewElement::Stock(s) => s.label_side = side, - ViewElement::Flow(f) => f.label_side = side, - ViewElement::Aux(a) => a.label_side = side, - ViewElement::Module(m) => m.label_side = side, - _ => {} - } - } -} - /// Apply arc curvature to connectors involved in feedback loops. fn apply_loop_curvature( state: &mut LayoutState, @@ -5052,38 +3802,6 @@ pub fn count_view_crossings(view: &datamodel::StockFlow) -> usize { annealing::count_crossings(&build_view_segments(view)) } -/// Assemble a [`datamodel::StockFlow`] from finalized layout state, copying -/// metadata (name, view box, zoom, font, sketch_compat) from `template`. -/// -/// The view box is copied verbatim, never recomputed from the elements: the -/// editor stores its viewport there (the pan offset and the canvas size it -/// was last shown at, `Canvas.tsx` `getCanvasOffset`), and an incremental -/// pass runs after every kernel/MCP edit of a diagram someone is looking -/// at -- re-boxing it to the content bounds snaps their pan back and, when -/// the size no longer matches the canvas, triggers the editor's -/// proportional refit, so the diagram jumps on every "Updated from Python". -/// Content that ends up outside the viewport is the editor's business (it -/// re-centres an offscreen diagram on mount). Only a from-scratch layout -/// synthesises a box. -fn build_stock_flow_from_state( - state: LayoutState, - template: &datamodel::StockFlow, -) -> datamodel::StockFlow { - datamodel::StockFlow { - name: template.name.clone(), - elements: state.elements, - view_box: template.view_box.clone(), - zoom: if template.zoom > 0.0 { - template.zoom - } else { - 1.0 - }, - use_lettered_polarity: template.use_lettered_polarity, - font: template.font.clone(), - sketch_compat: template.sketch_compat.clone(), - } -} - /// Seeds for parallel layout generation. Each seed produces a different SFDP /// layout; the one with fewest connector crossings is selected. /// @@ -5093,677 +3811,6 @@ fn build_stock_flow_from_state( /// so it is exposed publicly. The value and behavior are unchanged. pub const LAYOUT_SEEDS: [u64; 4] = [42, 123, 456, 789]; -/// Apply a model patch incrementally to an existing diagram view, -/// preserving existing element positions and only placing new or -/// modified elements. -/// -/// The `project` must already reflect the post-patch model state -/// (i.e., `apply_patch` has been called). The `patch` is taken by -/// reference so callers can inspect the operations. -/// -/// Contract for elements the patch did not touch: position AND -/// `label_side` are returned byte-for-byte. A label side is chosen only -/// for elements created in this pass -- new variables, kind-changed or -/// endpoint-changed rebuilds, and flows whose pipe orientation flipped. -/// A flow rebuilt merely to slide along the same stock face keeps its -/// side. The optimizer never revisits an existing side, even when a -/// connector added by this patch now crosses the label: hand placement -/// wins, and the human (or a full relayout) can move it. -/// -/// Composition: -/// 1. Compute metadata for the post-patch model -/// 2. Seed LayoutState from old view -/// 3. Process deletions and renames from the patch -/// 4. Identify new elements, compute initial positions -/// 5. Create view elements and settle via pinned SFDP -/// 6. Diff connectors/clouds, place labels for this pass's elements, -/// apply loop curvature -/// 7. Build StockFlow from final state -pub fn incremental_layout( - old_view: &datamodel::StockFlow, - project: &datamodel::Project, - model_name: &str, - patch: &crate::patch::ModelPatch, - db_state: Option<(&crate::db::SimlinDb, crate::db::SourceProject)>, -) -> Result { - if old_view.elements.is_empty() { - return generate_best_layout(project, model_name, db_state); - } - - // View-only patches (UpsertView/DeleteView) don't affect model variables, - // so the diagram should be returned unchanged. Without this guard, the - // diff_connectors and optimize_labels passes would rewrite connectors and - // labels even though nothing structurally changed. - let has_variable_ops = patch.ops.iter().any(|op| { - !matches!( - op, - crate::patch::ModelOperation::UpsertView { .. } - | crate::patch::ModelOperation::DeleteView { .. } - ) - }); - if !has_variable_ops { - return Ok(old_view.clone()); - } - - let config = LayoutConfig::default(); - - let not_found = || format!("model '{}' not found in project", model_name); - let model = project.get_model(model_name).ok_or_else(not_found)?; - let metadata = compute_metadata(project, model_name, db_state).ok_or_else(not_found)?; - - // Step 2: Seed state from old view - let mut state = LayoutState::from_existing_view(old_view, model); - - // Step 3: Process deletions and renames - for op in &patch.ops { - match op { - crate::patch::ModelOperation::DeleteVariable { ident } => { - state.apply_deletion(ident); - } - crate::patch::ModelOperation::RenameVariable { from, to } => { - let new_display = state - .display_names - .get(&canonicalize(to).into_owned()) - .cloned() - .unwrap_or_else(|| to.clone()); - state.apply_rename(from, to, &new_display); - } - _ => {} - } - } - - // Between steps 3 and 4a: detect variables whose type changed (e.g., Aux -> Stock). - // When a caller issues UpsertStock for a variable that was previously an Aux, there - // is no DeleteVariable in the patch and the old Aux element is still in state. - // identify_new_elements only checks for UID presence, not element type, so the - // stale element would survive. We detect type mismatches here and remove the - // old element so it is rebuilt with the correct type. - { - let kind_changed: Vec = model - .variables - .iter() - .filter_map(|var| { - let canonical = canonicalize(var.get_ident()).into_owned(); - let uid = state.uid_manager.get_uid(&canonical)?; - // Find the view element for this UID - let elem = state.elements.iter().find(|e| e.get_uid() == uid)?; - // Check for a type mismatch - let mismatch = !matches!( - (var, elem), - (datamodel::Variable::Stock(_), ViewElement::Stock(_)) - | (datamodel::Variable::Flow(_), ViewElement::Flow(_)) - | (datamodel::Variable::Aux(_), ViewElement::Aux(_)) - | (datamodel::Variable::Module(_), ViewElement::Module(_)) - ); - if mismatch { Some(canonical) } else { None } - }) - .collect(); - for ident in kind_changed { - // Save the display name before apply_deletion removes it from display_names, - // so the rebuilt element can recover the original casing (e.g. "Growth Rate" - // instead of "growth_rate"). - let saved_display = state.display_names.get(&ident).cloned(); - state.apply_deletion(&ident); - // Restore: use the saved original display name when available, otherwise - // fall back to the canonical ident so the entry is always present. - let display = saved_display.unwrap_or_else(|| ident.clone()); - state.display_names.insert(ident, display); - } - } - - // Between steps 3 and 4: detect flows whose stock connections changed. - // A flow element keeps its old attached_to_uid values when preserved in state, - // so a flow that moved from one stock to another would keep stale endpoints. - // Remove such flows (and their clouds) so identify_new_elements picks them - // up as new and they get rebuilt with correct endpoints. - // - // This also handles transitions between stock and cloud endpoints: if the - // model now expects a cloud source (from_stock == None) but the preserved - // flow's source point is still attached to a stock UID, the flow is stale. - { - let uid_to_ident: HashMap = model - .variables - .iter() - .filter_map(|var| { - let ident = canonicalize(var.get_ident()).into_owned(); - state.uid_manager.get_uid(&ident).map(|uid| (uid, ident)) - }) - .collect(); - - // Build the set of cloud UIDs so we can validate cloud-endpoint assignments. - // When a cloud is expected (expected_from/to == None), the flow endpoint must - // be either unattached or attached to a cloud. Checking against cloud_uids - // (rather than just "not in stock_uids") catches the case where a stock was - // kind-changed to an aux: the old UID is reused by the new non-stock element, - // so the flow must be rebuilt with a proper cloud endpoint. - let cloud_uids: HashSet = state - .elements - .iter() - .filter_map(|elem| match elem { - ViewElement::Cloud(c) => Some(c.uid), - _ => None, - }) - .collect(); - - let flows_to_reset: Vec = state - .elements - .iter() - .filter_map(|elem| { - let flow = match elem { - ViewElement::Flow(f) => f, - _ => return None, - }; - if flow.points.len() < 2 { - return None; - } - let flow_ident = uid_to_ident.get(&flow.uid)?; - let (expected_from, expected_to) = metadata.flow_to_stocks.get(flow_ident)?; - - let expected_from_uid = expected_from - .as_deref() - .and_then(|s| state.uid_manager.get_uid(s)); - let expected_to_uid = expected_to - .as_deref() - .and_then(|s| state.uid_manager.get_uid(s)); - - // Check the source endpoint (points[0]): - // - None expected (cloud): endpoint must be unattached or attached to a cloud - // - Some(uid) expected: the source must be attached to exactly that stock - let source_uid = flow.points[0].attached_to_uid; - let from_matches = match expected_from_uid { - None => { - source_uid.is_none() || source_uid.is_some_and(|u| cloud_uids.contains(&u)) - } - Some(uid) => source_uid == Some(uid), - }; - - // Check the sink endpoint (points[last]): - // - None expected (cloud): endpoint must be unattached or attached to a cloud - // - Some(uid) expected: the sink must be attached to exactly that stock - let last = flow.points.len() - 1; - let sink_uid = flow.points[last].attached_to_uid; - let to_matches = match expected_to_uid { - None => sink_uid.is_none() || sink_uid.is_some_and(|u| cloud_uids.contains(&u)), - Some(uid) => sink_uid == Some(uid), - }; - - if from_matches && to_matches { - None - } else { - Some(flow_ident.clone()) - } - }) - .collect(); - - for flow_ident in flows_to_reset { - // apply_deletion removes the element from state.elements but leaves - // the UID in uid_manager. identify_new_elements will see a UID with - // no corresponding element and classify the flow as new, causing - // create_flow_view_element to rebuild it with correct endpoints. - let canonical = canonicalize(&flow_ident).into_owned(); - // Save the display name before apply_deletion removes it so the - // rebuilt element recovers the original casing. - let saved_display = state.display_names.get(&canonical).cloned(); - state.apply_deletion(&flow_ident); - let display = saved_display.unwrap_or_else(|| flow_ident.clone()); - state.display_names.insert(canonical, display); - } - } - - // Step 4: Identify new elements and compute initial positions - let new_elements = state.identify_new_elements(model); - - // Compute flow attachments for flows on stocks that are affected by - // flow additions, deletions, or connection changes. This ensures - // preserved flows get reclassified when a sibling chain flow is - // added or removed. - let mut incr_flow_attachments: HashMap = HashMap::new(); - let mut affected_stocks: HashSet = HashSet::new(); - - for flow_ident in &new_elements.new_flows { - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - if let Some(stock) = from_stock { - affected_stocks.insert(stock.to_string()); - } - if let Some(stock) = to_stock { - affected_stocks.insert(stock.to_string()); - } - } - - // Also mark stocks whose flow connections changed via the patch - // (e.g. when a chain flow is deleted, the stock loses a flow and - // remaining cloud flows may need reclassification from Bottom/Top - // back to Right/Left). - for op in &patch.ops { - if let crate::patch::ModelOperation::UpdateStockFlows { ident, .. } = op { - let canonical = canonicalize(ident).into_owned(); - affected_stocks.insert(canonical); - } - } - - // For deleted flows, find which stocks they were connected to in the - // old view. This handles patches that only emit DeleteVariable without - // UpdateStockFlows -- the remaining sibling flows still need to be - // reclassified. - // Build UID-to-ident map from the model's stock variables rather than - // from view element labels, since labels go through - // format_label_with_line_breaks and may not round-trip through - // canonicalize for quoted names like "a.b". - let stock_uid_to_ident: HashMap = model - .variables - .iter() - .filter_map(|v| { - if !matches!(v, datamodel::Variable::Stock(_)) { - return None; - } - let canonical = canonicalize(v.get_ident()).into_owned(); - state - .uid_manager - .get_uid(&canonical) - .map(|uid| (uid, canonical)) - }) - .collect(); - for op in &patch.ops { - if let crate::patch::ModelOperation::DeleteVariable { ident } = op { - let canonical = canonicalize(ident).into_owned(); - // Match by UID rather than display name: labels go through - // format_label_with_line_breaks which strips quoting, so - // canonicalizing the label back can produce a different ident - // for names like "a.b". - let deleted_uid = match state.uid_manager.get_uid(&canonical) { - Some(uid) => uid, - None => continue, - }; - for elem in &old_view.elements { - if let ViewElement::Flow(f) = elem - && f.uid == deleted_uid - { - for pt in &f.points { - if let Some(uid) = pt.attached_to_uid - && let Some(stock_ident) = stock_uid_to_ident.get(&uid) - { - affected_stocks.insert(stock_ident.clone()); - } - } - } - } - } - } - - // 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 named element still standing at this point survived the patch - // untouched (or was merely renamed): its label side is pinned for the rest - // of the pass. Whatever gets created from here on -- new variables, - // kind-changed or endpoint-changed rebuilds, orientation-flipped flows -- - // is absent from this snapshot and has its side chosen by - // `optimize_labels_for` below. Offset-only rebuilt flows are added back - // explicitly because they were just deleted but keep their orientation. - let mut pinned_label_sides: HashMap = state - .elements - .iter() - .filter_map(|elem| match elem { - ViewElement::Stock(s) => Some((s.uid, s.label_side)), - ViewElement::Flow(f) => Some((f.uid, f.label_side)), - ViewElement::Aux(a) => Some((a.uid, a.label_side)), - ViewElement::Module(m) => Some((m.uid, m.label_side)), - _ => None, - }) - .collect(); - for (flow_ident, side) in &offset_rebuilt_label_sides { - if let Some(uid) = state.uid_manager.get_uid(flow_ident) { - pinned_label_sides.insert(uid, *side); - } - } - - // Compute positions for rebuilt flows based on their attachment info, - // falling back to the old position if the stock UID lookup fails. - for flow_ident in &flows_to_rebuild { - let pos = attachment_based_flow_position( - &state, - &config, - &metadata, - flow_ident, - &incr_flow_attachments, - ) - .or_else(|| old_flow_positions.get(flow_ident).copied()); - if let Some(pos) = pos { - let uid = state.get_or_alloc_uid(flow_ident); - create_flow_view_element( - &mut state, - &config, - &metadata, - flow_ident, - uid, - pos, - &incr_flow_attachments, - )?; - } - } - - restore_label_sides(&mut state, &pinned_label_sides); - let needs_label_placement = |uid: i32| !pinned_label_sides.contains_key(&uid); - - if new_elements.is_empty() { - // No new elements and no settlement step, so rebuilt flows - // already have correct geometry from create_flow_view_element. - // Skip resnap entirely to avoid rewriting unrelated manual or - // imported flow endpoints elsewhere in the diagram. - diff_connectors(&mut state, &metadata); - diff_clouds(&mut state, &metadata); - optimize_labels_for(&mut state, model, &metadata, needs_label_placement); - apply_loop_curvature(&mut state, &config, model, &metadata); - validate_view_completeness(&state, model)?; - return Ok(build_stock_flow_from_state(state, old_view)); - } - - let initial_positions = compute_new_element_positions(&state, &metadata, &new_elements); - - // Step 5: Create view elements for new variables and insert their - // initial positions into state so settlement can find them. - for stock_ident in &new_elements.new_stocks { - if let Some(&pos) = initial_positions.get(stock_ident) { - let uid = state.get_or_alloc_uid(stock_ident); - let name = state.display_name(stock_ident); - let formatted = format_label_with_line_breaks(&name); - state.elements.push(ViewElement::Stock(view_element::Stock { - name: formatted, - uid, - x: pos.x, - y: pos.y, - label_side: LabelSide::Bottom, - compat: None, - })); - state.positions.insert(uid, pos); - } - } - - for flow_ident in &new_elements.new_flows { - // For stock-to-stock (chain) flows, use the generic seed position - // which places the valve between the two stocks. For cloud flows - // (one unattached end), use attachment-based position so top/bottom - // flows get their valve on the correct vertical pipe. - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - let is_stock_to_stock = from_stock.is_some() && to_stock.is_some(); - let pos = if is_stock_to_stock { - initial_positions.get(flow_ident).copied() - } else { - attachment_based_flow_position( - &state, - &config, - &metadata, - flow_ident, - &incr_flow_attachments, - ) - .or_else(|| initial_positions.get(flow_ident).copied()) - }; - if let Some(pos) = pos { - let uid = state.get_or_alloc_uid(flow_ident); - create_flow_view_element( - &mut state, - &config, - &metadata, - flow_ident, - uid, - pos, - &incr_flow_attachments, - )?; - } - } - - // create_flow_view_element calls build_clouds_for_flow which pushes Cloud elements into - // state.elements but does not add their positions to state.positions. Record those - // positions now so that settle_new_elements can seed proper initial positions for cloud - // nodes in SFDP and later update them after settling. - for elem in &state.elements { - if let ViewElement::Cloud(c) = elem { - state - .positions - .entry(c.uid) - .or_insert_with(|| Position::new(c.x, c.y)); - } - } - - for aux_ident in &new_elements.new_auxes { - if let Some(&pos) = initial_positions.get(aux_ident) { - let uid = state.get_or_alloc_uid(aux_ident); - let name = state.display_name(aux_ident); - let formatted = format_label_with_line_breaks(&name); - state.elements.push(ViewElement::Aux(view_element::Aux { - name: formatted, - uid, - x: pos.x, - y: pos.y, - label_side: LabelSide::Bottom, - compat: None, - })); - state.positions.insert(uid, pos); - } - } - - for module_ident in &new_elements.new_modules { - if let Some(&pos) = initial_positions.get(module_ident) { - let uid = state.get_or_alloc_uid(module_ident); - let name = state.display_name(module_ident); - let formatted = format_label_with_line_breaks(&name); - state - .elements - .push(ViewElement::Module(view_element::Module { - name: formatted, - uid, - x: pos.x, - y: pos.y, - label_side: LabelSide::Bottom, - })); - state.positions.insert(uid, pos); - } - } - - // Step 6: Settle new elements with existing elements pinned - let chains_data: Vec<_> = metadata - .chains - .iter() - .map(|c| (c.stocks.clone(), c.flows.clone(), c.all_vars.clone())) - .collect(); - settle_new_elements( - &mut state, - &config, - model, - &metadata, - &new_elements, - &chains_data, - )?; - - // Update view element coordinates from settled positions - for elem in &mut state.elements { - let uid = elem.get_uid(); - if let Some(&pos) = state.positions.get(&uid) { - match elem { - ViewElement::Stock(s) => { - s.x = pos.x; - s.y = pos.y; - } - ViewElement::Flow(f) => { - let dx = pos.x - f.x; - let dy = pos.y - f.y; - f.x = pos.x; - f.y = pos.y; - for pt in &mut f.points { - pt.x += dx; - pt.y += dy; - } - } - ViewElement::Aux(a) => { - a.x = pos.x; - a.y = pos.y; - } - ViewElement::Module(m) => { - m.x = pos.x; - m.y = pos.y; - } - ViewElement::Cloud(c) => { - c.x = pos.x; - c.y = pos.y; - } - _ => {} - } - } - } - - resnap_flow_endpoints(&mut state, &config); - - // Step 7: Diff connectors and clouds - diff_connectors(&mut state, &metadata); - diff_clouds(&mut state, &metadata); - - // Step 8: Polish. Only elements created in this pass get a label side - // chosen; pinned elements keep theirs even if a new connector now runs - // through the label (hand placement wins; the human can move it). - optimize_labels_for(&mut state, model, &metadata, needs_label_placement); - apply_loop_curvature(&mut state, &config, model, &metadata); - // Guarantee flows stay orthogonal after re-snapping endpoints to moved - // stocks (only rewrites pipes that actually went diagonal; hand-routed - // orthogonal flows are left untouched). - orthogonal::orthogonalize_flow_pipes(&mut state.elements); - - validate_view_completeness(&state, model)?; - - // Step 9: Build StockFlow - Ok(build_stock_flow_from_state(state, old_view)) -} - /// Generate a complete stock-flow diagram layout for a model using a single /// seed. This is the fast path; for higher-quality results use /// [`generate_best_layout`] which tries multiple seeds in parallel. From 15068be464010a213616a45ee6da197c67fefab6 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 00:46:40 -0700 Subject: [PATCH 07/16] engine: declutter what an incremental layout adds incremental_layout placed new elements with a force pass that treats them as points and chose their label sides by connector angles, with no declutter at all, so a diagram an agent builds over several edit_model calls ended up far worse than a fresh layout of the same model (2-8x the metric cost in the layout eval replay): names on circles, names struck by links, parameters jammed together. Its polish step now runs declutter::declutter_part over the elements the patch created: their label sides are chosen by what the metric charges (the same LabelScene the fresh declutter uses) and the new free-floating elements are pushed off whatever they landed on. Everything that was already drawn is an obstacle only -- no zoom, no compaction -- so the incremental contract holds: untouched elements keep their positions and label sides byte for byte. On the corpus replay (small and medium tiers) the incremental cost falls on 18 of 21 models and rises on none. The angle-based label pass only serves the fresh layout now, so its per-element filter goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/declutter.rs | 122 +++++++++++++- src/simlin-engine/src/layout/incremental.rs | 46 ++++-- .../src/layout/incremental_tests.rs | 156 ++++++++++++++++++ src/simlin-engine/src/layout/mod.rs | 18 +- 4 files changed, 309 insertions(+), 33 deletions(-) create mode 100644 src/simlin-engine/src/layout/incremental_tests.rs diff --git a/src/simlin-engine/src/layout/declutter.rs b/src/simlin-engine/src/layout/declutter.rs index baf8f79e1..aad8d7264 100644 --- a/src/simlin-engine/src/layout/declutter.rs +++ b/src/simlin-engine/src/layout/declutter.rs @@ -537,6 +537,16 @@ pub(crate) fn resnap_flow_endpoints_to_stocks(elements: &mut [ViewElement]) { /// 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) { + 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(); @@ -553,7 +563,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() @@ -567,14 +577,22 @@ 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| id_of_uid.get(&uid).and_then(|&other| label_of(other)); + 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, @@ -590,6 +608,16 @@ 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() @@ -604,7 +632,7 @@ fn relax_positions(elements: &mut [ViewElement], alias_names: &HashMap 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::*; @@ -1082,6 +1131,69 @@ mod tests { ); } + #[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" + ); + } + // ── flow labels as side-choice obstacles ── #[test] diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs index 85ca7091c..f415cc6a2 100644 --- a/src/simlin-engine/src/layout/incremental.rs +++ b/src/simlin-engine/src/layout/incremental.rs @@ -1729,13 +1729,15 @@ pub fn incremental_layout( } } - // Every named element still standing at this point survived the patch - // untouched (or was merely renamed): its label side is pinned for the rest - // of the pass. Whatever gets created from here on -- new variables, - // kind-changed or endpoint-changed rebuilds, orientation-flipped flows -- - // is absent from this snapshot and has its side chosen by - // `optimize_labels_for` below. Offset-only rebuilt flows are added back - // explicitly because they were just deleted but keep their orientation. + // 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() @@ -1788,7 +1790,7 @@ pub fn incremental_layout( // imported flow endpoints elsewhere in the diagram. diff_connectors(&mut state, &metadata); diff_clouds(&mut state, &metadata); - optimize_labels_for(&mut state, model, &metadata, needs_label_placement); + 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)); @@ -1953,10 +1955,26 @@ pub fn incremental_layout( diff_connectors(&mut state, &metadata); diff_clouds(&mut state, &metadata); - // Step 8: Polish. Only elements created in this pass get a label side - // chosen; pinned elements keep theirs even if a new connector now runs - // through the label (hand placement wins; the human can move it). - optimize_labels_for(&mut state, model, &metadata, needs_label_placement); + // Step 8: Polish. Elements created in this pass get their label sides + // chosen by what the metric charges, and the new free-floating ones step + // off whatever they landed on. 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). + 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 @@ -1968,3 +1986,7 @@ pub fn incremental_layout( // 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..8ebfeb666 --- /dev/null +++ b/src/simlin-engine/src/layout/incremental_tests.rs @@ -0,0 +1,156 @@ +// 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"); + } +} diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index c7d201c33..b57f745ad 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -2371,22 +2371,9 @@ fn calculate_allowed_label_sides_for_stock( } /// Apply optimal label placement based on connector angles to every named -/// element. This is the full-layout pass; incremental layout uses -/// [`optimize_labels_for`] so it never rewrites a pre-existing element's side. +/// element: the fresh layout's first guess, which the declutter pass then +/// refines against the metric. fn optimize_labels(state: &mut LayoutState, model: &datamodel::Model, metadata: &ComputedMetadata) { - optimize_labels_for(state, model, metadata, |_| true); -} - -/// Apply optimal label placement to the named elements whose UID satisfies -/// `should_place`. Elements it rejects keep their current `label_side` -/// untouched, even though their positions still inform the placement of the -/// elements it accepts (connector angles are computed from all positions). -fn optimize_labels_for( - state: &mut LayoutState, - model: &datamodel::Model, - metadata: &ComputedMetadata, - should_place: impl Fn(i32) -> bool, -) { let uid_to_ident: HashMap = model .variables .iter() @@ -2409,7 +2396,6 @@ fn optimize_labels_for( .elements .iter() .enumerate() - .filter(|(_, elem)| should_place(elem.get_uid())) .filter_map(|(i, elem)| match elem { ViewElement::Stock(stock) => { let ident = uid_to_ident.get(&stock.uid)?; From 46d3586bf074a9336861874b656177cff9081673 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 00:54:25 -0700 Subject: [PATCH 08/16] engine: lay out chains an edit adds whole as chains When an edit added whole stock-flow chains to a drawn diagram -- the usual agent edit, "add the capital sector" -- incremental_layout seeded every new stock at one periphery point and settled each chain as a rigid group, so the chains landed on each other and on the existing diagram (the delays replay drew four chains in one pile; reliability stacked Developers onto Product Comprehensiveness). place_new_chains now lays each chain whose stocks are all new out with layout_chain, exactly as a fresh layout draws a chain, and sets it down below the diagram or to its right: nearer the drawn variables it reads from or feeds, or, with none, whichever keeps the diagram nearer square, preferring below. The placed chain is held still while the new parameters settle around it. On the corpus replay the incremental cost falls on delays (6.71 -> 2.16), reliability (2.71 -> 1.31), fishbanks, workforce, and lotka_volterra, and moves under 3% elsewhere. A new stock hung off an existing chain still takes the generic placement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/incremental.rs | 211 ++++++++++++++++++ .../src/layout/incremental_tests.rs | 51 +++++ 2 files changed, 262 insertions(+) diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs index f415cc6a2..ffeec7109 100644 --- a/src/simlin-engine/src/layout/incremental.rs +++ b/src/simlin-engine/src/layout/incremental.rs @@ -8,6 +8,7 @@ //! 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. @@ -1298,6 +1299,206 @@ pub(super) fn build_stock_flow_from_state( } } +/// 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) +} + +/// `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. @@ -1796,6 +1997,16 @@ pub fn incremental_layout( 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 placed_chain_vars = place_new_chains(&mut state, &config, &metadata, &new_elements)?; + 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 diff --git a/src/simlin-engine/src/layout/incremental_tests.rs b/src/simlin-engine/src/layout/incremental_tests.rs index 8ebfeb666..c3a1e9354 100644 --- a/src/simlin-engine/src/layout/incremental_tests.rs +++ b/src/simlin-engine/src/layout/incremental_tests.rs @@ -154,3 +154,54 @@ fn new_parameters_are_decluttered_around_the_fixed_diagram() { 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"); + } +} From 42c09cafa70ed92a307ea1cfe641320d478e00d3 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 00:59:52 -0700 Subject: [PATCH 09/16] engine: continue a drawn chain's row for a new stock A stock an edit hangs off a drawn chain ("infected now drains into recovered") took the generic new-element placement: seeded near variables it reads, which for a fresh stock is nothing, so it went to the diagram's periphery and its pipe bent to reach it. place_chain_extensions sets each such stock one chain step past its drawn neighbor, in the neighbor's row -- right of the stock it drains, left of the stock it feeds -- fanning vertically only past a stock or parameter already drawn there, and places stocks reached only through other new stocks from them in turn. The flow between the two, now between two drawn stocks, takes the stock-pair valve position the existing path already gives such flows. The layout eval replay adds chains whole and never exercises this case; the new incremental test pins it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/incremental.rs | 80 ++++++++++++++++++- .../src/layout/incremental_tests.rs | 47 +++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs index ffeec7109..3ddb4c4e8 100644 --- a/src/simlin-engine/src/layout/incremental.rs +++ b/src/simlin-engine/src/layout/incremental.rs @@ -1425,6 +1425,78 @@ fn place_new_chains( 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() @@ -1999,7 +2071,13 @@ pub fn incremental_layout( // 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 placed_chain_vars = place_new_chains(&mut state, &config, &metadata, &new_elements)?; + 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), diff --git a/src/simlin-engine/src/layout/incremental_tests.rs b/src/simlin-engine/src/layout/incremental_tests.rs index c3a1e9354..4f76d8c09 100644 --- a/src/simlin-engine/src/layout/incremental_tests.rs +++ b/src/simlin-engine/src/layout/incremental_tests.rs @@ -205,3 +205,50 @@ fn chains_added_whole_are_laid_out_as_chains_beside_the_diagram() { 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"); + } +} From b935db03f588723f87a6641fd8d1b5887a1f5d75 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 01:06:55 -0700 Subject: [PATCH 10/16] engine: bound a rendered module by its label too The SVG renderer sizes its viewBox from each node's drawn extent, but module_bounds returned only the rounded rect, so a module's name hanging past the diagram's edge was cut off -- visible in the layout eval renders of hares_and_foxes, where both module names were clipped. The TS Canvas's moduleBounds merges the label, as the aux and stock bounds already do on both sides. module_bounds now includes the label, and the label-free rect becomes module_shape_bounds, which the layout metric keeps using as the module's shape (it charges labels separately). The new render test rows every labeled node kind the renderer bounds: aux, flow, stock, and module. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/diagram/elements.rs | 17 +++++- src/simlin-engine/src/diagram/render.rs | 72 +++++++++++++++++++++++ src/simlin-engine/src/layout/metrics.rs | 6 +- 3 files changed, 92 insertions(+), 3 deletions(-) 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/metrics.rs b/src/simlin-engine/src/layout/metrics.rs index c61dfad2a..22d5e5626 100644 --- a/src/simlin-engine/src/layout/metrics.rs +++ b/src/simlin-engine/src/layout/metrics.rs @@ -31,7 +31,9 @@ use crate::diagram::common::{ segment_clip_interval_in_rect, }; use crate::diagram::connector::{ARC_POLYLINE_SAMPLES, connector_polyline, get_visual_center}; -use crate::diagram::elements::{aux_shape_bounds, cloud_bounds, module_bounds, stock_shape_bounds}; +use crate::diagram::elements::{ + aux_shape_bounds, cloud_bounds, module_shape_bounds, stock_shape_bounds, +}; use crate::diagram::label::{LabelProps, label_bounds}; use super::annealing::segment_intersection; @@ -393,7 +395,7 @@ pub(crate) fn node_shape_box(element: &ViewElement) -> Option { 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(circle_box(f.x, f.y, AUX_RADIUS)), ViewElement::Alias(a) => Some(alias_shape_box(a)), From 22827818c56b0000140cfbb28ee9d025b1f33266 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 01:28:09 -0700 Subject: [PATCH 11/16] engine: index the label chooser's scene by a grid The metric-aware label chooser charged each candidate side by scanning every node and connector in the diagram, so on large models it doubled production layout time (wrld3_03 1.5s -> 3.5s, covid19 1.2s -> 2.3s on the layout eval large tier). LabelScene now buckets nodes by the region they can reach (the shape grown by its label's size and the crowding clearance) and connectors by their bounding boxes, and a query visits only the candidates whose cells meet the label or the owner's own shape -- every pair the crowding term can charge involves one of the two. Skipped items add exact zeros and the candidates are visited in index order, so every sum is bit-identical to a full scan: the new metrics test checks that on every side of every exemplar element, and the large tier's layouts come out identical while production time falls about 35% (wrld3_03 2.2s, covid19 1.3s). The rest of the large-model cost is the declutter relaxation's all-pairs overlap scan, which predates the chooser. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/metrics.rs | 117 +++++++++++++++++- src/simlin-engine/src/layout/metrics_tests.rs | 71 +++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/src/simlin-engine/src/layout/metrics.rs b/src/simlin-engine/src/layout/metrics.rs index 22d5e5626..57fcace0b 100644 --- a/src/simlin-engine/src/layout/metrics.rs +++ b/src/simlin-engine/src/layout/metrics.rs @@ -867,7 +867,11 @@ fn label_connector_overlap_term( /// How struck out the label box `lbl` of node `owner` is: the connector length /// through its (inset) text box relative to the box's smaller side, capped at /// 1. -fn label_strike_fraction(owner: i32, lbl: &Rect, connectors: &[ConnectorGeometry]) -> f64 { +fn label_strike_fraction<'a>( + owner: i32, + lbl: &Rect, + connectors: impl IntoIterator, +) -> f64 { let text = inset(lbl, LABEL_INSET); let side = common::rect_width(&text).min(common::rect_height(&text)); if side <= 0.0 { @@ -909,21 +913,57 @@ pub(crate) struct LabelScene { /// `labels / nodes`: converts a per-node crowding deficit into the same /// per-label units the label terms are charged in. crowding_scale: f64, + index_of_uid: HashMap, + /// Each node by the region it can reach: its shape, grown by its label's + /// size on every side (a label may take any side) and the crowding + /// clearance. + node_grid: SceneGrid, + /// Each connector by its polyline's bounding box. + connector_grid: SceneGrid, } impl LabelScene { pub(crate) fn new(elements: &[ViewElement]) -> Self { let nodes = build_scene_nodes(elements); + let connectors = collect_connector_geometry(elements); let labels = nodes.iter().filter(|n| n.label.is_some()).count(); let crowding_scale = if nodes.is_empty() { 0.0 } else { labels as f64 / nodes.len() as f64 }; + let mut node_grid = SceneGrid::default(); + for (i, n) in nodes.iter().enumerate() { + let (w, h) = n.label.map_or((0.0, 0.0), |l| { + (common::rect_width(&l), common::rect_height(&l)) + }); + let reach = w.max(h) + REACH_PAD; + node_grid.insert(i, &grown(&n.shape, reach)); + } + let mut connector_grid = SceneGrid::default(); + for (i, c) in connectors.iter().enumerate() { + let bounds = c + .polyline + .iter() + .fold(None, |acc: Option, p| { + let point = Rect { + left: p.x, + top: p.y, + right: p.x, + bottom: p.y, + }; + Some(acc.map_or(point, |r| merge_bounds(r, point))) + }) + .expect("a connector has at least two points"); + connector_grid.insert(i, &bounds); + } LabelScene { - connectors: collect_connector_geometry(elements), + index_of_uid: nodes.iter().enumerate().map(|(i, n)| (n.uid, i)).collect(), nodes, + connectors, crowding_scale, + node_grid, + connector_grid, } } @@ -946,12 +986,24 @@ impl LabelScene { if area <= 0.0 { return 0.0; } - let Some(own) = self.nodes.iter().find(|n| n.uid == owner) else { + let Some(&own_index) = self.index_of_uid.get(&owner) else { return 0.0; }; + let own = &self.nodes[own_index]; + // Only nodes whose reach meets this label or the owner's own shape + // (every pair the crowding term can charge involves one of the two) + // can contribute; the rest add exact zeros. Visiting the candidates in + // index order keeps every sum bit-identical to a full scan. + let query = grown(&merge_bounds(*lbl, own.shape), COMFORTABLE_CLEARANCE); let mut covered = 0.0; let mut crowding = 0.0; - for other in self.nodes.iter().filter(|n| n.uid != owner) { + for other in self + .node_grid + .query(&query) + .into_iter() + .map(|i| &self.nodes[i]) + .filter(|n| n.uid != owner) + { let other_label = label_of(other.uid); covered += rect_overlap_area(lbl, &other.shape); if let Some(ol) = &other_label { @@ -965,12 +1017,67 @@ impl LabelScene { crowding += (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); } } + let text = inset(lbl, LABEL_INSET); + let struck = self + .connector_grid + .query(&text) + .into_iter() + .map(|i| &self.connectors[i]); w.label_overlap * covered.min(area) / area - + w.label_connector_overlap * label_strike_fraction(owner, lbl, &self.connectors) + + w.label_connector_overlap * label_strike_fraction(owner, lbl, struck) + w.crowding * self.crowding_scale * crowding } } +/// How far past a node's shape its reach extends beyond its label's size: the +/// crowding clearance plus room for the label's offset from the shape. +const REACH_PAD: f64 = 2.0 * COMFORTABLE_CLEARANCE; + +/// Cell size of [`SceneGrid`]: about a node with its label. +const SCENE_GRID_CELL: f64 = 96.0; + +/// A uniform grid of item indices by the cells their rects cover. +#[derive(Default)] +struct SceneGrid { + cells: HashMap<(i64, i64), Vec>, +} + +impl SceneGrid { + fn cell_range(r: &Rect) -> (std::ops::RangeInclusive, std::ops::RangeInclusive) { + let cell = |v: f64| (v / SCENE_GRID_CELL).floor() as i64; + (cell(r.left)..=cell(r.right), cell(r.top)..=cell(r.bottom)) + } + + fn insert(&mut self, index: usize, r: &Rect) { + let (xs, ys) = Self::cell_range(r); + for x in xs { + for y in ys.clone() { + self.cells.entry((x, y)).or_default().push(index); + } + } + } + + /// Every item whose rect's cells meet `r`'s, each once, in index order. + fn query(&self, r: &Rect) -> Vec { + let (xs, ys) = Self::cell_range(r); + let mut out = Vec::new(); + for x in xs { + for y in ys.clone() { + if let Some(items) = self.cells.get(&(x, y)) { + out.extend_from_slice(items); + } + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +fn grown(r: &Rect, d: f64) -> Rect { + inset(r, -d) +} + /// `crossings`: crossings per connector, on the drawn polylines. fn crossings_term( view: &datamodel::StockFlow, diff --git a/src/simlin-engine/src/layout/metrics_tests.rs b/src/simlin-engine/src/layout/metrics_tests.rs index 1ab8d5350..5d6ef6a7d 100644 --- a/src/simlin-engine/src/layout/metrics_tests.rs +++ b/src/simlin-engine/src/layout/metrics_tests.rs @@ -1465,3 +1465,74 @@ fn test_reference_pair_population_human_beats_auto() { fn test_reference_pair_logistic_growth_human_beats_auto() { assert_human_beats_auto("logistic-growth"); } + +// --- the label-side chooser's scene --- + +#[test] +fn test_label_scene_cost_equals_a_full_scan() { + // `LabelScene::label_cost` visits only the nodes and connectors its grid + // says can reach the candidate label. It must charge exactly what a scan of + // the whole scene charges -- bit for bit, since the chooser compares costs + // with a tie tolerance. Rows: every label side of every named element in + // the shipped exemplar diagrams, against the other labels as drawn. + use crate::datamodel::view_element::LabelSide; + let weights = MetricWeights::default(); + for dir in ["logistic-growth", "population", "fishbanks", "reliability"] { + let view = default_project_view(dir); + let scene = LabelScene::new(&view.elements); + let nodes = build_scene_nodes(&view.elements); + let connectors = collect_connector_geometry(&view.elements); + let drawn: HashMap = nodes + .iter() + .filter_map(|n| n.label.map(|l| (n.uid, l))) + .collect(); + let label_of = |uid: i32| drawn.get(&uid).copied(); + for elem in &view.elements { + for side in [ + LabelSide::Top, + LabelSide::Bottom, + LabelSide::Left, + LabelSide::Right, + ] { + let Some(props) = element_label_props_for(elem, side) else { + continue; + }; + let lbl = label_bounds(&props); + let owner = elem.get_uid(); + let indexed = scene.label_cost(owner, &lbl, label_of, &weights); + + let own = nodes + .iter() + .find(|n| n.uid == owner) + .expect("owner in scene"); + let area = rect_area(&lbl); + let mut covered = 0.0; + let mut crowding = 0.0; + for other in nodes.iter().filter(|n| n.uid != owner) { + let other_label = label_of(other.uid); + covered += rect_overlap_area(&lbl, &other.shape); + if let Some(ol) = &other_label { + covered += rect_overlap_area(&lbl, ol); + } + if own.is_cloud || other.is_cloud { + continue; + } + let (gap, _) = footprint_gap(own, Some(lbl), other, other_label); + if gap < COMFORTABLE_CLEARANCE { + crowding += (1.0 - gap / COMFORTABLE_CLEARANCE).powi(2); + } + } + let labels = nodes.iter().filter(|n| n.label.is_some()).count(); + let scanned = weights.label_overlap * covered.min(area) / area + + weights.label_connector_overlap + * label_strike_fraction(owner, &lbl, &connectors) + + weights.crowding * (labels as f64 / nodes.len() as f64) * crowding; + assert_eq!( + indexed.to_bits(), + scanned.to_bits(), + "{dir}: element {owner} side {side:?}: indexed {indexed} vs scanned {scanned}" + ); + } + } + } +} From 04ccceac9534120bcdc6e7d9e01c5477426f865a Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 01:36:02 -0700 Subject: [PATCH 12/16] engine: broad-phase the declutter relaxation remove_overlaps tested every pair of footprints on every iteration -- up to 400 iterations per relax, several relaxes per declutter, four seeds per production layout -- and a profile of wrld3_03 put a quarter of its layout time there. Each iteration now buckets the items' translated boxes, grown by the separation margin, into a grid and tests only the pairs that share a cell: any pair separation_mtv would push apart meets in grown boxes. Candidates are visited in the full scan's (i, j) order and a skipped pair pushes nothing, so the accumulated displacements are bit-identical; the new declutter test checks that against an all-pairs oracle on jammed random scenes, and the layout eval large tier reproduces its layouts exactly while production time falls another third (wrld3_03 2.2s -> 1.5s, covid19 1.3s -> 0.9s). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/declutter.rs | 149 +++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/src/simlin-engine/src/layout/declutter.rs b/src/simlin-engine/src/layout/declutter.rs index aad8d7264..6ef606690 100644 --- a/src/simlin-engine/src/layout/declutter.rs +++ b/src/simlin-engine/src/layout/declutter.rs @@ -27,7 +27,7 @@ use std::collections::HashMap; use crate::datamodel::ViewElement; use crate::datamodel::view_element::LabelSide; -use crate::diagram::common::Rect; +use crate::diagram::common::{Rect, merge_bounds}; use crate::diagram::label::label_bounds; use super::metrics::{ @@ -162,13 +162,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 +243,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, @@ -1003,6 +1056,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] From 41c2167f4c3cc544fd163b2d4595b8b1c2965da0 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 01:48:40 -0700 Subject: [PATCH 13/16] engine: declutter to the clearance the metric charges The declutter separated footprints to a 6px margin and compacted the diagram down to exactly that margin, while the metric charges crowding for any two footprints closer than 8px -- so the tightest arrangement the declutter reached was one the metric charged by construction. The margin is now the metric's COMFORTABLE_CLEARANCE, and the label sides are chosen once more after compaction, which moves everything closer without re-siding. On the layout eval corpus the effect is small and mostly within noise (aggregate -1.6%, n.s.); where it moves a picture it removes visible jamming, e.g. ai_modules_arrays' input no longer touches the module box. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/declutter.rs | 43 ++++++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/simlin-engine/src/layout/declutter.rs b/src/simlin-engine/src/layout/declutter.rs index 6ef606690..a83a5bf51 100644 --- a/src/simlin-engine/src/layout/declutter.rs +++ b/src/simlin-engine/src/layout/declutter.rs @@ -31,14 +31,15 @@ use crate::diagram::common::{Rect, merge_bounds}; use crate::diagram::label::label_bounds; use super::metrics::{ - LabelScene, MetricWeights, alias_label_props_for, alias_source_names, element_label_props_for, - node_shape_box, pipe_rects, + 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 @@ -857,6 +858,9 @@ 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: @@ -1339,6 +1343,35 @@ mod tests { ); } + #[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] From 3b53995ec302f3ff03c14efe6ea15cc98492837a Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 01:56:28 -0700 Subject: [PATCH 14/16] engine: re-seed the layout eval baseline The committed baseline predated every change to the metric's label terms (pipes now strike names instead of covering area) and to the layouts themselves -- side-flow faces, builtin-input and lookup-table links, the metric-aware label chooser, the declutter margin -- so every run's diff against it mixed metric drift with layout change. Re-seeded over the whole corpus at 8 seeds, as its README describes. The engine map now points at layout/incremental.rs, where the incremental path lives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/CLAUDE.md | 2 +- .../examples/layout_eval_baseline.json | 6280 ++++++++--------- 2 files changed, 3141 insertions(+), 3141 deletions(-) diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index 17fcef710..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). The metric and the eval harness that measures layouts against it are described in [layout quality](/docs/design/layout-quality.md). 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_baseline.json b/src/simlin-engine/examples/layout_eval_baseline.json index 5ec73f6cc..f82566336 100644 --- a/src/simlin-engine/examples/layout_eval_baseline.json +++ b/src/simlin-engine/examples/layout_eval_baseline.json @@ -9,19 +9,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 1, @@ -29,19 +29,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 2, @@ -49,19 +49,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 3, @@ -69,19 +69,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 4, @@ -89,19 +89,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 5, @@ -109,19 +109,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 6, @@ -129,19 +129,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 7, @@ -149,19 +149,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 42, @@ -169,19 +169,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 123, @@ -189,19 +189,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 456, @@ -209,19 +209,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 }, { "seed": 789, @@ -229,27 +229,27 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15391387036044574, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.09453125, - "sprawl": 0.8448978029221074, + "crowding": 0.0, + "sprawl": 0.7662647586228936, "long_connectors": 0.0, - "edge_length_cv": 0.2610911829947331, - "aspect_penalty": 0.13544536271809005, - "misalignment": 0.4, + "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.5766265062711955 + "weighted_cost": 0.1915661896557234 } ], - "median_cost": 0.5766265062711955, + "median_cost": 0.1915661896557234, "spread": [ - 0.5766265062711955, - 0.5766265062711955 + 0.1915661896557234, + 0.1915661896557234 ], - "best_of_k_cost": 0.5766265062711955, + "best_of_k_cost": 0.1915661896557234, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -263,39 +263,39 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2948298473219633, + "label_connector_overlap": 0.05986055020151664, "crossings": 0.0, - "crowding": 0.1215850476744519, - "sprawl": 0.9403348818139852, + "crowding": 0.0, + "sprawl": 0.7740016226085579, "long_connectors": 0.0, - "edge_length_cv": 0.37369814882506985, - "aspect_penalty": 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.8433579835553376 + "weighted_cost": 0.3277356753988589 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02520424416376052, + "node_connector_overlap": 0.03288886650355453, "label_overlap": 0.0, - "label_connector_overlap": 0.1935026950692871, - "crossings": 0.0, - "crowding": 0.07275131307248045, - "sprawl": 1.0062128462450983, + "label_connector_overlap": 0.0, + "crossings": 0.2222222222222222, + "crowding": 0.04872327973855522, + "sprawl": 0.6455218876577643, "long_connectors": 0.0, - "edge_length_cv": 0.5121229757061013, - "aspect_penalty": 1.8270609318996418, - "misalignment": 0.375, + "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.7024670555652067 + "weighted_cost": 0.5106037068823275 }, { "seed": 2, @@ -303,19 +303,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.07111960153152644, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.13037109375, - "sprawl": 0.8141553731978302, + "crowding": 0.0, + "sprawl": 0.7522349327869304, "long_connectors": 0.0, - "edge_length_cv": 0.2921899080548419, - "aspect_penalty": 1.3826699834162524, + "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.4530893393467472 + "weighted_cost": 0.20055873319673262 }, { "seed": 3, @@ -323,19 +323,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.25, + "label_connector_overlap": 0.125, "crossings": 0.0, - "crowding": 0.13037109375, - "sprawl": 0.9231935277246567, + "crowding": 0.0, + "sprawl": 0.7678703008266486, "long_connectors": 0.0, - "edge_length_cv": 0.29840579737450973, - "aspect_penalty": 1.1242090651822654, + "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.7486694756811642 + "weighted_cost": 0.3919675752066622 }, { "seed": 4, @@ -343,39 +343,39 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.0691303619201131, + "label_connector_overlap": 0.0, "crossings": 0.2222222222222222, - "crowding": 0.13037109375, - "sprawl": 0.9020133742274014, + "crowding": 0.0, + "sprawl": 0.8342509597581058, "long_connectors": 0.0, - "edge_length_cv": 0.2070469549586528, - "aspect_penalty": 1.2046165884194053, + "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.6942922024092422 + "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.125, + "label_connector_overlap": 0.010818700666470534, "crossings": 0.0, - "crowding": 0.13862875846791572, - "sprawl": 1.0132963898058205, + "crowding": 0.11821097099555047, + "sprawl": 0.9581861565599862, "long_connectors": 0.0, - "edge_length_cv": 0.356464037268458, - "aspect_penalty": 0.6148775894538607, + "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.6230491615490003 + "weighted_cost": 0.41247404095373824 }, { "seed": 6, @@ -383,19 +383,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1907454395911714, + "label_connector_overlap": 0.06400646284962555, "crossings": 0.0, - "crowding": 0.07520194644223477, - "sprawl": 0.9501600542589955, + "crowding": 0.06104179019223477, + "sprawl": 0.8752705499420612, "long_connectors": 0.0, "edge_length_cv": 0.3078393138268285, - "aspect_penalty": 0.0, + "aspect_penalty": 0.2198298777246146, "misalignment": 0.375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.6363601193937407 + "weighted_cost": 0.41336912195218845 }, { "seed": 7, @@ -403,59 +403,59 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2739916736727163, + "label_connector_overlap": 0.0798884908166642, "crossings": 0.0, - "crowding": 0.13037109375, - "sprawl": 1.0334338602539141, + "crowding": 0.0, + "sprawl": 0.845637473008386, "long_connectors": 0.0, - "edge_length_cv": 0.16736351313364584, - "aspect_penalty": 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.837217069322553 + "weighted_cost": 0.36874210447709277 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.031265080610215934, + "node_connector_overlap": 0.03124960334962321, "label_overlap": 0.0, - "label_connector_overlap": 0.06521559058989596, - "crossings": 0.0, - "crowding": 0.20286226336123286, - "sprawl": 0.7223902724571235, + "label_connector_overlap": 0.0, + "crossings": 0.2222222222222222, + "crowding": 0.04872327973855522, + "sprawl": 0.6794323069602288, "long_connectors": 0.0, - "edge_length_cv": 0.4824379787075562, - "aspect_penalty": 1.81120527306968, + "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.5813133785807895 + "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.2333398399384502, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.1228298611111111, - "sprawl": 1.1043525279334245, + "crowding": 0.0, + "sprawl": 1.033455214901108, "long_connectors": 0.0, - "edge_length_cv": 0.42562431304846177, - "aspect_penalty": 0.252476506004256, + "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.8324206966601185 + "weighted_cost": 0.3419114229862219 }, { "seed": 456, @@ -463,19 +463,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.12916253333703215, + "label_connector_overlap": 0.06260824424892301, "crossings": 0.0, - "crowding": 0.13037109375, - "sprawl": 1.0681071015556278, + "crowding": 0.0, + "sprawl": 0.8858498095274605, "long_connectors": 0.0, - "edge_length_cv": 0.4106297937365929, + "edge_length_cv": 0.4168459410899187, "aspect_penalty": 0.0, "misalignment": 0.25, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.6161416691444552 + "weighted_cost": 0.34037481875524966 }, { "seed": 789, @@ -483,30 +483,30 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.35969284630365944, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.007432519743273804, - "sprawl": 1.596148146962461, - "long_connectors": 0.02062705020281728, - "edge_length_cv": 0.6115506444639912, - "aspect_penalty": 0.16218853680218803, - "misalignment": 0.4444444444444444, + "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": 1.0007667954852313 + "weighted_cost": 0.3797863997082253 } ], - "median_cost": 0.6983796289872244, + "median_cost": 0.38587698745744375, "spread": [ - 0.621322288447864, - 0.8336197898257272 + 0.34152727192847887, + 0.4208480820045785 ], - "best_of_k_cost": 0.5813133785807895, + "best_of_k_cost": 0.34037481875524966, "best_seed": 2, - "median_seed": 4, - "worst_seed": 789 + "median_seed": 3, + "worst_seed": 42 }, { "model": "logistic_growth", @@ -517,10 +517,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.38245730181224497, + "label_connector_overlap": 0.07228406293912552, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.1178229316949528, + "crowding": 0.0, + "sprawl": 1.0147599018870563, "long_connectors": 0.0, "edge_length_cv": 0.17217974049680632, "aspect_penalty": 0.0, @@ -529,7 +529,7 @@ "flow_bends": 0.0, "loop_straightness": 0.2704593035495808 }, - "weighted_cost": 1.078259086826967 + "weighted_cost": 0.5732825782081709 }, { "seed": 1, @@ -537,19 +537,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.25521157043525533, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.022879464285714284, - "sprawl": 1.0877982593089472, + "crowding": 0.0, + "sprawl": 0.9933311851645652, "long_connectors": 0.0, - "edge_length_cv": 0.09779512391099372, + "edge_length_cv": 0.08964727609905875, "aspect_penalty": 0.0, "misalignment": 0.2857142857142857, - "loop_compactness": 0.24567843839715708, + "loop_compactness": 0.24235909976409753, "flow_bends": 0.0, - "loop_straightness": 0.25920663156109086 + "loop_straightness": 0.2609680507050972 }, - "weighted_cost": 0.8304098518522346 + "weighted_cost": 0.3999446698387186 }, { "seed": 2, @@ -557,10 +557,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3876424186792966, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.1607292210088769, + "crowding": 0.0, + "sprawl": 1.0303129118167984, "long_connectors": 0.0, "edge_length_cv": 0.2048318622612551, "aspect_penalty": 0.0, @@ -569,7 +569,7 @@ "flow_bends": 0.0, "loop_straightness": 0.28998100631839646 }, - "weighted_cost": 1.1110471582368588 + "weighted_cost": 0.48302856006275147 }, { "seed": 3, @@ -577,10 +577,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.32564761879314696, + "label_connector_overlap": 0.06856751600669905, "crossings": 0.0, - "crowding": 0.014274598037490703, - "sprawl": 1.269465583427676, + "crowding": 0.0, + "sprawl": 1.1270030438398368, "long_connectors": 0.0, "edge_length_cv": 0.2376883029323176, "aspect_penalty": 0.0, @@ -589,7 +589,7 @@ "flow_bends": 0.0, "loop_straightness": 0.28968087957424843 }, - "weighted_cost": 1.0097107686522917 + "weighted_cost": 0.5742003815381694 }, { "seed": 4, @@ -597,10 +597,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2555691731188538, + "label_connector_overlap": 0.03654326338218004, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.1298081089123353, + "crowding": 0.0, + "sprawl": 1.0263730401930906, "long_connectors": 0.0, "edge_length_cv": 0.2746507084970142, "aspect_penalty": 0.0, @@ -609,7 +609,7 @@ "flow_bends": 0.0, "loop_straightness": 0.26422946609706655 }, - "weighted_cost": 0.8838290419233146 + "weighted_cost": 0.51548051728135 }, { "seed": 5, @@ -617,10 +617,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3850476579985305, + "label_connector_overlap": 0.03222493555027321, "crossings": 0.14285714285714285, - "crowding": 0.013950892857142858, - "sprawl": 1.2489029774889713, + "crowding": 0.0, + "sprawl": 1.1077019117828182, "long_connectors": 0.0, "edge_length_cv": 0.3279090325353458, "aspect_penalty": 0.0, @@ -629,7 +629,7 @@ "flow_bends": 0.0, "loop_straightness": 0.292909775195911 }, - "weighted_cost": 1.3498798034914392 + "weighted_cost": 0.771394560535372 }, { "seed": 6, @@ -637,10 +637,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3498340153770081, + "label_connector_overlap": 0.015907927926438914, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.2637262777866718, + "crowding": 0.0, + "sprawl": 1.12169891980271, "long_connectors": 0.0, "edge_length_cv": 0.13999832182315233, "aspect_penalty": 0.0, @@ -649,7 +649,7 @@ "flow_bends": 0.0, "loop_straightness": 0.29753287145471186 }, - "weighted_cost": 1.0989482258041408 + "weighted_cost": 0.5486013622751538 }, { "seed": 7, @@ -657,10 +657,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3203350053786094, + "label_connector_overlap": 0.018415418329942642, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.2772085690905233, + "crowding": 0.0, + "sprawl": 1.134156585450223, "long_connectors": 0.0, "edge_length_cv": 0.1631996331385811, "aspect_penalty": 0.0, @@ -669,7 +669,7 @@ "flow_bends": 0.0, "loop_straightness": 0.28677457779580046 }, - "weighted_cost": 1.0145335529051012 + "weighted_cost": 0.5119402835648831 }, { "seed": 42, @@ -677,10 +677,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.32469743123799194, + "label_connector_overlap": 0.011641854067886023, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.1486919182742106, + "crowding": 0.0, + "sprawl": 1.019546646391941, "long_connectors": 0.0, "edge_length_cv": 0.1935296097191283, "aspect_penalty": 0.0, @@ -689,7 +689,7 @@ "flow_bends": 0.0, "loop_straightness": 0.2596785934540375 }, - "weighted_cost": 0.9852862890019948 + "weighted_cost": 0.4694657124191255 }, { "seed": 123, @@ -697,10 +697,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.32936012992236524, + "label_connector_overlap": 0.019368693806034232, "crossings": 0.0, - "crowding": 0.013950892857142858, - "sprawl": 1.2198374741517137, + "crowding": 0.0, + "sprawl": 1.0836671526136021, "long_connectors": 0.0, "edge_length_cv": 0.15792800546333421, "aspect_penalty": 0.0, @@ -709,7 +709,7 @@ "flow_bends": 0.0, "loop_straightness": 0.2780848549440337 }, - "weighted_cost": 1.0396984774095617 + "weighted_cost": 0.5267178499933943 }, { "seed": 456, @@ -717,10 +717,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.30542344094981405, + "label_connector_overlap": 0.06517461950284437, "crossings": 0.0, - "crowding": 0.022879464285714284, - "sprawl": 1.0457329201817067, + "crowding": 0.0, + "sprawl": 0.9645752245172804, "long_connectors": 0.0, "edge_length_cv": 0.19498350334051162, "aspect_penalty": 0.0, @@ -729,7 +729,7 @@ "flow_bends": 0.0, "loop_straightness": 0.21077167161141264 }, - "weighted_cost": 1.0296547389156119 + "weighted_cost": 0.6261126185433366 }, { "seed": 789, @@ -737,29 +737,29 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.41388461715977015, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035521352868755664, - "sprawl": 1.056463100037717, + "crowding": 0.0, + "sprawl": 0.9402449256880457, "long_connectors": 0.0, - "edge_length_cv": 0.26640944699663793, + "edge_length_cv": 0.260374516153528, "aspect_penalty": 0.0, "misalignment": 0.2857142857142857, - "loop_compactness": 0.32260826169216406, + "loop_compactness": 0.3215757251462962, "flow_bends": 0.0, - "loop_straightness": 0.24558496226773144 + "loop_straightness": 0.26055607003504544 }, - "weighted_cost": 1.1026372830929074 + "weighted_cost": 0.418318557055463 } ], - "median_cost": 1.0346766081625867, + "median_cost": 0.5210991836373722, "spread": [ - 1.0036046487397174, - 1.0998704901263325 + 0.47963784815184496, + 0.5735120290406704 ], - "best_of_k_cost": 0.9852862890019948, + "best_of_k_cost": 0.418318557055463, "best_seed": 1, - "median_seed": 456, + "median_seed": 4, "worst_seed": 5 }, { @@ -771,19 +771,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 1, @@ -791,19 +791,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 2, @@ -811,19 +811,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 3, @@ -831,19 +831,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 4, @@ -851,19 +851,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 5, @@ -871,19 +871,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 6, @@ -891,19 +891,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 7, @@ -911,19 +911,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 42, @@ -931,19 +931,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 123, @@ -951,19 +951,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 456, @@ -971,19 +971,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 }, { "seed": 789, @@ -991,27 +991,27 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.19330921253666428, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.306357982190828, + "crowding": 0.0, + "sprawl": 1.3236092942819244, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927968, - "aspect_penalty": 0.3511273855119561, + "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.634408856435263 + "weighted_cost": 0.3309023235704811 } ], - "median_cost": 0.634408856435263, + "median_cost": 0.3309023235704811, "spread": [ - 0.634408856435263, - 0.634408856435263 + 0.3309023235704811, + 0.3309023235704811 ], - "best_of_k_cost": 0.634408856435263, + "best_of_k_cost": 0.3309023235704811, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -1023,21 +1023,21 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03997195553746291, + "node_connector_overlap": 0.042102345290493094, "label_overlap": 0.0, - "label_connector_overlap": 0.2297491595217757, - "crossings": 0.2, - "crowding": 0.054327058469548176, - "sprawl": 2.245355916163957, + "label_connector_overlap": 0.06553822127566591, + "crossings": 0.3333333333333333, + "crowding": 0.0, + "sprawl": 2.2462060717947483, "long_connectors": 0.0, - "edge_length_cv": 0.4249382812737304, + "edge_length_cv": 0.4252921239131767, "aspect_penalty": 0.0, "misalignment": 0.4117647058823529, - "loop_compactness": 0.9369490344714397, + "loop_compactness": 0.8312623423442776, "flow_bends": 0.0, - "loop_straightness": 0.5668313546673985 + "loop_straightness": 0.43027969649994674 }, - "weighted_cost": 1.712872907711678 + "weighted_cost": 1.4941062509524468 }, { "seed": 1, @@ -1045,10 +1045,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.010443247137025317, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.28722220990213276, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, "crowding": 0.17647058823529413, - "sprawl": 1.4217194848058616, + "sprawl": 1.3956262778951116, "long_connectors": 0.0, "edge_length_cv": 0.3431770636033404, "aspect_penalty": 0.0, @@ -1057,47 +1057,47 @@ "flow_bends": 0.0, "loop_straightness": 0.39076126579718773 }, - "weighted_cost": 1.5231829860848687 + "weighted_cost": 1.1929692266468392 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006884632504338363, + "node_connector_overlap": 0.006884643227649289, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.13618306151946574, + "label_connector_overlap": 0.020304421681628753, "crossings": 0.06666666666666667, "crowding": 0.17647058823529413, - "sprawl": 1.474972224139633, + "sprawl": 1.458072207253222, "long_connectors": 0.0, - "edge_length_cv": 0.39531305968667735, + "edge_length_cv": 0.39532563544817556, "aspect_penalty": 0.0, "misalignment": 0.17647058823529416, "loop_compactness": 0.6736648685514584, "flow_bends": 0.0, "loop_straightness": 0.43350788146500446 }, - "weighted_cost": 1.1659570583319872 + "weighted_cost": 0.9879141158002509 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.000011202542721830981, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.24916127495893364, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "crowding": 0.1803147611697808, - "sprawl": 1.6904032611073978, + "crowding": 0.17647058823529413, + "sprawl": 1.6277153206557222, "long_connectors": 0.0, - "edge_length_cv": 0.6184917294273167, + "edge_length_cv": 0.629801673006797, "aspect_penalty": 0.0, "misalignment": 0.3529411764705882, - "loop_compactness": 0.38449846215069705, + "loop_compactness": 0.39894748422337123, "flow_bends": 0.0, - "loop_straightness": 0.3205882211495155 + "loop_straightness": 0.39624163645771643 }, - "weighted_cost": 1.3367122425572828 + "weighted_cost": 1.0639643846596674 }, { "seed": 4, @@ -1105,10 +1105,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.010443247137025317, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.28722220990213276, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, "crowding": 0.17647058823529413, - "sprawl": 1.4217194848058616, + "sprawl": 1.3956262778951116, "long_connectors": 0.0, "edge_length_cv": 0.3431770636033404, "aspect_penalty": 0.0, @@ -1117,7 +1117,7 @@ "flow_bends": 0.0, "loop_straightness": 0.39076126579718773 }, - "weighted_cost": 1.5231829860848687 + "weighted_cost": 1.1929692266468392 }, { "seed": 5, @@ -1125,19 +1125,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.2338678534851391, + "label_connector_overlap": 0.0, "crossings": 0.13333333333333333, "crowding": 0.17647058823529413, - "sprawl": 1.4087922337425303, + "sprawl": 1.3644014590436446, "long_connectors": 0.0, - "edge_length_cv": 0.40037381115878795, + "edge_length_cv": 0.4148582598924626, "aspect_penalty": 0.0, "misalignment": 0.23529411764705888, - "loop_compactness": 0.37192154364966035, + "loop_compactness": 0.3737982363666196, "flow_bends": 0.0, - "loop_straightness": 0.6074582966259526 + "loop_straightness": 0.6666666666666666 }, - "weighted_cost": 1.2514167148357636 + "weighted_cost": 0.8961887550241885 }, { "seed": 6, @@ -1145,10 +1145,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.016950561755362952, "label_overlap": 0.0015911702047513103, - "label_connector_overlap": 0.28543964821675927, + "label_connector_overlap": 0.06848921404951298, "crossings": 0.2, "crowding": 0.17647058823529413, - "sprawl": 1.7336729895308924, + "sprawl": 1.6922294299280263, "long_connectors": 0.0, "edge_length_cv": 0.5031401402309018, "aspect_penalty": 0.0, @@ -1157,67 +1157,67 @@ "flow_bends": 0.0, "loop_straightness": 0.3081093890430895 }, - "weighted_cost": 1.60334693308102 + "weighted_cost": 1.267560391929434 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01586118306043599, + "node_connector_overlap": 0.014806678434385297, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.32345482875933973, + "label_connector_overlap": 0.13981623684865277, "crossings": 0.06666666666666667, - "crowding": 0.17914302463001489, - "sprawl": 1.67267387675816, + "crowding": 0.17647058823529413, + "sprawl": 1.6241583223310254, "long_connectors": 0.0, - "edge_length_cv": 0.45338724107118894, + "edge_length_cv": 0.4529341737892187, "aspect_penalty": 0.0, "misalignment": 0.3529411764705882, "loop_compactness": 0.5775948806524611, "flow_bends": 0.0, "loop_straightness": 0.36560054449676893 }, - "weighted_cost": 1.4893439898204528 + "weighted_cost": 1.1969757677008166 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01896310646224659, + "node_connector_overlap": 0.0189412605675763, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.28110790015332676, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, - "crowding": 0.21218486357093913, - "sprawl": 1.479629582520823, + "crowding": 0.18458679653650567, + "sprawl": 1.4604617019709278, "long_connectors": 0.0, - "edge_length_cv": 0.36996658552386863, + "edge_length_cv": 0.36088212844591855, "aspect_penalty": 0.0, "misalignment": 0.4117647058823529, - "loop_compactness": 0.709864853181061, + "loop_compactness": 0.724698316725964, "flow_bends": 0.0, - "loop_straightness": 0.4437347416119741 + "loop_straightness": 0.442280222829501 }, - "weighted_cost": 1.5500786374274482 + "weighted_cost": 1.2089138489187812 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.016996776375112654, + "node_connector_overlap": 0.013523078691317067, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.18406504429001286, + "label_connector_overlap": 0.12912017767611159, "crossings": 0.13333333333333333, - "crowding": 0.18382352941176472, - "sprawl": 1.6666592240736395, + "crowding": 0.17647058823529413, + "sprawl": 1.633569427687596, "long_connectors": 0.0, - "edge_length_cv": 0.5179964575979691, + "edge_length_cv": 0.5140646593053726, "aspect_penalty": 0.0, "misalignment": 0.3529411764705882, - "loop_compactness": 0.8709647629948463, + "loop_compactness": 0.8700934705537196, "flow_bends": 0.0, - "loop_straightness": 0.3561572024324124 + "loop_straightness": 0.35385406015170545 }, - "weighted_cost": 1.4687776267536208 + "weighted_cost": 1.3632087099876748 }, { "seed": 456, @@ -1225,10 +1225,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.010443247137025317, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.28722220990213276, + "label_connector_overlap": 0.07142857142857142, "crossings": 0.13333333333333333, "crowding": 0.17647058823529413, - "sprawl": 1.4217194848058616, + "sprawl": 1.3956262778951116, "long_connectors": 0.0, "edge_length_cv": 0.3431770636033404, "aspect_penalty": 0.0, @@ -1237,37 +1237,37 @@ "flow_bends": 0.0, "loop_straightness": 0.39076126579718773 }, - "weighted_cost": 1.5231829860848687 + "weighted_cost": 1.1929692266468392 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00896593212003968, + "node_connector_overlap": 0.011460500259803375, "label_overlap": 0.001591170204751304, - "label_connector_overlap": 0.186137609443811, + "label_connector_overlap": 0.11288023459216052, "crossings": 0.2, "crowding": 0.17647058823529413, - "sprawl": 1.7365983068126392, + "sprawl": 1.7213479263074025, "long_connectors": 0.0, - "edge_length_cv": 0.4317694697031315, + "edge_length_cv": 0.43125539140305297, "aspect_penalty": 0.0, - "misalignment": 0.2941176470588235, - "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": 1.460881004761533 + "weighted_cost": 1.3576807900874996 } ], - "median_cost": 1.5062634879526606, + "median_cost": 1.1949724971738278, "spread": [ - 1.4298388142104705, - 1.5299068989205136 + 1.1607180161500463, + 1.2900904914689504 ], - "best_of_k_cost": 1.460881004761533, - "best_seed": 2, - "median_seed": 7, + "best_of_k_cost": 1.1929692266468392, + "best_seed": 5, + "median_seed": 1, "worst_seed": 0 }, { @@ -1277,21 +1277,21 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.008279104282179942, + "node_connector_overlap": 0.008265677667601688, "label_overlap": 0.0, - "label_connector_overlap": 0.45522548426241727, + "label_connector_overlap": 0.23816201992846991, "crossings": 0.13043478260869565, - "crowding": 0.03409152100595501, - "sprawl": 1.3514239876520624, + "crowding": 0.03369140625, + "sprawl": 1.2946902622233476, "long_connectors": 0.0, - "edge_length_cv": 0.35823152843349637, + "edge_length_cv": 0.362844395381303, "aspect_penalty": 0.0, - "misalignment": 0.25, - "loop_compactness": 0.5672715847487961, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.5597425593094773, "flow_bends": 0.0, - "loop_straightness": 0.28562289053676365 + "loop_straightness": 0.29938947865144294 }, - "weighted_cost": 1.4822496584388467 + "weighted_cost": 1.1570757778980427 }, { "seed": 1, @@ -1299,19 +1299,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.47523878287413235, + "label_connector_overlap": 0.22077137000646457, "crossings": 0.08695652173913043, - "crowding": 0.033705510904926114, - "sprawl": 1.3536996175384342, + "crowding": 0.02698707268960003, + "sprawl": 1.2946409991818164, "long_connectors": 0.0, - "edge_length_cv": 0.41197926925448225, + "edge_length_cv": 0.4111066744900999, "aspect_penalty": 0.0, "misalignment": 0.41666666666666663, - "loop_compactness": 0.5099253512543144, + "loop_compactness": 0.5108629319882905, "flow_bends": 0.0, - "loop_straightness": 0.2763249943391913 + "loop_straightness": 0.27592496070277484 }, - "weighted_cost": 1.4452144179421753 + "weighted_cost": 1.0423652347661418 }, { "seed": 2, @@ -1319,19 +1319,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.5103000856958216, + "label_connector_overlap": 0.2557227106807807, "crossings": 0.13043478260869565, - "crowding": 0.038248697916666664, - "sprawl": 1.242537298018221, + "crowding": 0.004069010416666667, + "sprawl": 1.208444442119752, "long_connectors": 0.0, - "edge_length_cv": 0.34985659343977915, + "edge_length_cv": 0.3518721660252704, "aspect_penalty": 0.0, "misalignment": 0.375, - "loop_compactness": 0.5880644701143496, + "loop_compactness": 0.5859438451467865, "flow_bends": 0.0, - "loop_straightness": 0.323909822740439 + "loop_straightness": 0.3196040910090745 }, - "weighted_cost": 1.5498847038934338 + "weighted_cost": 1.1240369167360933 }, { "seed": 3, @@ -1339,19 +1339,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.4839538756492726, + "label_connector_overlap": 0.3203554975825508, "crossings": 0.13043478260869565, - "crowding": 0.030436197916666668, - "sprawl": 1.2625746708820178, + "crowding": 0.0, + "sprawl": 1.2327400821305925, "long_connectors": 0.0, - "edge_length_cv": 0.39247217657047684, + "edge_length_cv": 0.3924468315253192, "aspect_penalty": 0.0, "misalignment": 0.375, - "loop_compactness": 0.5879669671422784, + "loop_compactness": 0.5879680436888179, "flow_bends": 0.0, - "loop_straightness": 0.3814647471849522 + "loop_straightness": 0.38216139748609135 }, - "weighted_cost": 1.5132787232951823 + "weighted_cost": 1.2300564067393063 }, { "seed": 4, @@ -1359,10 +1359,10 @@ "node_overlap": 0.0, "node_connector_overlap": 0.017608053232295456, "label_overlap": 0.0, - "label_connector_overlap": 0.45609373650081714, + "label_connector_overlap": 0.24148129310790792, "crossings": 0.17391304347826086, - "crowding": 0.03520593869346284, - "sprawl": 1.4414867201375652, + "crowding": 0.011442917860129508, + "sprawl": 1.4019160475931465, "long_connectors": 0.0, "edge_length_cv": 0.4624551097823725, "aspect_penalty": 0.0, @@ -1371,7 +1371,7 @@ "flow_bends": 0.0, "loop_straightness": 0.31811265944508005 }, - "weighted_cost": 1.6165226003112538 + "weighted_cost": 1.2609482462524517 }, { "seed": 5, @@ -1379,99 +1379,99 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.4478260993701819, + "label_connector_overlap": 0.26531879320146456, "crossings": 0.13043478260869565, - "crowding": 0.03564453124999981, - "sprawl": 1.227002426202059, + "crowding": 0.0, + "sprawl": 1.19308161683157, "long_connectors": 0.0, - "edge_length_cv": 0.3367031327479818, + "edge_length_cv": 0.33626627001651355, "aspect_penalty": 0.0, "misalignment": 0.375, - "loop_compactness": 0.5874886731720569, + "loop_compactness": 0.5857287163762109, "flow_bends": 0.0, - "loop_straightness": 0.3353784035130565 + "loop_straightness": 0.33413277376950734 }, - "weighted_cost": 1.4506023790846114 + "weighted_cost": 1.13188814054622 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006441756573366715, + "node_connector_overlap": 0.0059383484576128985, "label_overlap": 0.0, - "label_connector_overlap": 0.45049457310186675, + "label_connector_overlap": 0.24252778590222152, "crossings": 0.13043478260869565, - "crowding": 0.031087239583333332, - "sprawl": 1.4251170459695213, + "crowding": 0.0, + "sprawl": 1.8651813761484, "long_connectors": 0.0, - "edge_length_cv": 0.49904304449548154, - "aspect_penalty": 0.004879319457522735, + "edge_length_cv": 0.4673694424647331, + "aspect_penalty": 0.17065742907074588, "misalignment": 0.41666666666666663, - "loop_compactness": 0.6530513344789997, + "loop_compactness": 0.6440253129993527, "flow_bends": 0.0, - "loop_straightness": 0.4506421126685112 + "loop_straightness": 0.45268154624588436 }, - "weighted_cost": 1.5543780682090604 + "weighted_cost": 1.31694344890535 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00767625389432729, + "node_connector_overlap": 0.007671439509169212, "label_overlap": 0.0, - "label_connector_overlap": 0.4816322285980764, + "label_connector_overlap": 0.39868299238010657, "crossings": 0.21739130434782608, - "crowding": 0.027959192374642543, - "sprawl": 1.426696876584905, + "crowding": 0.004069010416666667, + "sprawl": 1.4004517881583005, "long_connectors": 0.0, - "edge_length_cv": 0.4040249217858767, + "edge_length_cv": 0.4029477902491051, "aspect_penalty": 0.0, "misalignment": 0.33333333333333337, "loop_compactness": 0.6348843392039653, "flow_bends": 0.0, "loop_straightness": 0.35556746793305993 }, - "weighted_cost": 1.6626693823626897 + "weighted_cost": 1.5077844452007918 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.017777328969124467, + "node_connector_overlap": 0.010176431802497717, "label_overlap": 0.0, - "label_connector_overlap": 0.3984603017501767, - "crossings": 0.21739130434782608, - "crowding": 0.0365604125328968, - "sprawl": 1.3446045008003582, - "long_connectors": 0.005678356215624773, - "edge_length_cv": 0.5260018856550409, + "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, - "misalignment": 0.33333333333333337, - "loop_compactness": 0.6338290885840142, + "misalignment": 0.41666666666666663, + "loop_compactness": 0.5858511381448802, "flow_bends": 0.0, - "loop_straightness": 0.34112832371805724 + "loop_straightness": 0.4634359137526274 }, - "weighted_cost": 1.5471649318908836 + "weighted_cost": 1.2697614359533085 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.003099928440121134, + "node_connector_overlap": 0.0030925233724442397, "label_overlap": 0.0, - "label_connector_overlap": 0.474945226869136, + "label_connector_overlap": 0.26788467876560634, "crossings": 0.13043478260869565, - "crowding": 0.032027043502712536, - "sprawl": 1.2762442105738219, + "crowding": 0.004257739706929184, + "sprawl": 1.240155384650455, "long_connectors": 0.0, - "edge_length_cv": 0.4623845645928669, + "edge_length_cv": 0.46018064416194987, "aspect_penalty": 0.0, "misalignment": 0.29166666666666663, - "loop_compactness": 0.5334016591520779, + "loop_compactness": 0.5333654363515483, "flow_bends": 0.0, - "loop_straightness": 0.3520393790164842 + "loop_straightness": 0.3501709982997821 }, - "weighted_cost": 1.4778718441679561 + "weighted_cost": 1.1302733744088005 }, { "seed": 456, @@ -1479,50 +1479,50 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.4478260993701819, + "label_connector_overlap": 0.26531879320146456, "crossings": 0.13043478260869565, - "crowding": 0.03564453124999981, - "sprawl": 1.227002426202059, + "crowding": 0.0, + "sprawl": 1.19308161683157, "long_connectors": 0.0, - "edge_length_cv": 0.3367031327479818, + "edge_length_cv": 0.33626627001651355, "aspect_penalty": 0.0, "misalignment": 0.375, - "loop_compactness": 0.5874886731720569, + "loop_compactness": 0.5857287163762109, "flow_bends": 0.0, - "loop_straightness": 0.3353784035130565 + "loop_straightness": 0.33413277376950734 }, - "weighted_cost": 1.4506023790846114 + "weighted_cost": 1.13188814054622 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.018119308363776246, + "node_connector_overlap": 0.018072279372278226, "label_overlap": 0.0, - "label_connector_overlap": 0.49772083698144276, + "label_connector_overlap": 0.2314158484527029, "crossings": 0.17391304347826086, - "crowding": 0.027863607870441662, - "sprawl": 1.5674991792532653, + "crowding": 0.0, + "sprawl": 1.5342383319123571, "long_connectors": 0.0, - "edge_length_cv": 0.5612590549553415, + "edge_length_cv": 0.5562754595332245, "aspect_penalty": 0.0, "misalignment": 0.33333333333333337, "loop_compactness": 0.674276852188812, "flow_bends": 0.0, "loop_straightness": 0.4116513268461032 }, - "weighted_cost": 1.720680525255204 + "weighted_cost": 1.2849501647734294 } ], - "median_cost": 1.5302218275930328, + "median_cost": 1.1935660923186746, "spread": [ - 1.47105447789712, - 1.5699142012346088 + 1.1314844490118652, + 1.2735586181583387 ], - "best_of_k_cost": 1.4506023790846114, + "best_of_k_cost": 1.1302733744088005, "best_seed": 1, "median_seed": 3, - "worst_seed": 789 + "worst_seed": 7 }, { "model": "lotka_volterra", @@ -1531,252 +1531,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.023437544936108005, + "node_connector_overlap": 0.02346760824682478, "label_overlap": 0.0, - "label_connector_overlap": 0.30923899201764815, + "label_connector_overlap": 0.03709829278069446, "crossings": 0.1, - "crowding": 0.0005486896484388827, - "sprawl": 1.3688801775401178, + "crowding": 0.0, + "sprawl": 1.2920381800408944, "long_connectors": 0.0, - "edge_length_cv": 0.3330810559825201, + "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.9985023119321567 + "weighted_cost": 0.5705922006749149 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.022921332894019143, + "node_connector_overlap": 0.022161395358868782, "label_overlap": 0.0, - "label_connector_overlap": 0.34857898128243503, + "label_connector_overlap": 0.07384141068976431, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.3916468939981192, + "sprawl": 1.3693358003411529, "long_connectors": 0.0, - "edge_length_cv": 0.3647254692177101, + "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.9616228612112206 + "weighted_cost": 0.5424188568376723 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.023749214382369373, + "node_connector_overlap": 0.023265920999116684, "label_overlap": 0.0, - "label_connector_overlap": 0.35932343575343917, + "label_connector_overlap": 0.06224642446212526, "crossings": 0.05, "crowding": 0.0, - "sprawl": 1.3516042938761283, + "sprawl": 1.3040644887277486, "long_connectors": 0.0, - "edge_length_cv": 0.35850350782526264, + "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": 1.0143846558639296 + "weighted_cost": 0.5559176008733584 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.022944846834058216, + "node_connector_overlap": 0.02296537700770215, "label_overlap": 0.0, - "label_connector_overlap": 0.3265886602348098, + "label_connector_overlap": 0.05425683728778813, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.3903049591920005, + "sprawl": 1.3123673600673518, "long_connectors": 0.0, - "edge_length_cv": 0.35907392781953873, + "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.9283489238183313 + "weighted_cost": 0.5004078499639245 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01946054669788179, + "node_connector_overlap": 0.019519701056219835, "label_overlap": 0.0, - "label_connector_overlap": 0.3263538122233368, + "label_connector_overlap": 0.08575477358688569, "crossings": 0.0, - "crowding": 0.003125, - "sprawl": 1.3670930117552724, + "crowding": 0.0, + "sprawl": 1.3013825149720775, "long_connectors": 0.0, - "edge_length_cv": 0.3843983289426198, + "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.918350064669587 + "weighted_cost": 0.5380171912357876 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012167927274499071, + "node_connector_overlap": 0.012180155833699807, "label_overlap": 0.0, - "label_connector_overlap": 0.2550702528352603, + "label_connector_overlap": 0.04221245977985824, "crossings": 0.0, - "crowding": 0.0031249999999998224, - "sprawl": 1.4582946858287442, + "crowding": 0.0, + "sprawl": 1.3976066055447958, "long_connectors": 0.0, - "edge_length_cv": 0.3328034682006411, + "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.8196399052590745 + "weighted_cost": 0.4820806527233859 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.022154939341986424, + "node_connector_overlap": 0.022174586513571876, "label_overlap": 0.0, - "label_connector_overlap": 0.279623255489416, + "label_connector_overlap": 0.03753592519695266, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.4018710259365192, + "sprawl": 1.3395944918568847, "long_connectors": 0.0, - "edge_length_cv": 0.33320477642808993, + "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.8642125184022267 + "weighted_cost": 0.4855516837867939 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.036268897911063705, + "node_connector_overlap": 0.035833282280642836, "label_overlap": 0.0, - "label_connector_overlap": 0.23922613058531195, - "crossings": 0.05, - "crowding": 0.008395267017583653, - "sprawl": 1.3388590712050135, + "label_connector_overlap": 0.04560043408388389, + "crossings": 0.1, + "crowding": 0.0, + "sprawl": 1.28543060098147, "long_connectors": 0.0, - "edge_length_cv": 0.384545597004169, + "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.8594870265189324 + "weighted_cost": 0.596424865932479 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01671014307667795, + "node_connector_overlap": 0.015391746459467039, "label_overlap": 0.0, - "label_connector_overlap": 0.2709549272500089, + "label_connector_overlap": 0.047188973981440355, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.4604111735887648, + "sprawl": 1.432797667484931, "long_connectors": 0.0, - "edge_length_cv": 0.4305317304608594, - "aspect_penalty": 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.8499554704255605 + "weighted_cost": 0.5047663707623273 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.023437544936108005, + "node_connector_overlap": 0.02346760824682478, "label_overlap": 0.0, - "label_connector_overlap": 0.30923899201764815, + "label_connector_overlap": 0.03709829278069446, "crossings": 0.1, - "crowding": 0.0005486896484388827, - "sprawl": 1.3688801775401178, + "crowding": 0.0, + "sprawl": 1.2920381800408944, "long_connectors": 0.0, - "edge_length_cv": 0.3330810559825201, + "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.9985023119321567 + "weighted_cost": 0.5705922006749149 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.026067546259404724, + "node_connector_overlap": 0.024538137828128092, "label_overlap": 0.0, - "label_connector_overlap": 0.29843853731536085, + "label_connector_overlap": 0.05092980347847856, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.3497259222338067, + "sprawl": 1.3287647924745603, "long_connectors": 0.0, - "edge_length_cv": 0.3747864843935116, + "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.8822243790503025 + "weighted_cost": 0.5026621789926141 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02531142633634823, + "node_connector_overlap": 0.02381840436955773, "label_overlap": 0.0, - "label_connector_overlap": 0.24189731120462743, + "label_connector_overlap": 0.09244678862113376, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.38640211682805, + "sprawl": 1.3738068067015583, "long_connectors": 0.0, - "edge_length_cv": 0.3396013719644028, + "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.7950693486866501 + "weighted_cost": 0.5647586933462058 } ], - "median_cost": 0.9002872218599447, + "median_cost": 0.54021802403673, "spread": [ - 0.8571041374955894, - 0.9708427238914547 + 0.5020985967354418, + 0.5662170701783831 ], - "best_of_k_cost": 0.7950693486866501, - "best_seed": 789, - "median_seed": 456, - "worst_seed": 2 + "best_of_k_cost": 0.5026621789926141, + "best_seed": 5, + "median_seed": 1, + "worst_seed": 7 }, { "model": "workforce", @@ -1785,252 +1785,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007096173654460949, + "node_connector_overlap": 0.017146032863112688, "label_overlap": 0.0, - "label_connector_overlap": 0.3831631192975692, - "crossings": 0.10714285714285714, - "crowding": 0.003324436952535634, - "sprawl": 1.5686134831362382, + "label_connector_overlap": 0.18360895824554635, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 1.4260046812482687, "long_connectors": 0.0, - "edge_length_cv": 0.42562781840399644, + "edge_length_cv": 0.40279064974933687, "aspect_penalty": 0.0, - "misalignment": 0.5862068965517242, + "misalignment": 0.5517241379310345, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1501783807899004 + "weighted_cost": 0.8593101216824742 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007372083988457059, + "node_connector_overlap": 0.02776835401010422, "label_overlap": 0.0, - "label_connector_overlap": 0.3942311725989049, - "crossings": 0.10714285714285714, - "crowding": 0.0021551724137931034, - "sprawl": 1.5216223358137748, + "label_connector_overlap": 0.10230548256668706, + "crossings": 0.06896551724137931, + "crowding": 0.0, + "sprawl": 1.3983717985673354, "long_connectors": 0.0, - "edge_length_cv": 0.37860605642109085, + "edge_length_cv": 0.4632082190555329, "aspect_penalty": 0.0, - "misalignment": 0.5172413793103448, + "misalignment": 0.5517241379310345, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1475186783164 + "weighted_cost": 0.6827258125465556 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007230642203432912, + "node_connector_overlap": 0.02479744691244747, "label_overlap": 0.0, - "label_connector_overlap": 0.41324716597275996, - "crossings": 0.14285714285714285, + "label_connector_overlap": 0.1258218292364085, + "crossings": 0.06896551724137931, "crowding": 0.0, - "sprawl": 1.5286991198661164, + "sprawl": 1.4985880635396216, "long_connectors": 0.0, - "edge_length_cv": 0.430465606971024, + "edge_length_cv": 0.4152880511308788, "aspect_penalty": 0.0, - "misalignment": 0.48275862068965514, + "misalignment": 0.5172413793103448, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.2076398182586434 + "weighted_cost": 0.7336643087368269 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007195837858323048, + "node_connector_overlap": 0.01672743961399548, "label_overlap": 0.0, - "label_connector_overlap": 0.3613413210814754, - "crossings": 0.10714285714285714, - "crowding": 0.0076207746312177875, - "sprawl": 1.5473311750226575, + "label_connector_overlap": 0.08513070625825366, + "crossings": 0.06896551724137931, + "crowding": 0.0, + "sprawl": 1.4603066908316127, "long_connectors": 0.0, - "edge_length_cv": 0.4432369079778298, + "edge_length_cv": 0.4250280254049808, "aspect_penalty": 0.0, - "misalignment": 0.5517241379310345, + "misalignment": 0.3793103448275862, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.113172496661702 + "weighted_cost": 0.6331241630474125 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01272653444764572, + "node_connector_overlap": 0.00814542284205888, "label_overlap": 0.0, - "label_connector_overlap": 0.4149870412567001, - "crossings": 0.10714285714285714, - "crowding": 0.0, - "sprawl": 1.4931566983119746, + "label_connector_overlap": 0.14442561667233497, + "crossings": 0.06896551724137931, + "crowding": 0.002130096332936282, + "sprawl": 1.502583771308146, "long_connectors": 0.0, - "edge_length_cv": 0.39034079040783465, + "edge_length_cv": 0.45496480581890303, "aspect_penalty": 0.0, - "misalignment": 0.5862068965517242, + "misalignment": 0.4137931034482759, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1869863521563648 + "weighted_cost": 0.7210501374387998 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007044712287514318, + "node_connector_overlap": 0.017782334862105377, "label_overlap": 0.0, - "label_connector_overlap": 0.4242823233303506, - "crossings": 0.07142857142857142, - "crowding": 0.0022445819034710105, - "sprawl": 1.577684524150105, + "label_connector_overlap": 0.21978870573567452, + "crossings": 0.1724137931034483, + "crowding": 0.0005387931034482759, + "sprawl": 1.4901200045786953, "long_connectors": 0.0, - "edge_length_cv": 0.3828168162248467, + "edge_length_cv": 0.40500084958439386, "aspect_penalty": 0.0, - "misalignment": 0.5517241379310345, + "misalignment": 0.31034482758620685, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1737796077332265 + "weighted_cost": 0.9417647984379136 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007293054101507116, + "node_connector_overlap": 0.016909961215100675, "label_overlap": 0.0, - "label_connector_overlap": 0.4056034829542973, - "crossings": 0.10714285714285714, - "crowding": 0.0020866992238851305, - "sprawl": 1.5386164019189574, + "label_connector_overlap": 0.20746347615603422, + "crossings": 0.034482758620689655, + "crowding": 0.0, + "sprawl": 1.4544656422902518, "long_connectors": 0.0, - "edge_length_cv": 0.37753173641838067, + "edge_length_cv": 0.4125871346381366, "aspect_penalty": 0.0, - "misalignment": 0.4137931034482759, + "misalignment": 0.5862068965517242, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1582542998257694 + "weighted_cost": 0.8017349955126777 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0, + "node_connector_overlap": 0.0228706946843528, "label_overlap": 0.0, - "label_connector_overlap": 0.3582282189958258, - "crossings": 0.14285714285714285, - "crowding": 0.0016131454675492039, - "sprawl": 1.6589526087823843, + "label_connector_overlap": 0.21121290373900045, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 1.4806563104871846, "long_connectors": 0.0, - "edge_length_cv": 0.3956640799785136, + "edge_length_cv": 0.4191869234144257, "aspect_penalty": 0.0, - "misalignment": 0.5172413793103448, + "misalignment": 0.5862068965517242, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1482749069450613 + "weighted_cost": 0.9292765467369334 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007168294762011524, + "node_connector_overlap": 0.018420993186962144, "label_overlap": 0.0, - "label_connector_overlap": 0.3881828383997622, - "crossings": 0.10714285714285714, - "crowding": 0.0021551724137931034, - "sprawl": 1.5873161079071918, + "label_connector_overlap": 0.1617679597305636, + "crossings": 0.06896551724137931, + "crowding": 0.0053230631791927295, + "sprawl": 1.4746040963544904, "long_connectors": 0.0, - "edge_length_cv": 0.40350336175353513, + "edge_length_cv": 0.4668744386087121, "aspect_penalty": 0.0, - "misalignment": 0.48275862068965514, + "misalignment": 0.3793103448275862, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1510137657260802 + "weighted_cost": 0.7603645649617229 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006936875618217165, + "node_connector_overlap": 0.013024338994816217, "label_overlap": 0.0, - "label_connector_overlap": 0.48043202983842237, - "crossings": 0.07142857142857142, - "crowding": 0.0, - "sprawl": 1.555066311306872, + "label_connector_overlap": 0.20213285028177888, + "crossings": 0.06896551724137931, + "crowding": 0.013276634761051115, + "sprawl": 1.5854897800428382, "long_connectors": 0.0, - "edge_length_cv": 0.40687165760227795, + "edge_length_cv": 0.4254578498452355, "aspect_penalty": 0.0, - "misalignment": 0.5517241379310345, + "misalignment": 0.5172413793103448, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.2498893590424607 + "weighted_cost": 0.8595866883564752 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007049387257671134, + "node_connector_overlap": 0.008331988268351954, "label_overlap": 0.0, - "label_connector_overlap": 0.4121873369791232, - "crossings": 0.10714285714285714, - "crowding": 0.0032336379715036355, - "sprawl": 1.6038713865617287, + "label_connector_overlap": 0.18051992197517397, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 1.4893764280344155, "long_connectors": 0.0, - "edge_length_cv": 0.39821972648448384, + "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": 1.1816551562215787 + "weighted_cost": 0.835650035473586 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009789842512974278, + "node_connector_overlap": 0.017262537043145617, "label_overlap": 0.0, - "label_connector_overlap": 0.3925058289873281, - "crossings": 0.03571428571428571, - "crowding": 0.0023286805153446043, - "sprawl": 1.5680390023361215, + "label_connector_overlap": 0.21293780758834416, + "crossings": 0.10344827586206896, + "crowding": 0.0, + "sprawl": 1.435268123600494, "long_connectors": 0.0, - "edge_length_cv": 0.4231059257764172, + "edge_length_cv": 0.39877856335743417, "aspect_penalty": 0.0, - "misalignment": 0.48275862068965514, + "misalignment": 0.5862068965517242, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.086667007389567 + "weighted_cost": 0.8748177818861724 } ], - "median_cost": 1.1546340327759248, + "median_cost": 0.8186925154931318, "spread": [ - 1.148085849787896, - 1.182987955205275 + 0.7305107659123201, + 0.8633944617388996 ], - "best_of_k_cost": 1.086667007389567, - "best_seed": 789, + "best_of_k_cost": 0.7603645649617229, + "best_seed": 3, "median_seed": 6, - "worst_seed": 123 + "worst_seed": 5 }, { "model": "bathtub", @@ -2038,253 +2038,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.03843090402230209, - "node_connector_overlap": 0.0260660483443771, + "node_overlap": 0.0, + "node_connector_overlap": 0.02481036801335007, "label_overlap": 0.0, - "label_connector_overlap": 0.27287474465028355, - "crossings": 0.3, - "crowding": 0.0362288492853644, - "sprawl": 2.2631856113900914, + "label_connector_overlap": 0.05247536896546643, + "crossings": 0.2413793103448276, + "crowding": 0.006772602500018941, + "sprawl": 1.9636989818820405, "long_connectors": 0.0, - "edge_length_cv": 0.4062515431103036, + "edge_length_cv": 0.5234301260715261, "aspect_penalty": 0.0, - "misalignment": 0.4193548387096774, + "misalignment": 0.6129032258064516, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.539913113746092 + "weighted_cost": 0.9287007703709016 }, { "seed": 1, "metrics": { - "node_overlap": 0.05933890880127461, - "node_connector_overlap": 0.0413732296411515, + "node_overlap": 0.0, + "node_connector_overlap": 0.032189087861847925, "label_overlap": 0.0, - "label_connector_overlap": 0.24166661086516772, - "crossings": 0.35, - "crowding": 0.03225806451612903, - "sprawl": 1.727292831921362, + "label_connector_overlap": 0.03123557026293422, + "crossings": 0.3103448275862069, + "crowding": 0.000041273719404344137, + "sprawl": 2.325003480759551, "long_connectors": 0.0, - "edge_length_cv": 0.4643517047023839, + "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.5154009256551788 + "weighted_cost": 1.0512555993877897 }, { "seed": 2, "metrics": { - "node_overlap": 0.05933890880127461, - "node_connector_overlap": 0.058252435757656436, + "node_overlap": 0.0, + "node_connector_overlap": 0.05745198224055181, "label_overlap": 0.0, - "label_connector_overlap": 0.24568176995990718, - "crossings": 0.3, - "crowding": 0.03616524641757505, - "sprawl": 1.7033079595297296, + "label_connector_overlap": 0.09243103286573354, + "crossings": 0.0, + "crowding": 0.0, + "sprawl": 1.7802306465821613, "long_connectors": 0.0, - "edge_length_cv": 0.4694257288668445, + "edge_length_cv": 0.5846152082831353, "aspect_penalty": 0.0, - "misalignment": 0.4516129032258065, + "misalignment": 0.3548387096774194, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.4998672338822228 + "weighted_cost": 0.7340920463929862 }, { "seed": 3, "metrics": { - "node_overlap": 0.03843090402230209, - "node_connector_overlap": 0.02667141285693712, + "node_overlap": 0.0, + "node_connector_overlap": 0.049538170903595725, "label_overlap": 0.0, - "label_connector_overlap": 0.2745549483130331, - "crossings": 0.3, - "crowding": 0.036009806643084605, - "sprawl": 2.5296885439406767, + "label_connector_overlap": 0.04688724108275967, + "crossings": 0.1724137931034483, + "crowding": 0.0, + "sprawl": 1.9464762123080672, "long_connectors": 0.0, - "edge_length_cv": 0.3724815996392209, + "edge_length_cv": 0.5154440749911563, "aspect_penalty": 0.0, - "misalignment": 0.4516129032258065, + "misalignment": 0.5483870967741935, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.6132766452123157 + "weighted_cost": 0.8832787592892154 }, { "seed": 4, "metrics": { - "node_overlap": 0.05933890880127461, - "node_connector_overlap": 0.052243661747580245, + "node_overlap": 0.0, + "node_connector_overlap": 0.010846704231684401, "label_overlap": 0.0, - "label_connector_overlap": 0.19084746212982187, - "crossings": 0.2, - "crowding": 0.05148575988338288, - "sprawl": 1.99273731768074, + "label_connector_overlap": 0.03449205136024432, + "crossings": 0.20689655172413793, + "crowding": 0.0, + "sprawl": 3.01341633347209, "long_connectors": 0.0, - "edge_length_cv": 0.511718803048681, + "edge_length_cv": 0.5009443768271582, "aspect_penalty": 0.0, - "misalignment": 0.4838709677419355, + "misalignment": 0.5806451612903225, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.3965018835721157 + "weighted_cost": 1.091746636724928 }, { "seed": 5, "metrics": { - "node_overlap": 0.05933890880127461, - "node_connector_overlap": 0.04432174520885116, + "node_overlap": 0.0, + "node_connector_overlap": 0.025364584795034076, "label_overlap": 0.0, - "label_connector_overlap": 0.355332389691121, - "crossings": 0.35, - "crowding": 0.034822293998417145, - "sprawl": 1.7081960019657263, + "label_connector_overlap": 0.02241110110367858, + "crossings": 0.20689655172413793, + "crowding": 0.0, + "sprawl": 2.2250166293005287, "long_connectors": 0.0, - "edge_length_cv": 0.4075234934106665, + "edge_length_cv": 0.5399511130534949, "aspect_penalty": 0.0, - "misalignment": 0.3870967741935484, + "misalignment": 0.5806451612903225, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.6799092276680487 + "weighted_cost": 0.9055610464238883 }, { "seed": 6, "metrics": { - "node_overlap": 0.059338908801274286, - "node_connector_overlap": 0.060503765917226095, + "node_overlap": 0.0, + "node_connector_overlap": 0.02367263115481229, "label_overlap": 0.0, - "label_connector_overlap": 0.33320346726103794, - "crossings": 0.4, - "crowding": 0.037471046596949595, - "sprawl": 1.7253503559370102, + "label_connector_overlap": 0.02264713943294708, + "crossings": 0.1724137931034483, + "crowding": 0.0, + "sprawl": 2.3852719569164007, "long_connectors": 0.0, - "edge_length_cv": 0.4655353854238205, + "edge_length_cv": 0.5458513793126593, "aspect_penalty": 0.0, - "misalignment": 0.4516129032258065, + "misalignment": 0.59375, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.742468839434252 + "weighted_cost": 0.9094227537915937 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.004471151519176097, - "label_overlap": 0.0, - "label_connector_overlap": 0.30197270752486405, - "crossings": 0.45, - "crowding": 0.0, - "sprawl": 9.275275776741196, + "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.4297485450095218, + "edge_length_cv": 0.549937528940735, "aspect_penalty": 0.0, - "misalignment": 0.8064516129032258, + "misalignment": 0.5161290322580645, "loop_compactness": 0.0, - "flow_bends": 0.6666666666666666, + "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 3.41136546980127 + "weighted_cost": 1.0224955288353066 }, { "seed": 42, "metrics": { - "node_overlap": 0.059338908801274286, - "node_connector_overlap": 0.049671288422938394, + "node_overlap": 0.0, + "node_connector_overlap": 0.06697476397037916, "label_overlap": 0.0, - "label_connector_overlap": 0.322100742120703, - "crossings": 0.2, - "crowding": 0.036290322580645164, - "sprawl": 1.7571851510009409, + "label_connector_overlap": 0.05413813842732086, + "crossings": 0.034482758620689655, + "crowding": 0.0, + "sprawl": 2.140618537747331, "long_connectors": 0.0, - "edge_length_cv": 0.40975555503988564, + "edge_length_cv": 0.5198030808975015, "aspect_penalty": 0.0, - "misalignment": 0.4193548387096774, + "misalignment": 0.625, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.5077019650332395 + "weighted_cost": 0.847294128639262 }, { "seed": 123, "metrics": { - "node_overlap": 0.05933890880127461, - "node_connector_overlap": 0.03353847459355867, + "node_overlap": 0.0, + "node_connector_overlap": 0.04720966810879345, "label_overlap": 0.0, - "label_connector_overlap": 0.30193283404331045, - "crossings": 0.3, - "crowding": 0.03768387653046769, - "sprawl": 1.6761472220654632, + "label_connector_overlap": 0.062051054705971734, + "crossings": 0.13793103448275862, + "crowding": 0.0, + "sprawl": 2.3180620659755564, "long_connectors": 0.0, - "edge_length_cv": 0.3461058945727681, + "edge_length_cv": 0.47676677337257317, "aspect_penalty": 0.0, - "misalignment": 0.4193548387096774, + "misalignment": 0.6451612903225806, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.5263185469743454 + "weighted_cost": 0.9694585982854502 }, { "seed": 456, "metrics": { - "node_overlap": 0.05933890880127461, - "node_connector_overlap": 0.05252143336657532, + "node_overlap": 0.0, + "node_connector_overlap": 0.04857225254569967, "label_overlap": 0.0, - "label_connector_overlap": 0.33695075828998844, - "crossings": 0.35, - "crowding": 0.034274193548387094, - "sprawl": 1.8618123710690255, + "label_connector_overlap": 0.03708101497530043, + "crossings": 0.10344827586206896, + "crowding": 1.1122471202096628e-6, + "sprawl": 1.765129544572536, "long_connectors": 0.0, - "edge_length_cv": 0.3779757938288533, + "edge_length_cv": 0.5160068500493691, "aspect_penalty": 0.0, - "misalignment": 0.5161290322580645, + "misalignment": 0.6875, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.7194953745140442 + "weighted_cost": 0.7662478018066731 }, { "seed": 789, "metrics": { - "node_overlap": 0.059338908801274286, - "node_connector_overlap": 0.0471407927792872, + "node_overlap": 0.0, + "node_connector_overlap": 0.046854552476137394, "label_overlap": 0.0, - "label_connector_overlap": 0.24890300029128595, - "crossings": 0.2, - "crowding": 0.03508675781145527, - "sprawl": 1.7247062831720361, - "long_connectors": 0.0, - "edge_length_cv": 0.4064406466959635, + "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.32258064516129037, + "misalignment": 0.6875, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.3738436599205568 + "weighted_cost": 1.0080991764813811 } ], - "median_cost": 1.5331158303602188, + "median_cost": 0.9190617620812476, "spread": [ - 1.5057432822454853, - 1.6898057643795477 + 0.8742826016267271, + 1.0116982645698624 ], - "best_of_k_cost": 1.3738436599205568, - "best_seed": 789, - "median_seed": 0, - "worst_seed": 7 + "best_of_k_cost": 0.7662478018066731, + "best_seed": 2, + "median_seed": 6, + "worst_seed": 4 }, { "model": "catastrophe", @@ -2292,253 +2292,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.04277921332184914, - "node_connector_overlap": 0.07649089632598491, - "label_overlap": 0.000015200904892467164, - "label_connector_overlap": 0.25697036325242006, - "crossings": 0.20588235294117646, - "crowding": 0.04917221579322804, - "sprawl": 1.283186195601195, + "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.34934364210837365, + "edge_length_cv": 0.4200608133724496, "aspect_penalty": 0.0, - "misalignment": 0.3023255813953488, - "loop_compactness": 0.784267125345664, + "misalignment": 0.41860465116279066, + "loop_compactness": 0.9263991210931695, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.6830083132366993 + "weighted_cost": 1.6824725371997409 }, { "seed": 1, "metrics": { - "node_overlap": 0.027706000574217547, - "node_connector_overlap": 0.05674552474641684, - "label_overlap": 0.0, - "label_connector_overlap": 0.3023687019186074, - "crossings": 0.29411764705882354, - "crowding": 0.028187147155785162, - "sprawl": 1.7956379724771934, + "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.3334752072040872, + "edge_length_cv": 0.41424993144874206, "aspect_penalty": 0.0, - "misalignment": 0.39534883720930236, - "loop_compactness": 0.7842671253456643, + "misalignment": 0.33333333333333337, + "loop_compactness": 0.9031938681344511, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.8634711255736092 + "weighted_cost": 1.7432032053170865 }, { "seed": 2, "metrics": { - "node_overlap": 0.02770600057421779, - "node_connector_overlap": 0.053045161248027196, - "label_overlap": 0.0, - "label_connector_overlap": 0.31455545510712074, - "crossings": 0.3235294117647059, - "crowding": 0.027527400195566234, - "sprawl": 1.7470929690802584, + "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.3478984607286807, + "edge_length_cv": 0.4329572602871554, "aspect_penalty": 0.0, - "misalignment": 0.39534883720930236, - "loop_compactness": 0.7842671253456641, + "misalignment": 0.3571428571428571, + "loop_compactness": 0.9239720119166108, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.8909662952560302 + "weighted_cost": 1.7674476985715255 }, { "seed": 3, "metrics": { - "node_overlap": 0.027706000574217547, - "node_connector_overlap": 0.06075444277579878, - "label_overlap": 0.0, - "label_connector_overlap": 0.24992140566272938, - "crossings": 0.2647058823529412, - "crowding": 0.026169167604437816, - "sprawl": 1.7649704972315727, + "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.36882991799676895, + "edge_length_cv": 0.4285523636389374, "aspect_penalty": 0.0, - "misalignment": 0.32558139534883723, - "loop_compactness": 0.7842671253456643, + "misalignment": 0.38095238095238093, + "loop_compactness": 0.9195834651524788, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.7467446599938747 + "weighted_cost": 1.859275882483802 }, { "seed": 4, "metrics": { - "node_overlap": 0.02770600057421779, - "node_connector_overlap": 0.053045161248027196, - "label_overlap": 0.0, - "label_connector_overlap": 0.31455545510712074, - "crossings": 0.3235294117647059, - "crowding": 0.027527400195566234, - "sprawl": 1.7470929690802584, + "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.3478984607286807, + "edge_length_cv": 0.4236950626383284, "aspect_penalty": 0.0, - "misalignment": 0.39534883720930236, - "loop_compactness": 0.7842671253456641, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.9201935000971966, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.8909662952560302 + "weighted_cost": 1.8574575351458038 }, { "seed": 5, "metrics": { - "node_overlap": 0.028365667254556307, - "node_connector_overlap": 0.057596251918922314, - "label_overlap": 0.0, - "label_connector_overlap": 0.327989089123597, - "crossings": 0.2647058823529412, - "crowding": 0.02728116843994271, - "sprawl": 1.9298247654529708, + "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.3899100189951688, + "edge_length_cv": 0.4205189649170576, "aspect_penalty": 0.0, - "misalignment": 0.40476190476190477, - "loop_compactness": 0.7842671253456643, + "misalignment": 0.41860465116279066, + "loop_compactness": 0.9201935000971966, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.91008225568477 + "weighted_cost": 1.6900396452054318 }, { "seed": 6, "metrics": { - "node_overlap": 0.027706000574217547, - "node_connector_overlap": 0.05467062783789798, - "label_overlap": 0.0, - "label_connector_overlap": 0.3370115789372144, - "crossings": 0.3235294117647059, - "crowding": 0.02690386068673064, - "sprawl": 1.7373439504444699, + "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.3427653964761761, + "edge_length_cv": 0.45914873838330356, "aspect_penalty": 0.0, - "misalignment": 0.32558139534883723, - "loop_compactness": 0.7842671253456647, + "misalignment": 0.5348837209302326, + "loop_compactness": 0.9145798199778197, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.9178638758270825 + "weighted_cost": 1.7394243767923294 }, { "seed": 7, "metrics": { - "node_overlap": 0.02770600057421779, - "node_connector_overlap": 0.05209105324892361, - "label_overlap": 0.0, - "label_connector_overlap": 0.3099245773779065, - "crossings": 0.3235294117647059, - "crowding": 0.027527400195566234, - "sprawl": 1.778928155989096, - "long_connectors": 0.0, - "edge_length_cv": 0.34516764971788766, + "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.39534883720930236, - "loop_compactness": 0.7842671253456641, + "misalignment": 0.5106382978723405, + "loop_compactness": 0.9227103289514283, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.8900705593912113 + "weighted_cost": 1.5538530287760415 }, { "seed": 42, "metrics": { - "node_overlap": 0.0437977660199884, - "node_connector_overlap": 0.07288650504285034, - "label_overlap": 0.0000258582943897902, - "label_connector_overlap": 0.24346336696695228, - "crossings": 0.29411764705882354, - "crowding": 0.044400425889557456, - "sprawl": 1.4302620198292808, - "long_connectors": 0.0, - "edge_length_cv": 0.3598673957232046, + "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.30952380952380953, - "loop_compactness": 0.7842671253456646, + "misalignment": 0.38095238095238093, + "loop_compactness": 0.9257989501172955, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.7800935546328007 + "weighted_cost": 1.8044966742002828 }, { "seed": 123, "metrics": { - "node_overlap": 0.042779213321848905, - "node_connector_overlap": 0.0754430251158815, - "label_overlap": 3.142823178591658e-7, - "label_connector_overlap": 0.3176415872642993, - "crossings": 0.23529411764705882, - "crowding": 0.05239364497547453, - "sprawl": 1.327627709505751, + "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.3775375405730366, + "edge_length_cv": 0.44283960615502593, "aspect_penalty": 0.0, - "misalignment": 0.2093023255813954, - "loop_compactness": 0.7842671253456643, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.9138813525937537, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.806308550438172 + "weighted_cost": 1.622732144993066 }, { "seed": 456, "metrics": { - "node_overlap": 0.02770600057421779, - "node_connector_overlap": 0.08071554703046019, - "label_overlap": 0.0, - "label_connector_overlap": 0.2889338869694489, - "crossings": 0.3235294117647059, - "crowding": 0.023255813953488372, - "sprawl": 1.929965552146705, + "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.40539310135084533, + "edge_length_cv": 0.49967562418431816, "aspect_penalty": 0.0, - "misalignment": 0.3023255813953488, - "loop_compactness": 0.7842671253456642, + "misalignment": 0.4651162790697675, + "loop_compactness": 0.9229517522002332, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.9400189485575272 + "weighted_cost": 1.7850586811365199 }, { "seed": 789, "metrics": { - "node_overlap": 0.042779213321848905, - "node_connector_overlap": 0.0700279598624416, - "label_overlap": 0.000017886073204223656, - "label_connector_overlap": 0.3097868524550448, - "crossings": 0.29411764705882354, - "crowding": 0.03933001561057297, - "sprawl": 1.2776532340594644, + "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.3726320560614229, + "edge_length_cv": 0.4609263677137596, "aspect_penalty": 0.0, - "misalignment": 0.32558139534883723, - "loop_compactness": 0.7842671253456641, + "misalignment": 0.4883720930232558, + "loop_compactness": 0.9241908885457356, "flow_bends": 0.0, "loop_straightness": 0.75 }, - "weighted_cost": 1.828652007147548 + "weighted_cost": 1.7306904433352188 } ], - "median_cost": 1.87677084248241, + "median_cost": 1.7413137910547078, "spread": [ - 1.7997548014868292, - 1.8957452853632153 + 1.688147868204009, + 1.7899181794024606 ], - "best_of_k_cost": 1.7800935546328007, - "best_seed": 0, - "median_seed": 1, - "worst_seed": 456 + "best_of_k_cost": 1.622732144993066, + "best_seed": 7, + "median_seed": 6, + "worst_seed": 3 }, { "model": "groupon", @@ -2547,251 +2547,251 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.014271829924799194, + "node_connector_overlap": 0.018261840782698664, "label_overlap": 0.0, - "label_connector_overlap": 0.4616476236923571, - "crossings": 0.22916666666666666, - "crowding": 0.00870869750258955, - "sprawl": 1.656573452774021, - "long_connectors": 0.005129204788498755, - "edge_length_cv": 0.5376092895625322, + "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, - "misalignment": 0.6666666666666667, - "loop_compactness": 0.6361447229526843, + "misalignment": 0.4878048780487805, + "loop_compactness": 0.7592916830900419, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.78133836560827 + "weighted_cost": 1.5366247091960863 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.020081903825109545, - "label_overlap": 0.0, - "label_connector_overlap": 0.37725272906182605, - "crossings": 0.2916666666666667, - "crowding": 0.024322569240707255, - "sprawl": 1.1043510714932714, - "long_connectors": 0.01250863558122281, - "edge_length_cv": 0.6610046796073618, + "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.5121951219512195, - "loop_compactness": 0.5909205700009729, + "misalignment": 0.36585365853658536, + "loop_compactness": 0.6966944284441143, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.5765773476251572 + "weighted_cost": 1.4526083890480639 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00568162149995695, + "node_connector_overlap": 0.003576853674553667, "label_overlap": 0.0, - "label_connector_overlap": 0.45579755116091203, - "crossings": 0.5416666666666666, - "crowding": 0.002976190476190476, - "sprawl": 1.76683144584467, - "long_connectors": 0.008052009438853572, - "edge_length_cv": 0.5429110622441976, + "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, - "misalignment": 0.6666666666666667, - "loop_compactness": 0.7236638252707938, + "misalignment": 0.8292682926829268, + "loop_compactness": 0.6864017876544161, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 2.126183874455102 + "weighted_cost": 1.683000374535882 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.018796842957660067, + "node_connector_overlap": 0.004242871133919603, "label_overlap": 0.0, - "label_connector_overlap": 0.4143831128307079, - "crossings": 0.4166666666666667, - "crowding": 0.015217148528910863, - "sprawl": 1.2225639593454287, - "long_connectors": 0.01394497485369383, - "edge_length_cv": 0.7117436503088292, + "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, - "misalignment": 0.45238095238095233, - "loop_compactness": 0.7395733819502811, + "misalignment": 0.5853658536585367, + "loop_compactness": 0.6781310668833803, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.8293484802537556 + "weighted_cost": 1.426089613221026 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01666781218517642, + "node_connector_overlap": 0.006467238025044093, "label_overlap": 0.0, - "label_connector_overlap": 0.4347956287663958, - "crossings": 0.3541666666666667, - "crowding": 0.0076311269733087845, - "sprawl": 1.7055696735634782, - "long_connectors": 0.004572207844730701, - "edge_length_cv": 0.5562864286952276, + "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, - "misalignment": 0.4878048780487805, - "loop_compactness": 0.6670022659371307, + "misalignment": 0.36585365853658536, + "loop_compactness": 0.676799973109859, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.876202162268272 + "weighted_cost": 1.214312810441455 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02299890799554789, + "node_connector_overlap": 0.0065649101731851955, "label_overlap": 0.0, - "label_connector_overlap": 0.37060780646904634, - "crossings": 0.5, - "crowding": 0.013682548917309524, - "sprawl": 1.1786684207734912, - "long_connectors": 0.010596094963512664, - "edge_length_cv": 0.61687102316755, + "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, - "misalignment": 0.41463414634146345, - "loop_compactness": 0.686614596002508, + "misalignment": 0.5365853658536586, + "loop_compactness": 0.7447213670844698, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.8162818649376378 + "weighted_cost": 1.5845244504857852 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012557319867895681, + "node_connector_overlap": 0.029197746840089796, "label_overlap": 0.0, - "label_connector_overlap": 0.3327704627275542, - "crossings": 0.5625, - "crowding": 0.015452188371085009, - "sprawl": 1.2068944080558612, - "long_connectors": 0.012853285582203835, - "edge_length_cv": 0.6593936923613066, + "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.5121951219512195, - "loop_compactness": 0.7502722339824853, + "misalignment": 0.3414634146341463, + "loop_compactness": 0.6776104818452727, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.8463165574067755 + "weighted_cost": 1.3766609453218634 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012470304758649256, + "node_connector_overlap": 0.025734463759432072, "label_overlap": 0.0, - "label_connector_overlap": 0.39636575076835345, - "crossings": 0.2708333333333333, - "crowding": 0.009206887043949088, - "sprawl": 1.6142272348261806, - "long_connectors": 0.005220550791730666, - "edge_length_cv": 0.5812588613223643, + "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, - "misalignment": 0.5116279069767442, - "loop_compactness": 0.6195901513239371, + "misalignment": 0.4878048780487805, + "loop_compactness": 0.671587493155455, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.6893107759921553 + "weighted_cost": 1.2951512940080672 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.008759111680090068, + "node_connector_overlap": 0.0065649101731851955, "label_overlap": 0.0, - "label_connector_overlap": 0.3654224782572133, - "crossings": 0.2916666666666667, - "crowding": 0.005733700986427256, - "sprawl": 1.5786152354592806, - "long_connectors": 0.006887349677537181, - "edge_length_cv": 0.5858131553946206, + "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, - "misalignment": 0.5121951219512195, - "loop_compactness": 0.5862632209390699, + "misalignment": 0.5365853658536586, + "loop_compactness": 0.7447213670844698, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.6314899772888174 + "weighted_cost": 1.5845244504857852 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.04508854556291611, + "node_connector_overlap": 0.018451801803241366, "label_overlap": 0.0, - "label_connector_overlap": 0.404728143966934, - "crossings": 0.20833333333333334, - "crowding": 0.05159637012498731, - "sprawl": 1.048510850092427, - "long_connectors": 0.007672673300245399, - "edge_length_cv": 0.6436554996638777, + "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, - "misalignment": 0.41860465116279066, - "loop_compactness": 0.7559942883200771, + "misalignment": 0.5853658536585367, + "loop_compactness": 0.7809940982612654, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.6520366247674776 + "weighted_cost": 1.520130463024936 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.029347087077200268, - "label_overlap": 0.0017945526244131313, - "label_connector_overlap": 0.3973293556565071, - "crossings": 0.375, - "crowding": 0.06390864538390151, - "sprawl": 1.2734710547938077, - "long_connectors": 0.008945617879868386, - "edge_length_cv": 0.6033756809219896, + "node_connector_overlap": 0.009206896087871837, + "label_overlap": 0.0, + "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, - "misalignment": 0.3571428571428571, - "loop_compactness": 0.6803816860901426, + "misalignment": 0.6097560975609756, + "loop_compactness": 0.741364715692521, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.8152007046126222 + "weighted_cost": 1.39960216730266 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.026340639421717087, + "node_connector_overlap": 0.023865068243973823, "label_overlap": 0.0, - "label_connector_overlap": 0.3015179292346872, - "crossings": 0.4791666666666667, - "crowding": 0.00914926102210008, - "sprawl": 1.2992503645824423, - "long_connectors": 0.011518678609647672, - "edge_length_cv": 0.5856597507994968, + "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, - "misalignment": 0.5365853658536586, - "loop_compactness": 0.7443673064538694, + "misalignment": 0.6341463414634146, + "loop_compactness": 0.7953868914057837, "flow_bends": 0.0, "loop_straightness": 0.8461538461538461 }, - "weighted_cost": 1.7598668746169646 + "weighted_cost": 1.5524451593957498 } ], - "median_cost": 1.798269535110446, + "median_cost": 1.4863694260365, "spread": [ - 1.679992238185986, - 1.8335904995420107 + 1.3938668618074608, + 1.5604649821682588 ], - "best_of_k_cost": 1.6314899772888174, - "best_seed": 1, - "median_seed": 0, + "best_of_k_cost": 1.39960216730266, + "best_seed": 4, + "median_seed": 1, "worst_seed": 2 }, { @@ -2801,252 +2801,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02218066838083641, + "node_connector_overlap": 0.01459161967711902, "label_overlap": 0.0, - "label_connector_overlap": 0.32741939647250295, - "crossings": 0.35, - "crowding": 0.0018938237988164253, - "sprawl": 1.8477257970917058, + "label_connector_overlap": 0.17532415736198848, + "crossings": 0.3695652173913043, + "crowding": 0.006525213068181818, + "sprawl": 1.911329625521727, "long_connectors": 0.0, - "edge_length_cv": 0.31264042031560213, + "edge_length_cv": 0.40201899638723515, "aspect_penalty": 0.0, - "misalignment": 0.18181818181818177, + "misalignment": 0.2727272727272727, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.3674975227239883 + "weighted_cost": 1.1733650395098658 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.020674911119903973, + "node_connector_overlap": 0.007522068489343559, "label_overlap": 0.0, - "label_connector_overlap": 0.39065018302415533, - "crossings": 0.225, - "crowding": 0.06662476497811065, - "sprawl": 1.317633264078583, + "label_connector_overlap": 0.1137277301029934, + "crossings": 0.2391304347826087, + "crowding": 0.009299544578204302, + "sprawl": 2.4611071034778846, "long_connectors": 0.0, - "edge_length_cv": 0.3366141747904354, + "edge_length_cv": 0.43943748758321477, "aspect_penalty": 0.0, - "misalignment": 0.12121212121212122, + "misalignment": 0.303030303030303, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.2604793898950095 + "weighted_cost": 1.0796455176664916 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02218066838083641, + "node_connector_overlap": 0.02866564431885437, "label_overlap": 0.0, - "label_connector_overlap": 0.32741939647250295, - "crossings": 0.35, - "crowding": 0.0018938237988164253, - "sprawl": 1.8477257970917058, + "label_connector_overlap": 0.13509215888541945, + "crossings": 0.21739130434782608, + "crowding": 0.0042142922555424645, + "sprawl": 1.302406614731556, "long_connectors": 0.0, - "edge_length_cv": 0.31264042031560213, + "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": 1.3674975227239883 + "weighted_cost": 0.8253585954339137 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02218066838083641, + "node_connector_overlap": 0.0443126968786972, "label_overlap": 0.0, - "label_connector_overlap": 0.32741939647250295, - "crossings": 0.35, - "crowding": 0.0018938237988164253, - "sprawl": 1.8477257970917058, + "label_connector_overlap": 0.2215609891401972, + "crossings": 0.43478260869565216, + "crowding": 0.0026267125490166696, + "sprawl": 1.336144070780343, "long_connectors": 0.0, - "edge_length_cv": 0.31264042031560213, + "edge_length_cv": 0.359659911688514, "aspect_penalty": 0.0, "misalignment": 0.18181818181818177, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.3674975227239883 + "weighted_cost": 1.210594034589263 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007528421408871635, + "node_connector_overlap": 0.030644676375648265, "label_overlap": 0.0, - "label_connector_overlap": 0.2849672533324153, - "crossings": 0.15, - "crowding": 0.006825542734498282, - "sprawl": 1.6566564934447365, - "long_connectors": 0.008293837157956327, - "edge_length_cv": 0.5054211355484309, + "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.21212121212121215, + "misalignment": 0.1875, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.038856428703148 + "weighted_cost": 1.1726067998322132 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.001498028302292512, + "node_connector_overlap": 0.03343950373670096, "label_overlap": 0.0, - "label_connector_overlap": 0.3278796829189022, - "crossings": 0.225, + "label_connector_overlap": 0.20644147677405747, + "crossings": 0.3695652173913043, "crowding": 0.0, - "sprawl": 7.721532003057264, - "long_connectors": 0.0, - "edge_length_cv": 0.38289567608599284, + "sprawl": 1.3503109775855036, + "long_connectors": 0.0018540276823021922, + "edge_length_cv": 0.47400856852904255, "aspect_penalty": 0.0, - "misalignment": 0.5625, + "misalignment": 0.24242424242424243, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 2.7064485817472543 + "weighted_cost": 1.1088536225057435 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.011742254730133735, + "node_connector_overlap": 0.04654796233780711, "label_overlap": 0.0, - "label_connector_overlap": 0.2662380707953969, - "crossings": 0.25, - "crowding": 0.001893939393939394, - "sprawl": 2.328117751126569, - "long_connectors": 0.0, - "edge_length_cv": 0.31292902734498346, + "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.2727272727272727, + "misalignment": 0.18181818181818177, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.284037720101672 + "weighted_cost": 1.51648617138794 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.017914866803731617, + "node_connector_overlap": 0.03265613551205643, "label_overlap": 0.0, - "label_connector_overlap": 0.4655527057116693, - "crossings": 0.225, - "crowding": 0.005730214739302544, - "sprawl": 1.8237208799966098, + "label_connector_overlap": 0.14278021088831827, + "crossings": 0.2826086956521739, + "crowding": 0.000032289971904877035, + "sprawl": 1.8070007721131631, "long_connectors": 0.0, - "edge_length_cv": 0.3251665261220471, + "edge_length_cv": 0.3709480842464356, "aspect_penalty": 0.0, - "misalignment": 0.18181818181818177, + "misalignment": 0.1515151515151515, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.4390010450952402 + "weighted_cost": 1.0290252811604748 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03196713343611331, + "node_connector_overlap": 0.024179649943143172, "label_overlap": 0.0, - "label_connector_overlap": 0.3418730232394007, - "crossings": 0.325, - "crowding": 0.008887921780497394, - "sprawl": 1.8250398586290622, + "label_connector_overlap": 0.21257565745044152, + "crossings": 0.32608695652173914, + "crowding": 0.0, + "sprawl": 1.8552472597963248, "long_connectors": 0.0, - "edge_length_cv": 0.30227069079222796, + "edge_length_cv": 0.34095204398408596, "aspect_penalty": 0.0, - "misalignment": 0.18181818181818177, + "misalignment": 0.33333333333333337, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.3850735063509088 + "weighted_cost": 1.1904548908661023 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.04085458601305136, + "node_connector_overlap": 0.03477521342347458, "label_overlap": 0.0, - "label_connector_overlap": 0.40545481101488007, - "crossings": 0.55, - "crowding": 0.0037878218762103914, - "sprawl": 1.8884584536313105, + "label_connector_overlap": 0.07043144858990329, + "crossings": 0.2826086956521739, + "crowding": 0.04247055928744711, + "sprawl": 1.7581801914322206, "long_connectors": 0.0, - "edge_length_cv": 0.3626971636659405, + "edge_length_cv": 0.45466558053923956, "aspect_penalty": 0.0, - "misalignment": 0.16129032258064513, + "misalignment": 0.2727272727272727, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.7319228560905253 + "weighted_cost": 0.9670946298022075 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.029472904605496744, + "node_connector_overlap": 0.013019165107408458, "label_overlap": 0.0, - "label_connector_overlap": 0.3214087104619574, - "crossings": 0.225, - "crowding": 0.009260610710415005, - "sprawl": 1.8399075099905904, + "label_connector_overlap": 0.14230340849310655, + "crossings": 0.2826086956521739, + "crowding": 0.0004734848484848485, + "sprawl": 1.8070450409823386, "long_connectors": 0.0, - "edge_length_cv": 0.30249403395050967, + "edge_length_cv": 0.2911023387067692, "aspect_penalty": 0.0, - "misalignment": 0.12121212121212122, + "misalignment": 0.24242424242424243, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.2474175752332042 + "weighted_cost": 0.9985793079431445 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.013041946454782528, + "node_connector_overlap": 0.0067014522267324515, "label_overlap": 0.0, - "label_connector_overlap": 0.34757813117864156, - "crossings": 0.275, - "crowding": 0.002573821479836903, - "sprawl": 1.915257577728607, + "label_connector_overlap": 0.02125928986005374, + "crossings": 0.5217391304347826, + "crowding": 0.0, + "sprawl": 5.994854405117601, "long_connectors": 0.0, - "edge_length_cv": 0.35430029256408135, + "edge_length_cv": 0.3124068925559253, "aspect_penalty": 0.0, - "misalignment": 0.15625, + "misalignment": 0.33333333333333337, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.3194643055895159 + "weighted_cost": 2.0990779042910614 } ], - "median_cost": 1.3674975227239883, + "median_cost": 1.1407302111689783, "spread": [ - 1.2781481375500063, - 1.3985553910369917 + 1.0214137878561422, + 1.1954896767968926 ], - "best_of_k_cost": 1.2474175752332042, - "best_seed": 4, - "median_seed": 0, - "worst_seed": 5 + "best_of_k_cost": 0.9670946298022075, + "best_seed": 2, + "median_seed": 4, + "worst_seed": 789 }, { "model": "arms_race", @@ -3055,61 +3055,61 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009937653856781805, + "node_connector_overlap": 0.009937973184269299, "label_overlap": 0.0, - "label_connector_overlap": 0.46635975194827745, + "label_connector_overlap": 0.13674971689010884, "crossings": 0.3888888888888889, - "crowding": 0.032963194767996584, - "sprawl": 1.6966985821558853, + "crowding": 0.019806367602091764, + "sprawl": 1.660573571412186, "long_connectors": 0.0, - "edge_length_cv": 0.44423307193182415, + "edge_length_cv": 0.4454751511985133, "aspect_penalty": 0.0, "misalignment": 0.33333333333333337, - "loop_compactness": 0.8668963170399904, + "loop_compactness": 0.8668743181653887, "flow_bends": 0.0, - "loop_straightness": 0.30290099704931833 + "loop_straightness": 0.30664089367463093 }, - "weighted_cost": 1.975823624686098 + "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, - "label_connector_overlap": 0.3026911049928244, + "label_connector_overlap": 0.05545305371528578, "crossings": 0.2777777777777778, - "crowding": 0.013422486853090394, - "sprawl": 2.099192179209862, + "crowding": 0.00027338014907867897, + "sprawl": 1.6174579111511378, "long_connectors": 0.0, - "edge_length_cv": 0.4039609620769209, + "edge_length_cv": 0.381111992954584, "aspect_penalty": 0.0, "misalignment": 0.2666666666666667, - "loop_compactness": 0.8825010929238619, + "loop_compactness": 0.8893512810155005, "flow_bends": 0.0, - "loop_straightness": 0.23256127552027298 + "loop_straightness": 0.24283378154310556 }, - "weighted_cost": 1.672958198310809 + "weighted_cost": 1.1760546270606145 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03006260605435891, + "node_connector_overlap": 0.029972232551334292, "label_overlap": 0.0, - "label_connector_overlap": 0.39977021236883203, + "label_connector_overlap": 0.08396684599430672, "crossings": 0.3888888888888889, - "crowding": 0.005873827395426351, - "sprawl": 1.6732065979483157, + "crowding": 0.0, + "sprawl": 1.6303956575922558, "long_connectors": 0.0, - "edge_length_cv": 0.3911741366529669, + "edge_length_cv": 0.39217576414899175, "aspect_penalty": 0.0, "misalignment": 0.2666666666666667, - "loop_compactness": 0.8482301042144691, + "loop_compactness": 0.847673189478536, "flow_bends": 0.0, - "loop_straightness": 0.3077717431498357 + "loop_straightness": 0.3068399223031358 }, - "weighted_cost": 1.8695807791007981 + "weighted_cost": 1.378802472069476 }, { "seed": 3, @@ -3117,99 +3117,99 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3649870377322633, + "label_connector_overlap": 0.14054998474230357, "crossings": 0.3888888888888889, - "crowding": 0.009582151993582467, - "sprawl": 1.6911220565773664, + "crowding": 0.000024581119871140646, + "sprawl": 1.637195959486641, "long_connectors": 0.0, - "edge_length_cv": 0.32558435869583185, + "edge_length_cv": 0.3278464593264952, "aspect_penalty": 0.0, - "misalignment": 0.19999999999999996, - "loop_compactness": 0.8416431961799266, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.837247242477636, "flow_bends": 0.0, - "loop_straightness": 0.2593563504377954 + "loop_straightness": 0.2538015005563948 }, - "weighted_cost": 1.7513250251409582 + "weighted_cost": 1.395983150707236 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012149193939759141, + "node_connector_overlap": 0.010617004210914587, "label_overlap": 0.0, - "label_connector_overlap": 0.3888584573128413, + "label_connector_overlap": 0.13861385147854124, "crossings": 0.3888888888888889, - "crowding": 0.027695450751829064, - "sprawl": 1.654220830760522, + "crowding": 0.020025265634639924, + "sprawl": 1.6094410794546148, "long_connectors": 0.0, - "edge_length_cv": 0.3946108463204639, + "edge_length_cv": 0.40396901763651744, "aspect_penalty": 0.0, "misalignment": 0.2666666666666667, - "loop_compactness": 0.8126558695054827, + "loop_compactness": 0.8083395177685666, "flow_bends": 0.0, - "loop_straightness": 0.2861075393585377 + "loop_straightness": 0.2811022532730657 }, - "weighted_cost": 1.8180653895843424 + "weighted_cost": 1.4185419091282234 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.022578434605595048, + "node_connector_overlap": 0.021914327515018903, "label_overlap": 0.0, - "label_connector_overlap": 0.2887553298529586, + "label_connector_overlap": 0.33498040835373805, "crossings": 0.3888888888888889, - "crowding": 0.010314345255579099, - "sprawl": 1.791628882895522, + "crowding": 0.0019751600431562534, + "sprawl": 1.7331520455978435, "long_connectors": 0.0, - "edge_length_cv": 0.40341526282612233, + "edge_length_cv": 0.4063781867528364, "aspect_penalty": 0.0, "misalignment": 0.33333333333333337, - "loop_compactness": 0.7615780267084172, + "loop_compactness": 0.7574179127896663, "flow_bends": 0.0, - "loop_straightness": 0.2531903249631419 + "loop_straightness": 0.2506614018824678 }, - "weighted_cost": 1.688683895371991 + "weighted_cost": 1.7318179665295979 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0032535798816720604, + "node_connector_overlap": 0.0032497261672938225, "label_overlap": 0.0, - "label_connector_overlap": 0.32089436437169216, + "label_connector_overlap": 0.11867646192686691, "crossings": 0.3333333333333333, - "crowding": 0.04084121517467771, - "sprawl": 1.6141938159785616, + "crowding": 0.023811468836172283, + "sprawl": 1.5628805003439654, "long_connectors": 0.0, - "edge_length_cv": 0.41123562592049057, + "edge_length_cv": 0.41221488550108776, "aspect_penalty": 0.0, "misalignment": 0.33333333333333337, - "loop_compactness": 0.7611247473113882, + "loop_compactness": 0.7649235160576087, "flow_bends": 0.0, - "loop_straightness": 0.39158358392693593 + "loop_straightness": 0.3925211919880592 }, - "weighted_cost": 1.642513299474116 + "weighted_cost": 1.3109339314355677 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03670882081113866, + "node_connector_overlap": 0.0362427333693954, "label_overlap": 0.0, - "label_connector_overlap": 0.49459776881667733, + "label_connector_overlap": 0.25183380260935984, "crossings": 0.4444444444444444, - "crowding": 0.0041782308223587165, - "sprawl": 1.7875239990307978, + "crowding": 0.0, + "sprawl": 1.7364922301277, "long_connectors": 0.0, - "edge_length_cv": 0.29818499010342886, + "edge_length_cv": 0.2977657150117305, "aspect_penalty": 0.0, "misalignment": 0.4, - "loop_compactness": 0.9028734661034997, + "loop_compactness": 0.9035005219799066, "flow_bends": 0.0, - "loop_straightness": 0.3184099795632304 + "loop_straightness": 0.3166102396508354 }, - "weighted_cost": 2.143808354269519 + "weighted_cost": 1.7618649053862463 }, { "seed": 42, @@ -3217,19 +3217,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3649870377322633, + "label_connector_overlap": 0.14054998474230357, "crossings": 0.3888888888888889, - "crowding": 0.009582151993582467, - "sprawl": 1.6911220565773664, + "crowding": 0.000024581119871140646, + "sprawl": 1.637195959486641, "long_connectors": 0.0, - "edge_length_cv": 0.32558435869583185, + "edge_length_cv": 0.3278464593264952, "aspect_penalty": 0.0, - "misalignment": 0.19999999999999996, - "loop_compactness": 0.8416431961799266, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.837247242477636, "flow_bends": 0.0, - "loop_straightness": 0.2593563504377954 + "loop_straightness": 0.2538015005563948 }, - "weighted_cost": 1.7513250251409582 + "weighted_cost": 1.395983150707236 }, { "seed": 123, @@ -3237,19 +3237,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3649870377322633, + "label_connector_overlap": 0.14054998474230357, "crossings": 0.3888888888888889, - "crowding": 0.009582151993582467, - "sprawl": 1.6911220565773664, + "crowding": 0.000024581119871140646, + "sprawl": 1.637195959486641, "long_connectors": 0.0, - "edge_length_cv": 0.32558435869583185, + "edge_length_cv": 0.3278464593264952, "aspect_penalty": 0.0, - "misalignment": 0.19999999999999996, - "loop_compactness": 0.8416431961799266, + "misalignment": 0.2666666666666667, + "loop_compactness": 0.837247242477636, "flow_bends": 0.0, - "loop_straightness": 0.2593563504377954 + "loop_straightness": 0.2538015005563948 }, - "weighted_cost": 1.7513250251409582 + "weighted_cost": 1.395983150707236 }, { "seed": 456, @@ -3257,19 +3257,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2758772681314898, + "label_connector_overlap": 0.12822979281779895, "crossings": 0.3888888888888889, - "crowding": 0.013266291661162376, - "sprawl": 1.6273606759473405, + "crowding": 0.003303446622815623, + "sprawl": 1.59371354964202, "long_connectors": 0.0, - "edge_length_cv": 0.3979894091320524, + "edge_length_cv": 0.400630225206048, "aspect_penalty": 0.0, "misalignment": 0.2666666666666667, - "loop_compactness": 0.8334649713097237, + "loop_compactness": 0.8324049346606536, "flow_bends": 0.0, - "loop_straightness": 0.2507034744572673 + "loop_straightness": 0.24976274299507603 }, - "weighted_cost": 1.6079342543704038 + "weighted_cost": 1.3675703269793438 }, { "seed": 789, @@ -3277,28 +3277,28 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.3591697971858699, + "label_connector_overlap": 0.14054998474230376, "crossings": 0.4444444444444444, - "crowding": 0.009375, - "sprawl": 1.6420603991345986, + "crowding": 0.000024581119871140646, + "sprawl": 1.6210985021704356, "long_connectors": 0.0, - "edge_length_cv": 0.34311101296497853, + "edge_length_cv": 0.34086042024828334, "aspect_penalty": 0.0, "misalignment": 0.33333333333333337, - "loop_compactness": 0.8529478565851238, + "loop_compactness": 0.85207027154579, "flow_bends": 0.0, - "loop_straightness": 0.25789935168069605 + "loop_straightness": 0.2518242922561193 }, - "weighted_cost": 1.8033916511423516 + "weighted_cost": 1.4599124993976416 } ], - "median_cost": 1.7513250251409582, + "median_cost": 1.395983150707236, "spread": [ - 1.6847524711066955, - 1.8309442369634563 + 1.375994435796943, + 1.459667865610421 ], - "best_of_k_cost": 1.6079342543704038, - "best_seed": 456, + "best_of_k_cost": 1.3675703269793438, + "best_seed": 1, "median_seed": 3, "worst_seed": 7 }, @@ -3311,19 +3311,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.31444149819945955, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.015624123171601102, - "sprawl": 1.3329422116410952, + "crowding": 0.0, + "sprawl": 0.752155180038625, "long_connectors": 0.0, - "edge_length_cv": 0.2510596910558491, - "aspect_penalty": 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.8705219233810643 + "weighted_cost": 0.23803879500965625 }, { "seed": 1, @@ -3331,19 +3331,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2720230771454212, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.018919353137174265, - "sprawl": 0.8479801515896476, + "crowding": 0.09003253694763302, + "sprawl": 0.5042550452661156, "long_connectors": 0.0, - "edge_length_cv": 0.24153987589618206, + "edge_length_cv": 0.3594182691592225, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.7389490067527179 + "weighted_cost": 0.31609629826416197 }, { "seed": 2, @@ -3351,59 +3351,59 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.23363799539462698, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.026493866103904064, - "sprawl": 0.832904196283862, + "crowding": 0.17960557202015576, + "sprawl": 0.5486457635781365, "long_connectors": 0.0, - "edge_length_cv": 0.29811564277165953, - "aspect_penalty": 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.58517690826681 + "weighted_cost": 0.3167670129146899 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.045631972168947994, + "node_connector_overlap": 0.012774205681329993, "label_overlap": 0.0, - "label_connector_overlap": 0.1863992276401865, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.02780055156895072, - "sprawl": 1.1633234192206812, + "crowding": 0.0, + "sprawl": 1.175057315976473, "long_connectors": 0.0, - "edge_length_cv": 0.46598188906536736, + "edge_length_cv": 0.4547629612716285, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.7894941921722968 + "weighted_cost": 0.41931274035677824 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03707432906789591, + "node_connector_overlap": 0.045422434609506956, "label_overlap": 0.0, - "label_connector_overlap": 0.16311579941847928, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.02055091455615535, - "sprawl": 1.4630744694512237, + "crowding": 0.0, + "sprawl": 1.1519098142099826, "long_connectors": 0.0, - "edge_length_cv": 0.45253448331487, + "edge_length_cv": 0.49542007510345726, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.805141889182472 + "weighted_cost": 0.4788223227715096 }, { "seed": 5, @@ -3411,19 +3411,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2645075771829968, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.041188953961122, + "sprawl": 0.9738119711461916, "long_connectors": 0.0, - "edge_length_cv": 0.4896083755529902, + "edge_length_cv": 0.48580093166568084, "aspect_penalty": 0.0, "misalignment": 0.5, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.7070586042647757 + "weighted_cost": 0.2934529927865479 }, { "seed": 6, @@ -3431,19 +3431,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.39352662018498963, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.26304403370640206, - "sprawl": 0.954242722342609, + "crowding": 0.16930455973219807, + "sprawl": 0.9358746585727514, "long_connectors": 0.0, - "edge_length_cv": 0.2961652906451127, - "aspect_penalty": 0.24091651477240017, + "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": 1.1918946445695389 + "weighted_cost": 0.5032732243753859 }, { "seed": 7, @@ -3451,19 +3451,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.12504268419362394, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.01800120855572146, - "sprawl": 0.8407694601300947, + "crowding": 0.0, + "sprawl": 0.8465567355773407, "long_connectors": 0.0, - "edge_length_cv": 0.3876784910946535, + "edge_length_cv": 0.37887650492363323, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.515757599878681 + "weighted_cost": 0.3116391838943352 }, { "seed": 42, @@ -3471,59 +3471,59 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.2877827456412807, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.015625000000000444, - "sprawl": 1.2029531429780467, + "crowding": 0.006217967812834432, + "sprawl": 0.6187802337681223, "long_connectors": 0.0, - "edge_length_cv": 0.182365967999511, - "aspect_penalty": 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.8480374042064331 + "weighted_cost": 0.260913026254865 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.05755964068162532, + "node_connector_overlap": 0.08230853725271599, "label_overlap": 0.0, - "label_connector_overlap": 0.37449814972518786, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.005891374512217282, - "sprawl": 1.0046754405757474, + "crowding": 0.22647874083020253, + "sprawl": 0.6605197001872328, "long_connectors": 0.0, - "edge_length_cv": 0.3783713713241633, + "edge_length_cv": 0.44319453774726203, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.0339267406071868 + "weighted_cost": 0.6562257403824427 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.053381024540718136, + "node_connector_overlap": 0.04851785355486016, "label_overlap": 0.0, - "label_connector_overlap": 0.3797722662477906, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.014844340800618038, - "sprawl": 1.2745732551232656, + "crowding": 0.0003430732662794328, + "sprawl": 1.238706178979523, "long_connectors": 0.0, - "edge_length_cv": 0.4562838397703892, + "edge_length_cv": 0.43881644645615436, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 1.1099081030345568 + "weighted_cost": 0.5070553251208805 }, { "seed": 789, @@ -3531,30 +3531,30 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.13157874193717545, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.2560510993520694, - "sprawl": 0.8696379463960998, + "crowding": 0.1746939529456357, + "sprawl": 0.8478070935253363, "long_connectors": 0.0, - "edge_length_cv": 0.3352621885189167, + "edge_length_cv": 0.3226107256234914, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.7708286988568575 + "weighted_cost": 0.4866457263269698 } ], - "median_cost": 0.7973180406773843, + "median_cost": 0.36803987663573406, "spread": [ - 0.7309764061307324, - 0.9113731276875949 + 0.30709263611738835, + 0.4908026008390738 ], - "best_of_k_cost": 0.7708286988568575, - "best_seed": 7, - "median_seed": 3, - "worst_seed": 6 + "best_of_k_cost": 0.260913026254865, + "best_seed": 0, + "median_seed": 2, + "worst_seed": 123 }, { "model": "ai_modules_arrays", @@ -3567,17 +3567,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 1, @@ -3587,17 +3587,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 2, @@ -3607,17 +3607,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 3, @@ -3627,17 +3627,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 4, @@ -3647,17 +3647,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 5, @@ -3667,17 +3667,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 6, @@ -3687,17 +3687,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 7, @@ -3707,17 +3707,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 42, @@ -3727,17 +3727,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 123, @@ -3747,17 +3747,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 456, @@ -3767,17 +3767,17 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 }, { "seed": 789, @@ -3787,25 +3787,25 @@ "label_overlap": 0.0, "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.035599127828787706, - "sprawl": 0.34259214987287323, + "crowding": 0.005992457845075285, + "sprawl": 0.3730651545751382, "long_connectors": 0.0, - "edge_length_cv": 0.370733451417772, + "edge_length_cv": 0.33863843636905816, "aspect_penalty": 0.0, "misalignment": 1.0, "loop_compactness": 0.0, "flow_bends": 0.0, "loop_straightness": 0.0 }, - "weighted_cost": 0.22124716529700603 + "weighted_cost": 0.19925874648885983 } ], - "median_cost": 0.22124716529700603, + "median_cost": 0.19925874648885983, "spread": [ - 0.22124716529700603, - 0.22124716529700603 + 0.19925874648885983, + 0.19925874648885983 ], - "best_of_k_cost": 0.22124716529700603, + "best_of_k_cost": 0.19925874648885983, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -3819,19 +3819,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 1, @@ -3839,19 +3839,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 2, @@ -3859,19 +3859,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 3, @@ -3879,19 +3879,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 4, @@ -3899,19 +3899,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 5, @@ -3919,19 +3919,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 6, @@ -3939,19 +3939,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 7, @@ -3959,19 +3959,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 42, @@ -3979,19 +3979,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 123, @@ -3999,19 +3999,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 456, @@ -4019,19 +4019,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 }, { "seed": 789, @@ -4039,27 +4039,27 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.1667300847940283, + "label_connector_overlap": 0.0, "crossings": 0.0, "crowding": 0.0, - "sprawl": 1.2004023727154887, + "sprawl": 1.2311486818289008, "long_connectors": 0.0, - "edge_length_cv": 0.09806235152453334, + "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.5501957203699146 + "weighted_cost": 0.3077871704572252 } ], - "median_cost": 0.5501957203699146, + "median_cost": 0.3077871704572252, "spread": [ - 0.5501957203699146, - 0.5501957203699146 + 0.3077871704572252, + 0.3077871704572252 ], - "best_of_k_cost": 0.5501957203699146, + "best_of_k_cost": 0.3077871704572252, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -4070,253 +4070,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.060879623488298944, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.151029094354389, - "crossings": 0.5, - "crowding": 0.2365609687528194, - "sprawl": 1.5882909233200797, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.45227255025103114, - "aspect_penalty": 0.7862803320367586, + "edge_length_cv": 0.214758531786261, + "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.912716313979774, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.2783934613608023 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.6124481937010415 + "weighted_cost": 1.2527178631834184 }, { "seed": 1, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.05395763522098385, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.3465092833108762, - "crossings": 0.375, - "crowding": 0.14037436036375414, - "sprawl": 1.4621858161069112, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.15900756922487203, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.5141352860514037, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.19206098053703893 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.4710459561550597 + "weighted_cost": 1.2527178631834184 }, { "seed": 2, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.04748458711605549, + "node_overlap": 0.0, + "node_connector_overlap": 0.0048160911362588365, "label_overlap": 0.0, - "label_connector_overlap": 0.33084854186147, + "label_connector_overlap": 0.10145753189600365, "crossings": 0.125, - "crowding": 0.13368055555555555, - "sprawl": 1.3076047259801458, + "crowding": 0.0, + "sprawl": 1.5616664463040213, "long_connectors": 0.0, - "edge_length_cv": 0.20688696924357516, + "edge_length_cv": 0.2535445270584675, "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.4592298206273945, + "loop_compactness": 0.7638490316069791, "flow_bends": 0.0, - "loop_straightness": 0.20562751154321554 + "loop_straightness": 0.28857883654232325 }, - "weighted_cost": 2.118664137362218 + "weighted_cost": 1.0338548102117748 }, { "seed": 3, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.03246361484207835, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.34279585209295355, - "crossings": 0.125, - "crowding": 0.12196180555555555, - "sprawl": 1.5595215612838664, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.3152809745642004, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.7942029010435935, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.27482893175896606 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.2987129911754742 + "weighted_cost": 1.2527178631834184 }, { "seed": 4, "metrics": { - "node_overlap": 0.2043895747599459, - "node_connector_overlap": 0.03378579141677818, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.2815102256183621, - "crossings": 0.0, - "crowding": 0.22045177993641105, - "sprawl": 1.5223534249673132, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.470978273732384, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "misalignment": 0.11111111111111116, - "loop_compactness": 0.5470879543433413, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.048567333695646774 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.041043595317162 + "weighted_cost": 1.2527178631834184 }, { "seed": 5, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.04559409422241866, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.4346069900843088, - "crossings": 0.375, - "crowding": 0.1328125, - "sprawl": 1.8667905503847568, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.2973118558028373, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.5040015759096583, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.20517996229815672 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.6773131716431995 + "weighted_cost": 1.2527178631834184 }, { "seed": 6, "metrics": { - "node_overlap": 0.05994513031550091, - "node_connector_overlap": 0.040143424815926476, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.1668550909220817, + "label_connector_overlap": 0.16666666666666666, "crossings": 0.25, - "crowding": 0.06854214891975367, - "sprawl": 2.1336539671479353, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.329558318090864, - "aspect_penalty": 0.1315295815295816, + "edge_length_cv": 0.214758531786261, + "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.822943123314879, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.25293324939130984 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 1.769025879313271 + "weighted_cost": 1.2527178631834184 }, { "seed": 7, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.03977143246992413, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.3019091229276845, - "crossings": 0.0, - "crowding": 0.21483900538109604, - "sprawl": 1.2933184411962333, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.2545319074999315, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, - "misalignment": 0.11111111111111116, - "loop_compactness": 0.5644217853189382, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.17176629114026679 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.0399951310240514 + "weighted_cost": 1.2527178631834184 }, { "seed": 42, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.03824709357573146, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.10078421929156152, - "crossings": 0.0, - "crowding": 0.21550212908439786, - "sprawl": 1.344977666692242, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.2901375528365082, - "aspect_penalty": 0.24895073500693243, - "misalignment": 0.11111111111111116, - "loop_compactness": 0.5518054002419153, + "edge_length_cv": 0.214758531786261, + "aspect_penalty": 0.0, + "misalignment": 0.2222222222222222, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.10498812980413981 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 1.7371126576943634 + "weighted_cost": 1.2527178631834184 }, { "seed": 123, "metrics": { - "node_overlap": 0.2043895747599453, - "node_connector_overlap": 0.05395763522098385, + "node_overlap": 0.0, + "node_connector_overlap": 0.049849881647552566, "label_overlap": 0.0, - "label_connector_overlap": 0.3465092833108762, - "crossings": 0.375, - "crowding": 0.14037436036375414, - "sprawl": 1.4621858161069112, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.0, + "crowding": 0.05804136728366248, + "sprawl": 1.303485120317416, "long_connectors": 0.0, - "edge_length_cv": 0.15900756922487203, + "edge_length_cv": 0.23764533830878432, "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.5141352860514037, + "loop_compactness": 0.9008027958936617, "flow_bends": 0.0, - "loop_straightness": 0.19206098053703893 + "loop_straightness": 0.2697951232153641 }, - "weighted_cost": 2.4710459561550597 + "weighted_cost": 1.143135263559345 }, { "seed": 456, "metrics": { - "node_overlap": 0.20438957475994476, - "node_connector_overlap": 0.08546511205105828, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.5, - "crossings": 0.375, - "crowding": 0.20662916633561346, - "sprawl": 1.4013008685890471, + "label_connector_overlap": 0.16666666666666666, + "crossings": 0.25, + "crowding": 0.0, + "sprawl": 1.437787548996771, "long_connectors": 0.0, - "edge_length_cv": 0.37438119897611427, + "edge_length_cv": 0.214758531786261, "aspect_penalty": 0.0, "misalignment": 0.2222222222222222, - "loop_compactness": 0.4873195293257049, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.16814579396149446 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.802212732593452 + "weighted_cost": 1.2527178631834184 }, { "seed": 789, "metrics": { - "node_overlap": 0.2043895747599459, - "node_connector_overlap": 0.03852898789738477, + "node_overlap": 0.0, + "node_connector_overlap": 0.006008511509994464, "label_overlap": 0.0, - "label_connector_overlap": 0.10341408600121772, - "crossings": 0.375, - "crowding": 0.135354919371156, - "sprawl": 2.009499201587725, - "long_connectors": 0.036690362378647465, - "edge_length_cv": 0.714597445615422, + "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, "misalignment": 0.2222222222222222, - "loop_compactness": 0.9070368311076114, + "loop_compactness": 0.8305180748611386, "flow_bends": 0.0, - "loop_straightness": 0.27482893175896594 + "loop_straightness": 0.2682450074755902 }, - "weighted_cost": 2.391137365254982 + "weighted_cost": 1.2527178631834184 } ], - "median_cost": 2.3449251782152283, + "median_cost": 1.2527178631834184, "spread": [ - 2.0407814792438845, - 2.5063965155415553 + 1.2527178631834184, + 1.2527178631834184 ], - "best_of_k_cost": 1.7371126576943634, - "best_seed": 42, - "median_seed": 789, - "worst_seed": 456 + "best_of_k_cost": 1.143135263559345, + "best_seed": 2, + "median_seed": 0, + "worst_seed": 0 }, { "model": "arrayed_pop", @@ -4327,19 +4327,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 1, @@ -4347,19 +4347,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 2, @@ -4367,19 +4367,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 3, @@ -4387,19 +4387,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 4, @@ -4407,19 +4407,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 5, @@ -4427,19 +4427,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 6, @@ -4447,19 +4447,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 7, @@ -4467,19 +4467,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 42, @@ -4487,19 +4487,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 123, @@ -4507,19 +4507,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 456, @@ -4527,19 +4527,19 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 }, { "seed": 789, @@ -4547,27 +4547,27 @@ "node_overlap": 0.0, "node_connector_overlap": 0.0, "label_overlap": 0.0, - "label_connector_overlap": 0.15568282680129364, + "label_connector_overlap": 0.0, "crossings": 0.0, - "crowding": 0.017855542082559493, - "sprawl": 1.3463727490885313, + "crowding": 0.0, + "sprawl": 1.3768378555706637, "long_connectors": 0.0, - "edge_length_cv": 0.0759518425927967, - "aspect_penalty": 0.28135994365149086, + "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.5879729695566328 + "weighted_cost": 0.34420946389266593 } ], - "median_cost": 0.5879729695566328, + "median_cost": 0.34420946389266593, "spread": [ - 0.5879729695566328, - 0.5879729695566328 + 0.34420946389266593, + 0.34420946389266593 ], - "best_of_k_cost": 0.5879729695566328, + "best_of_k_cost": 0.34420946389266593, "best_seed": 0, "median_seed": 0, "worst_seed": 0 @@ -4579,252 +4579,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.013694557960544522, - "label_overlap": 0.00026148673684916454, - "label_connector_overlap": 0.45568562377621297, - "crossings": 0.2, - "crowding": 0.023157404220743836, - "sprawl": 1.7350585180926998, - "long_connectors": 0.00025497302559060974, - "edge_length_cv": 0.5298837404528474, + "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, - "misalignment": 0.5319148936170213, - "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.804519173147058 + "weighted_cost": 1.31127300881991 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009562933545921248, - "label_overlap": 0.0002855994930455662, - "label_connector_overlap": 0.4269900255300387, - "crossings": 0.22857142857142856, - "crowding": 0.026065812036718046, - "sprawl": 1.6845132103459781, - "long_connectors": 0.00009469358719338286, - "edge_length_cv": 0.5449424414263883, + "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, - "misalignment": 0.5789473684210527, - "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.7800284272360827 + "weighted_cost": 1.2926846062911044 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.011638953138247428, - "label_overlap": 0.00033325500650263054, - "label_connector_overlap": 0.46877522738582256, - "crossings": 0.19047619047619047, - "crowding": 0.019347833413445762, - "sprawl": 1.6847438012231613, - "long_connectors": 0.000442501056854147, - "edge_length_cv": 0.5659437530902042, + "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, - "misalignment": 0.631578947368421, - "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.809276518555248 + "weighted_cost": 1.348289417294382 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012228782200654893, - "label_overlap": 0.00030509805366980825, - "label_connector_overlap": 0.4072921808271034, - "crossings": 0.2, - "crowding": 0.027427766687565323, - "sprawl": 1.6775412115396586, - "long_connectors": 0.00040800445796101325, - "edge_length_cv": 0.5585503711799498, + "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, - "misalignment": 0.6, - "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.7274748086269547 + "weighted_cost": 1.3113551690675163 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.011447994283651878, - "label_overlap": 0.0002855994930455662, - "label_connector_overlap": 0.47475145019179604, - "crossings": 0.20952380952380953, - "crowding": 0.02653459416031592, - "sprawl": 1.6964793117171, - "long_connectors": 0.0013007252658454951, - "edge_length_cv": 0.5531268810625588, + "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, - "misalignment": 0.5473684210526315, - "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.828288378771653 + "weighted_cost": 1.2962414237643372 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009562933545921248, - "label_overlap": 0.0002855994930455662, - "label_connector_overlap": 0.4269900255300387, - "crossings": 0.22857142857142856, - "crowding": 0.026065812036718046, - "sprawl": 1.6845132103459781, - "long_connectors": 0.00009469358719338286, - "edge_length_cv": 0.5449424414263883, + "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, - "misalignment": 0.5789473684210527, - "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.7800284272360827 + "weighted_cost": 1.411184781696723 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01048780414889235, - "label_overlap": 0.00021967591092924717, - "label_connector_overlap": 0.44067089924169023, - "crossings": 0.19047619047619047, - "crowding": 0.024506237212747896, - "sprawl": 1.696941433294048, - "long_connectors": 0.00005330792760332216, - "edge_length_cv": 0.5403344574084297, + "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, - "misalignment": 0.5368421052631579, - "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.7616347912426324 + "weighted_cost": 1.369260956513894 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009936137118681018, - "label_overlap": 0.0002855994930455662, - "label_connector_overlap": 0.41635014814152804, - "crossings": 0.22857142857142856, - "crowding": 0.024784562084357004, - "sprawl": 1.6821232303118019, - "long_connectors": 0.00009469358719338286, - "edge_length_cv": 0.5420324031953525, + "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, - "misalignment": 0.5789473684210527, - "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.762936273337931 + "weighted_cost": 1.2962414237643372 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.010670272600082801, - "label_overlap": 0.0002855994930455662, - "label_connector_overlap": 0.43271391685171456, - "crossings": 0.22857142857142856, - "crowding": 0.026065812036718046, - "sprawl": 1.700785729925124, - "long_connectors": 0.0002725223975255504, - "edge_length_cv": 0.53627086427199, + "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, - "misalignment": 0.5578947368421052, - "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.7937057718872684 + "weighted_cost": 1.3337457275872124 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01488480016081996, - "label_overlap": 0.0002522009235372137, - "label_connector_overlap": 0.45640211279403425, - "crossings": 0.2, - "crowding": 0.02363768835060908, - "sprawl": 1.6851388030810486, - "long_connectors": 0.0010329411898757214, - "edge_length_cv": 0.5535939290017915, + "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, - "misalignment": 0.5319148936170213, - "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.7992305356188043 + "weighted_cost": 1.2472779279600736 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.01398956509162962, - "label_overlap": 0.0002984518167107434, - "label_connector_overlap": 0.4618764585178653, - "crossings": 0.18095238095238095, - "crowding": 0.030853983008026338, - "sprawl": 1.6887927517140062, - "long_connectors": 0.0001067117136648143, - "edge_length_cv": 0.5563941299831441, + "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, - "misalignment": 0.5638297872340425, - "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.7968048280660645 + "weighted_cost": 1.2962414237643372 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012894769358283226, - "label_overlap": 0.00028223949900973603, - "label_connector_overlap": 0.44500580599051903, - "crossings": 0.23809523809523808, - "crowding": 0.024717549543468383, - "sprawl": 1.693249139317837, - "long_connectors": 0.00036045512160048026, - "edge_length_cv": 0.5466254311343512, + "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, - "misalignment": 0.5625, - "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.818019006393904 + "weighted_cost": 1.3715871846209906 } ], - "median_cost": 1.7952552999766664, + "median_cost": 1.3113140889437132, "spread": [ - 1.7757553887615447, - 1.8057085094991054 + 1.2962414237643372, + 1.3535323020992602 ], - "best_of_k_cost": 1.7937057718872684, - "best_seed": 3, - "median_seed": 42, - "worst_seed": 4 + "best_of_k_cost": 1.2472779279600736, + "best_seed": 123, + "median_seed": 0, + "worst_seed": 5 }, { "model": "wrld3_03", @@ -4833,252 +4833,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.015572388693763017, + "node_connector_overlap": 0.008026816545998182, "label_overlap": 0.0, - "label_connector_overlap": 0.5286197245943369, - "crossings": 1.2185929648241205, - "crowding": 0.0038362452071276903, - "sprawl": 2.525411610958908, - "long_connectors": 0.010973751568254156, - "edge_length_cv": 0.6914077229100626, + "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, - "misalignment": 0.5859154929577465, - "loop_compactness": 0.8560717103122828, + "misalignment": 0.8088642659279779, + "loop_compactness": 0.800515656422774, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.1691461949504736 + "weighted_cost": 3.475130413831593 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012502333137228771, + "node_connector_overlap": 0.009837847663096097, "label_overlap": 0.0, - "label_connector_overlap": 0.5004102163223417, - "crossings": 1.271356783919598, - "crowding": 0.0010248893304223008, - "sprawl": 3.3839607131009264, - "long_connectors": 0.008262870933632718, - "edge_length_cv": 0.6558585682918281, + "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, - "misalignment": 0.6761363636363636, - "loop_compactness": 0.8315265988369029, + "misalignment": 0.73109243697479, + "loop_compactness": 0.7664329771091912, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.3831301623440884 + "weighted_cost": 2.939091629111395 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005535729158511072, + "node_connector_overlap": 0.007780783689326532, "label_overlap": 0.0, - "label_connector_overlap": 0.4658818598589807, - "crossings": 1.3819095477386936, - "crowding": 0.00039847283224037907, - "sprawl": 4.506558146886454, - "long_connectors": 0.006976775473588293, - "edge_length_cv": 0.6324108069090563, + "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, - "misalignment": 0.7655367231638418, - "loop_compactness": 0.6419705976766702, + "misalignment": 0.7694444444444444, + "loop_compactness": 0.8185122070909749, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.6404547132175393 + "weighted_cost": 3.3393251722277637 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0107562111632543, - "label_overlap": 0.0, - "label_connector_overlap": 0.503723708759342, - "crossings": 1.4020100502512562, - "crowding": 0.0010123876941172227, - "sprawl": 3.320240625705552, - "long_connectors": 0.011693760116720568, - "edge_length_cv": 0.6904975184787713, + "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.6676136363636364, - "loop_compactness": 0.8651852490477071, + "misalignment": 0.6537396121883656, + "loop_compactness": 0.68699286477665, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.513645531846742 + "weighted_cost": 2.951123214242883 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.012941227991718388, + "node_connector_overlap": 0.007201451131395623, "label_overlap": 0.0, - "label_connector_overlap": 0.5516631938266956, - "crossings": 1.4673366834170853, - "crowding": 0.004187679045091946, - "sprawl": 2.6133356388012308, - "long_connectors": 0.006562510437388137, - "edge_length_cv": 0.6590378494054465, + "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, - "misalignment": 0.6142857142857143, - "loop_compactness": 0.8380507083644845, + "misalignment": 0.8072625698324023, + "loop_compactness": 0.7107217310097007, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.4629482375746767 + "weighted_cost": 3.4239264453527856 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.014930233150242491, - "label_overlap": 0.0007051551101393699, - "label_connector_overlap": 0.5528689386771439, - "crossings": 1.2010050251256281, - "crowding": 0.023757123057085043, - "sprawl": 1.8820374606643302, - "long_connectors": 0.013581226879381575, - "edge_length_cv": 0.7010283355165973, + "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.49857549857549854, - "loop_compactness": 0.8486902073837164, + "misalignment": 0.8156424581005587, + "loop_compactness": 0.7894590802356498, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.0378102854968643 + "weighted_cost": 3.451283551529896 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.013245348564510508, + "node_connector_overlap": 0.006147718628178235, "label_overlap": 0.0, - "label_connector_overlap": 0.5276135676132545, - "crossings": 1.3165829145728642, - "crowding": 0.004723621382385712, - "sprawl": 2.5130623675228265, - "long_connectors": 0.013776823274982659, - "edge_length_cv": 0.6899016905726063, + "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.6136363636363636, - "loop_compactness": 0.839578355794327, + "misalignment": 0.8027777777777778, + "loop_compactness": 0.8067075695820783, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.2563491753993703 + "weighted_cost": 3.2610797428412477 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0073836956987028655, + "node_connector_overlap": 0.0048681119174894105, "label_overlap": 0.0, - "label_connector_overlap": 0.5247141017615626, - "crossings": 1.5251256281407035, - "crowding": 0.0018815581981534316, - "sprawl": 3.396148422003256, - "long_connectors": 0.010053695349774483, - "edge_length_cv": 0.682760537856356, + "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.6875, - "loop_compactness": 0.8200117888058551, + "misalignment": 0.807799442896936, + "loop_compactness": 0.7791596719462608, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.6644470077723024 + "weighted_cost": 3.521155506639415 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0063712236126864125, + "node_connector_overlap": 0.0025254017781143885, "label_overlap": 0.0, - "label_connector_overlap": 0.47821530978324406, - "crossings": 1.3190954773869348, - "crowding": 0.0004719890333401077, - "sprawl": 4.456902366346363, - "long_connectors": 0.008318991589672602, - "edge_length_cv": 0.655612664236303, + "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.8192090395480226, - "loop_compactness": 0.8555230827709296, + "misalignment": 0.8770949720670391, + "loop_compactness": 0.8070612114478872, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.676930711460767 + "weighted_cost": 4.138569510929956 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007214819571877599, - "label_overlap": 0.0, - "label_connector_overlap": 0.5023844723238251, - "crossings": 1.6055276381909547, - "crowding": 0.0009787724277578872, - "sprawl": 3.383480650040559, - "long_connectors": 0.01457076554286801, - "edge_length_cv": 0.6981814201197211, + "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.6864406779661016, - "loop_compactness": 0.8314813707773371, + "misalignment": 0.7170868347338936, + "loop_compactness": 0.7483095609041643, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.713687528332976 + "weighted_cost": 3.248073874455624 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.010161402179077524, - "label_overlap": 0.00025957170668397143, - "label_connector_overlap": 0.5215620733243398, - "crossings": 1.4195979899497488, - "crowding": 0.0038521451647914873, - "sprawl": 2.5835231277998347, - "long_connectors": 0.007634843990830857, - "edge_length_cv": 0.670767435822502, + "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.6118980169971671, - "loop_compactness": 0.8598660159507296, + "misalignment": 0.7737430167597765, + "loop_compactness": 0.828742074520958, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.3666415711536337 + "weighted_cost": 3.3661492863458222 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009844350050413779, + "node_connector_overlap": 0.01011006801876132, "label_overlap": 0.0, - "label_connector_overlap": 0.5340388187567631, - "crossings": 1.5025125628140703, - "crowding": 0.004288074050205525, - "sprawl": 2.5550483942086095, - "long_connectors": 0.01161098769101577, - "edge_length_cv": 0.6899247978227445, + "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.546742209631728, - "loop_compactness": 0.8680142533534396, + "misalignment": 0.7036011080332409, + "loop_compactness": 0.6979058407413977, "flow_bends": 0.0, - "loop_straightness": 0.8478260869565217 + "loop_straightness": 0.8888888888888888 }, - "weighted_cost": 3.4587776884981096 + "weighted_cost": 3.0224200921026094 } ], - "median_cost": 3.4608629630363934, + "median_cost": 3.352737229286793, "spread": [ - 3.3390684722150676, - 3.64645278685623 + 3.1916604288673707, + 3.4572452671053204 ], - "best_of_k_cost": 3.3666415711536337, - "best_seed": 5, - "median_seed": 4, - "worst_seed": 123 + "best_of_k_cost": 3.0224200921026094, + "best_seed": 1, + "median_seed": 2, + "worst_seed": 42 }, { "model": "beer_game", @@ -5087,252 +5087,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.020022448120181228, + "node_connector_overlap": 0.011597200092945304, "label_overlap": 0.0, - "label_connector_overlap": 0.5175683482708673, - "crossings": 0.62, - "crowding": 0.011086673116601883, - "sprawl": 2.2790534285651685, - "long_connectors": 0.0, - "edge_length_cv": 0.4503255017098236, + "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.4642857142857143, - "loop_compactness": 0.68449242284427, + "misalignment": 0.5609756097560976, + "loop_compactness": 0.8336198883450799, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.4220883740862216 + "weighted_cost": 2.7574503436496425 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.020158651542442854, + "node_connector_overlap": 0.002548513047123638, "label_overlap": 0.0, - "label_connector_overlap": 0.44278162866156956, - "crossings": 0.69, - "crowding": 0.0032893846960740415, - "sprawl": 2.257574661334337, - "long_connectors": 0.0, - "edge_length_cv": 0.4637480668662704, - "aspect_penalty": 0.0, - "misalignment": 0.40476190476190477, - "loop_compactness": 0.7055014757109696, + "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.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.3694649614828616 + "weighted_cost": 4.041246488162679 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.020022448120181228, + "node_connector_overlap": 0.0018289559301755962, "label_overlap": 0.0, - "label_connector_overlap": 0.5175683482708673, - "crossings": 0.62, - "crowding": 0.011086673116601883, - "sprawl": 2.2790534285651685, - "long_connectors": 0.0, - "edge_length_cv": 0.4503255017098236, + "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.4642857142857143, - "loop_compactness": 0.68449242284427, + "misalignment": 0.7073170731707317, + "loop_compactness": 0.8347964038472144, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.4220883740862216 + "weighted_cost": 4.216826732424902 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02066485651295293, + "node_connector_overlap": 0.04128807427904672, "label_overlap": 0.0, - "label_connector_overlap": 0.4557000963010446, - "crossings": 0.6, - "crowding": 0.006875258586801746, - "sprawl": 2.336301974877801, - "long_connectors": 0.0, - "edge_length_cv": 0.4905588594842285, + "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.45783132530120485, - "loop_compactness": 0.6953539729374377, + "misalignment": 0.4819277108433735, + "loop_compactness": 0.6013892825448071, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.324370716104205 + "weighted_cost": 2.0014555042794098 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.018293366308734807, + "node_connector_overlap": 0.0027473606635675463, "label_overlap": 0.0, - "label_connector_overlap": 0.469675453959938, - "crossings": 0.6, - "crowding": 0.004903458201487479, - "sprawl": 2.2351001735332336, - "long_connectors": 0.0, - "edge_length_cv": 0.47972145484406864, + "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, - "misalignment": 0.42352941176470593, - "loop_compactness": 0.682473153680573, + "misalignment": 0.7, + "loop_compactness": 0.7557695839224665, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.304736002406257 + "weighted_cost": 4.050885866783448 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.019178672719521256, + "node_connector_overlap": 0.020388227690251805, "label_overlap": 0.0, - "label_connector_overlap": 0.4802064017697673, - "crossings": 0.63, - "crowding": 0.010008889145096758, - "sprawl": 2.2824991318413717, - "long_connectors": 0.0, - "edge_length_cv": 0.5027084630876966, + "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, - "misalignment": 0.47619047619047616, - "loop_compactness": 0.7406729000260835, + "misalignment": 0.47560975609756095, + "loop_compactness": 0.6838778741379732, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.3978042124439987 + "weighted_cost": 2.1821108734617964 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006285108984130271, + "node_connector_overlap": 0.00218934956689381, "label_overlap": 0.0, - "label_connector_overlap": 0.43255927743090594, - "crossings": 0.67, + "label_connector_overlap": 0.1323371197267114, + "crossings": 1.072072072072072, "crowding": 0.0, - "sprawl": 4.053217292216389, - "long_connectors": 0.0, - "edge_length_cv": 0.457079862849838, + "sprawl": 9.036784598622166, + "long_connectors": 0.003700456094287436, + "edge_length_cv": 0.5335482903730038, "aspect_penalty": 0.0, - "misalignment": 0.5, - "loop_compactness": 0.7322735054969137, + "misalignment": 0.691358024691358, + "loop_compactness": 0.8444477837628889, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.772238243982867 + "weighted_cost": 4.036667744472903 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.022406190535740552, + "node_connector_overlap": 0.007272669602617505, "label_overlap": 0.0, - "label_connector_overlap": 0.4632725659523487, - "crossings": 0.59, - "crowding": 0.017978555595564494, - "sprawl": 2.2740395076391375, - "long_connectors": 0.0, - "edge_length_cv": 0.48979786972697426, + "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, - "misalignment": 0.426829268292683, - "loop_compactness": 0.6779565732111612, + "misalignment": 0.5731707317073171, + "loop_compactness": 0.7570749935258998, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.3146906032344705 + "weighted_cost": 2.596351246414167 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.020022448120181228, + "node_connector_overlap": 0.002284031496054041, "label_overlap": 0.0, - "label_connector_overlap": 0.5175683482708673, - "crossings": 0.62, - "crowding": 0.011086673116601883, - "sprawl": 2.2790534285651685, - "long_connectors": 0.0, - "edge_length_cv": 0.4503255017098236, + "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, - "misalignment": 0.4642857142857143, - "loop_compactness": 0.68449242284427, + "misalignment": 0.6867469879518072, + "loop_compactness": 0.7718761657607727, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.4220883740862216 + "weighted_cost": 3.8598161390397774 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.018295496265333687, + "node_connector_overlap": 0.028430870064508396, "label_overlap": 0.0, - "label_connector_overlap": 0.44584119673352934, - "crossings": 0.65, - "crowding": 0.004405830521328966, - "sprawl": 2.31937981273737, - "long_connectors": 0.0, - "edge_length_cv": 0.45781394079774046, + "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, - "misalignment": 0.36585365853658536, - "loop_compactness": 0.7337261998113247, + "misalignment": 0.46341463414634143, + "loop_compactness": 0.8036309982333548, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.3542948017302057 + "weighted_cost": 2.2317675654230587 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0438897516083697, + "node_connector_overlap": 0.0016425351149707166, "label_overlap": 0.0, - "label_connector_overlap": 0.41039526182061165, - "crossings": 0.59, - "crowding": 0.004776683818501667, - "sprawl": 2.2554744659811647, - "long_connectors": 0.0, - "edge_length_cv": 0.5206409279680329, + "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, - "misalignment": 0.45238095238095233, - "loop_compactness": 0.721850987510048, + "misalignment": 0.7073170731707317, + "loop_compactness": 0.7073085252488032, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.280611571118949 + "weighted_cost": 3.7232547046899485 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.024482980383915953, + "node_connector_overlap": 0.018331555266042483, "label_overlap": 0.0, - "label_connector_overlap": 0.5041843536311619, - "crossings": 0.62, - "crowding": 0.011915132327146506, - "sprawl": 2.312090770180308, - "long_connectors": 0.0, - "edge_length_cv": 0.44618476652345873, + "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, - "misalignment": 0.4285714285714286, - "loop_compactness": 0.6844924228442685, + "misalignment": 0.426829268292683, + "loop_compactness": 0.7735632392621542, "flow_bends": 0.0, - "loop_straightness": 0.8461538461538461 + "loop_straightness": 0.9375 }, - "weighted_cost": 2.416449812697033 + "weighted_cost": 2.007191723569562 } ], - "median_cost": 2.38363458696343, + "median_cost": 3.2403525241697952, "spread": [ - 2.3219506878867713, - 2.4220883740862216 + 2.219353392432743, + 4.037812430395347 ], - "best_of_k_cost": 2.280611571118949, - "best_seed": 456, - "median_seed": 1, - "worst_seed": 6 + "best_of_k_cost": 2.007191723569562, + "best_seed": 3, + "median_seed": 0, + "worst_seed": 2 }, { "model": "wonderland", @@ -5341,252 +5341,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.011842011400760587, - "label_overlap": 0.0005204014619917476, - "label_connector_overlap": 0.40668326288108675, - "crossings": 0.1346153846153846, - "crowding": 0.0264317350930829, - "sprawl": 1.5529742712604704, - "long_connectors": 0.0027890050349126208, - "edge_length_cv": 0.5657597957286353, + "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, - "misalignment": 0.5510204081632653, - "loop_compactness": 0.6521605242362543, + "misalignment": 0.6938775510204082, + "loop_compactness": 0.8632290322943468, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.5637202243304535 + "weighted_cost": 1.3429719935265227 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005324047963756839, - "label_overlap": 0.0004505991710809624, - "label_connector_overlap": 0.30129079688586385, - "crossings": 0.1346153846153846, - "crowding": 0.032845810461274674, - "sprawl": 1.4963218868749228, - "long_connectors": 0.006802760590201837, - "edge_length_cv": 0.5914107229358416, + "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, - "misalignment": 0.5306122448979591, - "loop_compactness": 0.48166469605376566, + "misalignment": 0.5918367346938775, + "loop_compactness": 0.8690561880904417, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.3163699998953475 + "weighted_cost": 1.3194450968921423 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.014472351378418189, - "label_overlap": 0.010479953287388053, - "label_connector_overlap": 0.30369670672719906, - "crossings": 0.15384615384615385, - "crowding": 0.05373601470969349, - "sprawl": 1.4889559711168476, - "long_connectors": 0.0056586344527813125, - "edge_length_cv": 0.5680100171972058, + "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, - "misalignment": 0.5918367346938775, - "loop_compactness": 0.5379659453871359, + "misalignment": 0.6326530612244898, + "loop_compactness": 0.8897056640927612, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.439728591077647 + "weighted_cost": 1.392746865751018 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007731219423780055, - "label_overlap": 0.0005420860636048062, - "label_connector_overlap": 0.34007383780959644, - "crossings": 0.15384615384615385, - "crowding": 0.04336734693877551, - "sprawl": 1.5626531327019215, - "long_connectors": 0.004460242288205139, - "edge_length_cv": 0.5661788104752684, + "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, - "misalignment": 0.4897959183673469, - "loop_compactness": 0.47216302249773134, + "misalignment": 0.6326530612244898, + "loop_compactness": 0.8381453631700538, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.4169606642633725 + "weighted_cost": 1.2903277340727324 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.013787441331784701, - "label_overlap": 0.000640575891404105, - "label_connector_overlap": 0.40131037847797, - "crossings": 0.11538461538461539, - "crowding": 0.05437427107608682, - "sprawl": 1.493420920985673, - "long_connectors": 0.013342161661229354, - "edge_length_cv": 0.6473545550538722, + "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, - "misalignment": 0.5800000000000001, - "loop_compactness": 0.5335232531681342, + "misalignment": 0.54, + "loop_compactness": 0.865805649676201, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.5145154263438896 + "weighted_cost": 1.329476152147405 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.016171780908997326, - "label_overlap": 0.0005289017893514109, - "label_connector_overlap": 0.3349754434088236, - "crossings": 0.21153846153846154, - "crowding": 0.046252680946694345, - "sprawl": 1.4847509813384698, - "long_connectors": 0.011184212778795092, - "edge_length_cv": 0.6119508656460726, + "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, - "misalignment": 0.6122448979591837, - "loop_compactness": 0.49107389469874596, + "misalignment": 0.4693877551020408, + "loop_compactness": 0.8232809739499274, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.4904213866170093 + "weighted_cost": 1.2301923423298062 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.009231687427713395, - "label_overlap": 0.0005816742599001578, - "label_connector_overlap": 0.3266396217749026, - "crossings": 0.1346153846153846, - "crowding": 0.043572758545151975, - "sprawl": 1.545578273940615, - "long_connectors": 0.0037165075658005856, - "edge_length_cv": 0.5218737705490942, + "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, "misalignment": 0.5714285714285714, - "loop_compactness": 0.5343484186756164, + "loop_compactness": 0.8852199962531863, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.4093203190075871 + "weighted_cost": 1.3995392356716625 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.013015432708125456, - "label_overlap": 0.000637326095233826, - "label_connector_overlap": 0.3161425747189096, - "crossings": 0.15384615384615385, - "crowding": 0.05295696485537152, - "sprawl": 1.4929536005749129, - "long_connectors": 0.006419776893031433, - "edge_length_cv": 0.5631581836353289, + "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, - "misalignment": 0.5510204081632653, - "loop_compactness": 0.5240520484752231, + "misalignment": 0.6326530612244898, + "loop_compactness": 0.8642616574174716, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.4119880978645805 + "weighted_cost": 1.4084873271557372 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006561082178295447, - "label_overlap": 0.00046105628298931823, - "label_connector_overlap": 0.3330283153470702, - "crossings": 0.11538461538461539, - "crowding": 0.0337799242133768, - "sprawl": 1.5217518265222731, - "long_connectors": 0.005017634321620256, - "edge_length_cv": 0.5716290091088273, + "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, - "misalignment": 0.5510204081632653, - "loop_compactness": 0.5216707279949413, + "misalignment": 0.5714285714285714, + "loop_compactness": 0.8494260286117654, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.371698441309794 + "weighted_cost": 1.255930427187368 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00992736494588411, - "label_overlap": 0.010752053801823687, - "label_connector_overlap": 0.36752215048680975, - "crossings": 0.1346153846153846, - "crowding": 0.024556510919070195, - "sprawl": 1.5410193916827395, - "long_connectors": 0.016214352170625747, - "edge_length_cv": 0.6350349742195764, + "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, - "misalignment": 0.5918367346938775, - "loop_compactness": 0.6231344218760133, + "misalignment": 0.5306122448979591, + "loop_compactness": 0.8422461036313983, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.5312799672270727 + "weighted_cost": 1.373905191997534 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.014183965092465015, - "label_overlap": 0.000637326095233826, - "label_connector_overlap": 0.31980847737820195, - "crossings": 0.1346153846153846, - "crowding": 0.04441010756170548, - "sprawl": 1.4828342868602136, - "long_connectors": 0.006314976320968418, - "edge_length_cv": 0.5660511304523829, + "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, - "misalignment": 0.5510204081632653, - "loop_compactness": 0.5878985778713302, + "misalignment": 0.7142857142857143, + "loop_compactness": 0.8056614695700871, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.4150027731414991 + "weighted_cost": 1.3386984137955233 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0029608620660240602, - "label_overlap": 0.0006507472897726539, - "label_connector_overlap": 0.3250468195269403, - "crossings": 0.19230769230769232, - "crowding": 0.06553827222910726, - "sprawl": 1.3932971171349964, - "long_connectors": 0.018725444959522983, - "edge_length_cv": 0.7379166733126099, + "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, - "misalignment": 0.653061224489796, - "loop_compactness": 0.7124993063621157, + "misalignment": 0.4897959183673469, + "loop_compactness": 0.84096301991364, "flow_bends": 0.0, - "loop_straightness": 0.6153846153846154 + "loop_straightness": 0.782608695652174 }, - "weighted_cost": 1.5231468417692604 + "weighted_cost": 1.300946940868284 } ], - "median_cost": 1.4283446276705098, + "median_cost": 1.3340872829714643, "spread": [ - 1.411321153150332, - 1.5166732802002323 + 1.298292139169396, + 1.378615610435905 ], - "best_of_k_cost": 1.371698441309794, - "best_seed": 1, - "median_seed": 2, - "worst_seed": 0 + "best_of_k_cost": 1.255930427187368, + "best_seed": 5, + "median_seed": 456, + "worst_seed": 7 }, { "model": "mortgage_econ", @@ -5594,253 +5594,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.013693770398751262, - "node_connector_overlap": 0.01040053079729197, - "label_overlap": 0.00001874162477953097, - "label_connector_overlap": 0.4124708370064381, - "crossings": 0.21951219512195122, - "crowding": 0.023895997723297473, - "sprawl": 1.2781649381210607, - "long_connectors": 0.0029820176477094945, - "edge_length_cv": 0.4294161464441858, + "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, - "misalignment": 0.4482758620689655, - "loop_compactness": 0.7464557344017121, + "misalignment": 0.5813953488372092, + "loop_compactness": 0.6661316141689497, "flow_bends": 0.0, - "loop_straightness": 0.2127779595817625 + "loop_straightness": 0.2830326965057852 }, - "weighted_cost": 1.616629221311725 + "weighted_cost": 1.2427694541243828 }, { "seed": 1, "metrics": { - "node_overlap": 0.013853000287108834, - "node_connector_overlap": 0.008177774367075051, - "label_overlap": 0.00004210393544710142, - "label_connector_overlap": 0.3843000311993992, - "crossings": 0.2073170731707317, - "crowding": 0.015687470520710595, - "sprawl": 1.243286315194182, - "long_connectors": 0.0032976256214132713, - "edge_length_cv": 0.4426922108323814, + "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, - "misalignment": 0.5232558139534884, - "loop_compactness": 0.5549144982099412, + "misalignment": 0.5465116279069768, + "loop_compactness": 0.6372235830025625, "flow_bends": 0.0, - "loop_straightness": 0.12668151475175202 + "loop_straightness": 0.2962730566010116 }, - "weighted_cost": 1.4638729277673899 + "weighted_cost": 1.2153613585316287 }, { "seed": 2, "metrics": { - "node_overlap": 0.013853000287108834, - "node_connector_overlap": 0.011693124832717623, - "label_overlap": 0.0012563703182779586, - "label_connector_overlap": 0.42554222819781423, - "crossings": 0.17073170731707318, - "crowding": 0.0297290997032429, - "sprawl": 1.2346960587881983, - "long_connectors": 0.0033347979106033462, - "edge_length_cv": 0.4486734135227615, + "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, - "misalignment": 0.4651162790697675, - "loop_compactness": 0.4988691465982324, + "misalignment": 0.5348837209302326, + "loop_compactness": 0.623258285966177, "flow_bends": 0.0, - "loop_straightness": 0.16634680892458886 + "loop_straightness": 0.2988799200909044 }, - "weighted_cost": 1.4880785771924065 + "weighted_cost": 1.1701060920209443 }, { "seed": 3, "metrics": { - "node_overlap": 0.021143749113097788, - "node_connector_overlap": 0.01921866276230942, - "label_overlap": 0.0004684226946538357, - "label_connector_overlap": 0.38067858892828377, - "crossings": 0.15853658536585366, - "crowding": 0.09838295224096388, - "sprawl": 0.9067191473356297, - "long_connectors": 0.0037304188372425753, - "edge_length_cv": 0.4493452495287874, + "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, - "misalignment": 0.3908045977011494, - "loop_compactness": 0.5128974548898316, + "misalignment": 0.5930232558139534, + "loop_compactness": 0.6274870443684626, "flow_bends": 0.0, - "loop_straightness": 0.20481209201322984 + "loop_straightness": 0.2940273278898046 }, - "weighted_cost": 1.435282995030892 + "weighted_cost": 1.2026576940540907 }, { "seed": 4, "metrics": { - "node_overlap": 0.0287838270303383, - "node_connector_overlap": 0.0324212216889203, - "label_overlap": 0.000896780968488125, - "label_connector_overlap": 0.4048947719037728, - "crossings": 0.17073170731707318, - "crowding": 0.122552466822154, - "sprawl": 0.9292113903786264, - "long_connectors": 0.002859175629347797, - "edge_length_cv": 0.453006162513223, + "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, - "misalignment": 0.31034482758620685, - "loop_compactness": 0.6089264638380886, + "misalignment": 0.5813953488372092, + "loop_compactness": 0.6208022117607791, "flow_bends": 0.0, - "loop_straightness": 0.18591454340072558 + "loop_straightness": 0.3159352635785473 }, - "weighted_cost": 1.5962798614118787 + "weighted_cost": 1.1616576580134819 }, { "seed": 5, "metrics": { - "node_overlap": 0.013853000287108834, - "node_connector_overlap": 0.014489617529305821, - "label_overlap": 0.000030464344305507277, - "label_connector_overlap": 0.41637078681948175, - "crossings": 0.2073170731707317, - "crowding": 0.020940648545613613, - "sprawl": 1.2922438063232113, - "long_connectors": 0.002393664549133571, - "edge_length_cv": 0.43829912953863087, + "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, - "misalignment": 0.4418604651162791, - "loop_compactness": 0.5315608934888757, + "misalignment": 0.5697674418604651, + "loop_compactness": 0.6310846028826644, "flow_bends": 0.0, - "loop_straightness": 0.24201841826002984 + "loop_straightness": 0.28955345839759666 }, - "weighted_cost": 1.5356552928026808 + "weighted_cost": 1.1899403016906158 }, { "seed": 6, "metrics": { - "node_overlap": 0.006273327591157072, - "node_connector_overlap": 0.007824231364931798, - "label_overlap": 0.0, - "label_connector_overlap": 0.4531799416336009, - "crossings": 0.21951219512195122, - "crowding": 0.008980077464608155, - "sprawl": 1.6526807925894411, - "long_connectors": 0.0030917651484321765, - "edge_length_cv": 0.41159900167398755, + "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, - "misalignment": 0.5, - "loop_compactness": 0.48681199271656445, + "misalignment": 0.4767441860465116, + "loop_compactness": 0.5971586321951298, "flow_bends": 0.0, - "loop_straightness": 0.20310364595729763 + "loop_straightness": 0.29922749178903774 }, - "weighted_cost": 1.625618536739806 + "weighted_cost": 1.1307500659326502 }, { "seed": 7, "metrics": { - "node_overlap": 0.013693770398751262, - "node_connector_overlap": 0.011546298535072044, - "label_overlap": 0.00002274816601542367, - "label_connector_overlap": 0.43963245756961566, - "crossings": 0.1951219512195122, - "crowding": 0.015965983127226267, - "sprawl": 1.2763693615305314, - "long_connectors": 0.002716792524725256, - "edge_length_cv": 0.3992024014964546, + "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, - "misalignment": 0.4367816091954023, - "loop_compactness": 0.555573099938561, + "misalignment": 0.5697674418604651, + "loop_compactness": 0.6345572600307907, "flow_bends": 0.0, - "loop_straightness": 0.1848187017899759 + "loop_straightness": 0.3118693596100894 }, - "weighted_cost": 1.5464770404669472 + "weighted_cost": 1.2239247136749085 }, { "seed": 42, "metrics": { - "node_overlap": 0.006201220377465587, - "node_connector_overlap": 0.004767100232412607, - "label_overlap": 0.0, - "label_connector_overlap": 0.4145773333687313, - "crossings": 0.1951219512195122, - "crowding": 0.013809330444728509, - "sprawl": 1.6800513393052905, - "long_connectors": 0.0024891989189802016, - "edge_length_cv": 0.39458057183927747, + "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, - "misalignment": 0.5632183908045977, - "loop_compactness": 0.5377457099744919, + "misalignment": 0.5348837209302326, + "loop_compactness": 0.6032611759643869, "flow_bends": 0.0, - "loop_straightness": 0.18381008024520223 + "loop_straightness": 0.28946496949152034 }, - "weighted_cost": 1.573094318883882 + "weighted_cost": 1.1238997682236598 }, { "seed": 123, "metrics": { - "node_overlap": 0.013853000287108834, - "node_connector_overlap": 0.008448316467509199, - "label_overlap": 0.000014396589012814155, - "label_connector_overlap": 0.39658420462794564, - "crossings": 0.17073170731707318, - "crowding": 0.017419881472261593, - "sprawl": 1.2613080959372562, - "long_connectors": 0.0024464178979556515, - "edge_length_cv": 0.39457366601434846, + "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, - "misalignment": 0.4767441860465116, - "loop_compactness": 0.5820206447378818, + "misalignment": 0.627906976744186, + "loop_compactness": 0.6135968465124343, "flow_bends": 0.0, - "loop_straightness": 0.18798941983588344 + "loop_straightness": 0.2889552594771149 }, - "weighted_cost": 1.4642922691493816 + "weighted_cost": 1.2423638247669901 }, { "seed": 456, "metrics": { - "node_overlap": 0.013853000287108834, - "node_connector_overlap": 0.012378620643396347, - "label_overlap": 0.000027513190235392115, - "label_connector_overlap": 0.347356779119712, - "crossings": 0.17073170731707318, - "crowding": 0.017528618661598803, - "sprawl": 1.23137617957387, - "long_connectors": 0.004275363577927803, - "edge_length_cv": 0.4724448722728046, + "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, - "misalignment": 0.4418604651162791, - "loop_compactness": 0.5959226302920894, + "misalignment": 0.5697674418604651, + "loop_compactness": 0.6335358877577004, "flow_bends": 0.0, - "loop_straightness": 0.21156720578845886 + "loop_straightness": 0.2794888991536159 }, - "weighted_cost": 1.3963280790054786 + "weighted_cost": 1.135167733554949 }, { "seed": 789, "metrics": { - "node_overlap": 0.006201220377465587, - "node_connector_overlap": 0.007679529802912755, - "label_overlap": 0.0, - "label_connector_overlap": 0.40193789818182907, - "crossings": 0.2073170731707317, - "crowding": 0.010403474485490227, - "sprawl": 1.6867088223967823, - "long_connectors": 0.002428130642699764, - "edge_length_cv": 0.4202411369545741, + "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, - "misalignment": 0.5632183908045977, - "loop_compactness": 0.561359966904477, + "misalignment": 0.6395348837209303, + "loop_compactness": 0.6125987210639157, "flow_bends": 0.0, - "loop_straightness": 0.21721872263904585 + "loop_straightness": 0.3006742739200878 }, - "weighted_cost": 1.5831696948826213 + "weighted_cost": 1.230498974002603 } ], - "median_cost": 1.541066166634814, + "median_cost": 1.1962989978723533, "spread": [ - 1.4641874338038838, - 1.5864472365149356 + 1.1550351768988487, + 1.225568278756832 ], - "best_of_k_cost": 1.3963280790054786, - "best_seed": 456, - "median_seed": 5, - "worst_seed": 6 + "best_of_k_cost": 1.1238997682236598, + "best_seed": 42, + "median_seed": 3, + "worst_seed": 0 }, { "model": "land_use", @@ -5848,253 +5848,253 @@ { "seed": 0, "metrics": { - "node_overlap": 0.008997867803837952, - "node_connector_overlap": 0.056117291570325314, - "label_overlap": 0.007172114756029206, - "label_connector_overlap": 0.42495936655061023, - "crossings": 0.5, - "crowding": 0.038456679800438945, - "sprawl": 1.2480236919234147, - "long_connectors": 0.014246592189297707, - "edge_length_cv": 0.649043414753701, - "aspect_penalty": 0.10873543260381124, - "misalignment": 0.5223880597014925, - "loop_compactness": 0.5813712753042742, + "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, + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, "flow_bends": 0.4, - "loop_straightness": 0.2381738983218848 + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 2.03245917672609 + "weighted_cost": 1.8218174952606974 }, { "seed": 1, "metrics": { - "node_overlap": 0.008997867803837952, - "node_connector_overlap": 0.04627031842754376, - "label_overlap": 0.005092175131001212, - "label_connector_overlap": 0.3784484947184568, - "crossings": 0.5833333333333334, - "crowding": 0.01147438510145448, - "sprawl": 1.7342651913624505, - "long_connectors": 0.006758554404669946, - "edge_length_cv": 0.6033437653521346, - "aspect_penalty": 0.027190909568809207, - "misalignment": 0.6119402985074627, - "loop_compactness": 0.6102178515054799, + "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, + "misalignment": 0.7007299270072993, + "loop_compactness": 0.55274023579804, "flow_bends": 0.4, - "loop_straightness": 0.24164193357938007 + "loop_straightness": 0.3429727814326075 }, - "weighted_cost": 2.1307271864933215 + "weighted_cost": 1.7816039464302358 }, { "seed": 2, "metrics": { - "node_overlap": 0.008997867803837952, - "node_connector_overlap": 0.03573064214310311, - "label_overlap": 0.005111791993510558, - "label_connector_overlap": 0.4362971349060946, - "crossings": 0.5972222222222222, - "crowding": 0.012261710226586345, - "sprawl": 2.273217927634735, - "long_connectors": 0.008997014153906725, - "edge_length_cv": 0.5985271505017521, - "aspect_penalty": 0.037957684434125394, - "misalignment": 0.6865671641791045, - "loop_compactness": 0.56258535983816, + "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, + "misalignment": 0.7132352941176471, + "loop_compactness": 0.5594868380156446, "flow_bends": 0.4, - "loop_straightness": 0.2508288549147973 + "loop_straightness": 0.28796431293799735 }, - "weighted_cost": 2.3363514632151676 + "weighted_cost": 1.8548129677396372 }, { "seed": 3, "metrics": { "node_overlap": 0.008931216931216932, - "node_connector_overlap": 0.041092723582942095, - "label_overlap": 0.005023083817651139, - "label_connector_overlap": 0.40314731564208844, - "crossings": 0.6041666666666666, - "crowding": 0.009722222222222222, - "sprawl": 1.6961161925709245, - "long_connectors": 0.01026561225734679, - "edge_length_cv": 0.6011729769877637, - "aspect_penalty": 0.12221656647654777, - "misalignment": 0.5851851851851853, - "loop_compactness": 0.5547687466913905, + "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, + "misalignment": 0.7111111111111111, + "loop_compactness": 0.5676849738885731, "flow_bends": 0.4, - "loop_straightness": 0.2553688036239825 + "loop_straightness": 0.3284550128058558 }, - "weighted_cost": 2.144760113967822 + "weighted_cost": 1.8755103323623519 }, { "seed": 4, "metrics": { - "node_overlap": 0.008800834202294057, - "node_connector_overlap": 0.041666750916489234, - "label_overlap": 0.005027728938392752, - "label_connector_overlap": 0.41937743421096524, - "crossings": 0.5902777777777778, - "crowding": 0.011984210955510631, - "sprawl": 1.6962185447019416, - "long_connectors": 0.013232848451165739, - "edge_length_cv": 0.6154940892120532, - "aspect_penalty": 0.10230283951998764, - "misalignment": 0.5912408759124088, - "loop_compactness": 0.5630739096954348, + "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, + "misalignment": 0.7279411764705883, + "loop_compactness": 0.5730185063011031, "flow_bends": 0.4, - "loop_straightness": 0.2601693464837388 + "loop_straightness": 0.31434790516676125 }, - "weighted_cost": 2.1641032593939755 + "weighted_cost": 1.8838294549540318 }, { "seed": 5, "metrics": { "node_overlap": 0.008931216931216932, - "node_connector_overlap": 0.045099553913648206, - "label_overlap": 0.005135457024607648, - "label_connector_overlap": 0.3926419713705743, - "crossings": 0.5833333333333334, - "crowding": 0.01119393854351798, - "sprawl": 1.7251165730225821, - "long_connectors": 0.00844346628779814, - "edge_length_cv": 0.5812379085500089, - "aspect_penalty": 0.09708191476799466, - "misalignment": 0.5629629629629629, - "loop_compactness": 0.5511661264801115, + "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, + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, "flow_bends": 0.4, - "loop_straightness": 0.263766152488173 + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 2.1215629341420983 + "weighted_cost": 1.8705332449789713 }, { "seed": 6, "metrics": { - "node_overlap": 0.008865546218487395, - "node_connector_overlap": 0.043190072390752406, - "label_overlap": 0.005006215932730438, - "label_connector_overlap": 0.3984706099898476, - "crossings": 0.5763888888888888, - "crowding": 0.01870019798855911, - "sprawl": 1.6986723176494816, - "long_connectors": 0.00958494564369222, - "edge_length_cv": 0.6183372482442994, - "aspect_penalty": 0.12353458757644753, - "misalignment": 0.5808823529411764, - "loop_compactness": 0.5458379516310521, + "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, + "misalignment": 0.762962962962963, + "loop_compactness": 0.5629785818037425, "flow_bends": 0.4, - "loop_straightness": 0.2724730904381128 + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 2.120857591397553 + "weighted_cost": 1.8734568414329014 }, { "seed": 7, "metrics": { "node_overlap": 0.008931216931216932, - "node_connector_overlap": 0.04020512946928141, - "label_overlap": 0.005048072957976342, - "label_connector_overlap": 0.3973786263942085, - "crossings": 0.5763888888888888, - "crowding": 0.010373100075321167, - "sprawl": 1.7212334916872887, - "long_connectors": 0.008630233840349744, - "edge_length_cv": 0.5986594565968657, - "aspect_penalty": 0.11566683046285586, - "misalignment": 0.6222222222222222, - "loop_compactness": 0.6120389917258025, + "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, + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5822745507532274, "flow_bends": 0.4, - "loop_straightness": 0.22924044404087426 + "loop_straightness": 0.31185349909251703 }, - "weighted_cost": 2.1367530552648897 + "weighted_cost": 1.8992221332143338 }, { "seed": 42, "metrics": { - "node_overlap": 0.008865546218487395, - "node_connector_overlap": 0.04511675601648066, - "label_overlap": 0.005047636154244, - "label_connector_overlap": 0.41487452076586695, - "crossings": 0.5486111111111112, - "crowding": 0.01087771489282873, - "sprawl": 1.6894761193326395, - "long_connectors": 0.011115705948597305, - "edge_length_cv": 0.6045190039096039, - "aspect_penalty": 0.04229204144137433, - "misalignment": 0.6029411764705883, - "loop_compactness": 0.5483328477985437, + "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, + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, "flow_bends": 0.4, - "loop_straightness": 0.2545527197047442 + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 2.113739669034671 + "weighted_cost": 1.8705332449789713 }, { "seed": 123, "metrics": { - "node_overlap": 0.008865546218487395, - "node_connector_overlap": 0.036167770221092396, - "label_overlap": 0.005085212275962662, - "label_connector_overlap": 0.4120992564236773, - "crossings": 0.5277777777777778, - "crowding": 0.011275739842895658, - "sprawl": 2.234051544315823, - "long_connectors": 0.00946898570472732, - "edge_length_cv": 0.6093207420467147, - "aspect_penalty": 0.04149108992339423, - "misalignment": 0.6470588235294117, - "loop_compactness": 0.558037841119737, + "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, + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, "flow_bends": 0.4, - "loop_straightness": 0.235194272446005 + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 2.2130534224057055 + "weighted_cost": 1.8705332449789713 }, { "seed": 456, "metrics": { - "node_overlap": 0.008997867803837952, - "node_connector_overlap": 0.04087019476408071, - "label_overlap": 0.005108821586583814, - "label_connector_overlap": 0.41089624242178524, - "crossings": 0.5694444444444444, - "crowding": 0.010920476763439232, - "sprawl": 1.7021859505723111, - "long_connectors": 0.008926179613588867, - "edge_length_cv": 0.6065325880351983, - "aspect_penalty": 0.07232561034013862, - "misalignment": 0.5447761194029851, - "loop_compactness": 0.5681472059896638, + "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, + "misalignment": 0.7481481481481482, + "loop_compactness": 0.5629785818037425, "flow_bends": 0.4, - "loop_straightness": 0.2595426416644441 + "loop_straightness": 0.3231233427773919 }, - "weighted_cost": 2.1255234231876794 + "weighted_cost": 1.8705332449789713 }, { "seed": 789, "metrics": { - "node_overlap": 0.008737060041407868, - "node_connector_overlap": 0.043395814051365576, - "label_overlap": 0.006970998844127809, - "label_connector_overlap": 0.404645221717569, - "crossings": 0.5, - "crowding": 0.0451932809247536, - "sprawl": 1.2824654863971627, - "long_connectors": 0.01124485509739876, - "edge_length_cv": 0.6295914771321264, - "aspect_penalty": 0.05656374193217961, - "misalignment": 0.5289855072463768, - "loop_compactness": 0.6542163593215728, + "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.2193168846431064 + "loop_straightness": 0.32395857140387685 }, - "weighted_cost": 2.016686529768781 + "weighted_cost": 1.8765972537185318 } ], - "median_cost": 2.1281253048405, + "median_cost": 1.8705332449789713, "spread": [ - 2.1190781108068326, - 2.1495959003243605 + 1.866603175669138, + 1.8757820627013968 ], - "best_of_k_cost": 2.016686529768781, - "best_seed": 789, - "median_seed": 456, - "worst_seed": 2 + "best_of_k_cost": 1.8705332449789713, + "best_seed": 1, + "median_seed": 5, + "worst_seed": 7 }, { "model": "scirev", @@ -6103,252 +6103,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005023063954558897, - "label_overlap": 0.00006165547732262022, - "label_connector_overlap": 0.6478933024301062, - "crossings": 2.4602510460251046, - "crowding": 0.0014756944444444444, - "sprawl": 4.413217055946651, - "long_connectors": 0.0, - "edge_length_cv": 0.4690793842542505, + "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, - "misalignment": 0.8, - "loop_compactness": 0.7900226559183088, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7967032967032968, + "loop_compactness": 0.779923503731026, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 5.089295788702287 + "weighted_cost": 3.8950004761777346 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.003718643097832968, - "label_overlap": 0.00015091385813115736, - "label_connector_overlap": 0.6212182437382824, - "crossings": 2.1631799163179917, - "crowding": 0.005818369149413545, - "sprawl": 5.304473271443493, - "long_connectors": 0.000610994239668717, - "edge_length_cv": 0.47653609295606086, + "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.8770949720670391, - "loop_compactness": 0.7931094837823149, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7071823204419889, + "loop_compactness": 0.8379213373309454, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 4.986322087628136 + "weighted_cost": 3.4027515574593727 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0031691726755202574, - "label_overlap": 0.000055147204960106984, - "label_connector_overlap": 0.6544211780219957, - "crossings": 2.3807531380753137, - "crowding": 0.004586585350068897, - "sprawl": 5.531269460850888, - "long_connectors": 0.0, - "edge_length_cv": 0.4816996524277825, + "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.8659217877094972, - "loop_compactness": 0.8662611774734862, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7213114754098361, + "loop_compactness": 0.7739439674900197, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 5.3355707121536895 + "weighted_cost": 3.5475275905490635 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.004703374787170532, - "label_overlap": 0.00009076248464353908, - "label_connector_overlap": 0.6122644957288166, - "crossings": 2.426778242677824, - "crowding": 0.004908592991600378, - "sprawl": 5.619111123251116, - "long_connectors": 8.693664334654772e-6, - "edge_length_cv": 0.538956368076091, + "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.8674033149171271, - "loop_compactness": 0.8743010088118505, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7016574585635359, + "loop_compactness": 0.8216233987728534, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 5.347204706348488 + "weighted_cost": 3.403635776334188 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0034564224011617187, - "label_overlap": 0.00011857882381027136, - "label_connector_overlap": 0.6077300886361133, - "crossings": 2.209205020920502, - "crowding": 0.0017786831610465989, - "sprawl": 5.506221479705362, - "long_connectors": 0.0, - "edge_length_cv": 0.47835003926543646, + "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.8722222222222222, - "loop_compactness": 0.8539470439865794, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7692307692307692, + "loop_compactness": 0.8167142164312676, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 5.081416963618419 + "weighted_cost": 3.3816268829758043 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005038318766440887, - "label_overlap": 0.0001499845731317493, - "label_connector_overlap": 0.5828094544276113, - "crossings": 2.221757322175732, - "crowding": 0.0018449571761196592, - "sprawl": 5.519637655883641, - "long_connectors": 0.0, - "edge_length_cv": 0.47792912970104645, + "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.8555555555555556, - "loop_compactness": 0.8697752887284731, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7348066298342542, + "loop_compactness": 0.8095731645895687, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 5.067946975703813 + "weighted_cost": 3.4209448693023603 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.004166469990543615, - "label_overlap": 0.00016058117107346686, - "label_connector_overlap": 0.5849137727267681, - "crossings": 2.514644351464435, - "crowding": 0.007881504329962596, - "sprawl": 5.271380105329591, - "long_connectors": 0.0, - "edge_length_cv": 0.5072989670169774, + "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.8777777777777778, - "loop_compactness": 0.8618490123154294, - "flow_bends": 0.3076923076923077, + "misalignment": 0.7472527472527473, + "loop_compactness": 0.7856831204021154, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 5.305307744154588 + "weighted_cost": 3.526686958395768 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005191340066181163, - "label_overlap": 0.00018378398089789345, - "label_connector_overlap": 0.5938372974925183, - "crossings": 2.00418410041841, - "crowding": 0.00435173000299102, - "sprawl": 5.2497399421485405, - "long_connectors": 0.0, - "edge_length_cv": 0.4980603105822829, + "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.8659217877094972, - "loop_compactness": 0.7746097802369686, - "flow_bends": 0.3076923076923077, + "misalignment": 0.6574585635359116, + "loop_compactness": 0.8051167274034787, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 4.765342623282401 + "weighted_cost": 3.355729494530927 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00573984941381459, - "label_overlap": 0.00008661890633228802, - "label_connector_overlap": 0.6098656456512631, - "crossings": 2.0418410041841004, - "crowding": 0.0023835831581735813, - "sprawl": 5.282052575119516, - "long_connectors": 0.0, - "edge_length_cv": 0.4822333842689033, + "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.8770949720670391, - "loop_compactness": 0.8203212112013726, - "flow_bends": 0.3076923076923077, + "misalignment": 0.65, + "loop_compactness": 0.8198108151497282, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 4.853310892439938 + "weighted_cost": 3.549377828383778 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0058313743979972014, - "label_overlap": 0.0, - "label_connector_overlap": 0.5961759974835781, - "crossings": 2.1673640167364017, - "crowding": 0.0013611956612301467, - "sprawl": 4.017832362210687, - "long_connectors": 0.0, - "edge_length_cv": 0.4743695844538718, + "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.7388888888888889, - "loop_compactness": 0.8009616859957324, - "flow_bends": 0.3076923076923077, + "misalignment": 0.6850828729281768, + "loop_compactness": 0.7828117108698086, + "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 4.619537457412692 + "weighted_cost": 3.6136422554418512 }, { "seed": 456, "metrics": { - "node_overlap": 0.0005631001371741201, - "node_connector_overlap": 0.014425924465999006, + "node_overlap": 0.0, + "node_connector_overlap": 0.0023727949134482013, "label_overlap": 0.0, - "label_connector_overlap": 0.6357150052687544, - "crossings": 2.0209205020920504, - "crowding": 0.014461796502613082, - "sprawl": 2.377009418098556, - "long_connectors": 0.0, - "edge_length_cv": 0.4974357385848453, + "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, - "misalignment": 0.7111111111111111, - "loop_compactness": 0.8306717254801246, + "misalignment": 0.8895027624309393, + "loop_compactness": 0.7933805614788217, "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 4.140486584814625 + "weighted_cost": 4.501227825017071 }, { "seed": 789, "metrics": { - "node_overlap": 0.0005631001371742138, - "node_connector_overlap": 0.012487640981371435, - "label_overlap": 0.0, - "label_connector_overlap": 0.6493130793156838, - "crossings": 1.8451882845188285, - "crowding": 0.0025797670241376304, - "sprawl": 2.344672247197794, - "long_connectors": 0.00034875069487206147, - "edge_length_cv": 0.5349005418792311, + "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.7277777777777779, - "loop_compactness": 0.7045927584663121, + "misalignment": 0.7111111111111111, + "loop_compactness": 0.8423732841141143, "flow_bends": 0.15384615384615385, "loop_straightness": 1.0 }, - "weighted_cost": 3.912718044347455 + "weighted_cost": 3.4218240307599648 } ], - "median_cost": 5.027134531665975, + "median_cost": 3.474255494577866, "spread": [ - 4.728891331814975, - 5.143298777565363 + 3.4034147216154844, + 3.5654439351482963 ], - "best_of_k_cost": 3.912718044347455, - "best_seed": 789, - "median_seed": 1, - "worst_seed": 3 + "best_of_k_cost": 3.4218240307599648, + "best_seed": 7, + "median_seed": 789, + "worst_seed": 456 }, { "model": "thyroid", @@ -6357,252 +6357,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03966330727955609, - "label_overlap": 0.006168609048954947, - "label_connector_overlap": 0.25033335226633535, - "crossings": 1.5, - "crowding": 0.0002643549074675683, - "sprawl": 12.81290676855133, - "long_connectors": 0.010330936330363928, - "edge_length_cv": 0.6699175945576715, + "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.7080745341614907, - "loop_compactness": 0.8227524948900049, - "flow_bends": 0.5161290322580645, + "misalignment": 0.5414012738853503, + "loop_compactness": 0.7713710246875346, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 5.74661162236709 + "weighted_cost": 3.495568409496194 }, { "seed": 1, "metrics": { - "node_overlap": 0.0006295529483935832, - "node_connector_overlap": 0.048940624196058685, - "label_overlap": 0.00708723361211398, - "label_connector_overlap": 0.3234851979376413, - "crossings": 1.447674418604651, - "crowding": 0.03074371521914429, - "sprawl": 5.704974325973123, - "long_connectors": 0.011629144335154431, - "edge_length_cv": 0.6815045435166093, - "aspect_penalty": 0.0, - "misalignment": 0.5279503105590062, - "loop_compactness": 0.8313354380503716, + "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": 4.04819833446588 + "weighted_cost": 3.2402760843922582 }, { "seed": 2, "metrics": { - "node_overlap": 0.0006334876543210432, - "node_connector_overlap": 0.04877135327492422, - "label_overlap": 0.007255171059925045, - "label_connector_overlap": 0.3417587890347798, - "crossings": 1.4593023255813953, - "crowding": 0.030248219810880928, - "sprawl": 5.68528476639671, - "long_connectors": 0.012032809935387247, - "edge_length_cv": 0.6657082063690742, - "aspect_penalty": 0.0, - "misalignment": 0.525, - "loop_compactness": 0.8232955709161561, + "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": 4.0787726083723115 + "weighted_cost": 2.9530797148426684 }, { "seed": 3, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.0393262687154309, - "label_overlap": 0.006118320953305693, - "label_connector_overlap": 0.2561399529161206, - "crossings": 1.494186046511628, - "crowding": 0.00022860145319784307, - "sprawl": 12.798289700935555, - "long_connectors": 0.014280668329756148, - "edge_length_cv": 0.6683593958934213, - "aspect_penalty": 0.0, - "misalignment": 0.6851851851851851, - "loop_compactness": 0.8259536335374792, - "flow_bends": 0.5161290322580645, + "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": 5.7459338505932145 + "weighted_cost": 3.181532406135104 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03824767701555717, - "label_overlap": 0.0062187482002812695, - "label_connector_overlap": 0.23193936835783716, - "crossings": 1.4069767441860466, - "crowding": 0.0002986443670278954, - "sprawl": 12.93469618807689, - "long_connectors": 0.014018480568867928, - "edge_length_cv": 0.6705300025261002, + "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, - "misalignment": 0.7, - "loop_compactness": 0.8108644119980205, - "flow_bends": 0.5161290322580645, + "misalignment": 0.620253164556962, + "loop_compactness": 0.7814603481562367, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 5.650104347079293 + "weighted_cost": 4.5037419660088895 }, { "seed": 5, "metrics": { - "node_overlap": 0.0006415064853883981, - "node_connector_overlap": 0.050600614125710694, - "label_overlap": 0.007239102903802137, - "label_connector_overlap": 0.3509901713983321, - "crossings": 1.4883720930232558, - "crowding": 0.028596516166396406, - "sprawl": 5.7507966662530965, - "long_connectors": 0.012449954790844278, - "edge_length_cv": 0.6755888394967652, - "aspect_penalty": 0.0, - "misalignment": 0.5506329113924051, - "loop_compactness": 0.8174784097806754, + "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": 4.140491068855767 + "weighted_cost": 3.242676268658867 }, { "seed": 6, "metrics": { - "node_overlap": 0.006157610828627198, - "node_connector_overlap": 0.05662552629414887, - "label_overlap": 0.012853028209920134, - "label_connector_overlap": 0.3495036076836421, - "crossings": 1.3430232558139534, - "crowding": 0.06218588829989763, - "sprawl": 4.306274410505637, - "long_connectors": 0.014024329043745589, - "edge_length_cv": 0.6747858565219813, - "aspect_penalty": 0.0, - "misalignment": 0.49689440993788825, - "loop_compactness": 0.8238908958141743, + "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": 3.7143544537750897 + "weighted_cost": 3.2561997152334357 }, { "seed": 7, "metrics": { - "node_overlap": 0.0006334876543210432, - "node_connector_overlap": 0.048484313957383056, - "label_overlap": 0.007255171059925045, - "label_connector_overlap": 0.3297168600559956, - "crossings": 1.4709302325581395, - "crowding": 0.02982206145263986, - "sprawl": 5.682827319548289, - "long_connectors": 0.01203000717648589, - "edge_length_cv": 0.6675089643621525, - "aspect_penalty": 0.0, - "misalignment": 0.525, - "loop_compactness": 0.8232955709161561, + "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": 4.070721621795999 + "weighted_cost": 3.1664051621551863 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03961161927854259, - "label_overlap": 0.006861919294376245, - "label_connector_overlap": 0.2648261463137394, - "crossings": 1.4709302325581395, - "crowding": 0.0002367492887973657, - "sprawl": 12.715633141290564, - "long_connectors": 0.014446639982495279, - "edge_length_cv": 0.6798828921334571, + "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, - "misalignment": 0.68944099378882, - "loop_compactness": 0.8246338474151633, - "flow_bends": 0.5161290322580645, + "misalignment": 0.7278481012658228, + "loop_compactness": 0.7910108756504212, + "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 5.718205282218284 + "weighted_cost": 5.106043962557037 }, { "seed": 123, "metrics": { - "node_overlap": 0.0006334876543210432, - "node_connector_overlap": 0.05012140549026802, - "label_overlap": 0.007255171059925045, - "label_connector_overlap": 0.32388035587798764, - "crossings": 1.505813953488372, - "crowding": 0.029115845186034862, - "sprawl": 5.776202821769986, - "long_connectors": 0.011170460799049283, - "edge_length_cv": 0.6580093057869585, - "aspect_penalty": 0.0, - "misalignment": 0.53125, - "loop_compactness": 0.8203301293525155, + "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": 4.1217714789996345 + "weighted_cost": 3.1873025656663416 }, { "seed": 456, "metrics": { - "node_overlap": 0.0, - "node_connector_overlap": 0.03919860888150385, - "label_overlap": 0.0060825555901274225, - "label_connector_overlap": 0.20091649619908045, - "crossings": 1.3895348837209303, - "crowding": 0.00025653499688617197, - "sprawl": 12.772216366949294, - "long_connectors": 0.011955824409981487, - "edge_length_cv": 0.6853001942790974, - "aspect_penalty": 0.0, - "misalignment": 0.705521472392638, - "loop_compactness": 0.8207422223665977, - "flow_bends": 0.5161290322580645, + "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": 5.550363246627606 + "weighted_cost": 3.136341883525829 }, { "seed": 789, "metrics": { - "node_overlap": 0.0006334876543210432, - "node_connector_overlap": 0.04815188342443994, - "label_overlap": 0.007255171059925045, - "label_connector_overlap": 0.3600774182889406, - "crossings": 1.4709302325581395, - "crowding": 0.03159233157716464, - "sprawl": 5.665526359591076, - "long_connectors": 0.012211821181441943, - "edge_length_cv": 0.6791960018036255, - "aspect_penalty": 0.0, - "misalignment": 0.48124999999999996, - "loop_compactness": 0.8220980755184399, + "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, + "misalignment": 0.713375796178344, + "loop_compactness": 0.7734185599631833, "flow_bends": 0.3870967741935484, "loop_straightness": 0.8421052631578947 }, - "weighted_cost": 4.108279537058144 + "weighted_cost": 5.301533870172316 } ], - "median_cost": 4.131131273927701, + "median_cost": 3.2414761765255626, "spread": [ - 4.076759861728234, - 5.6671295808640405 + 3.1777505951401244, + 3.7476117986243676 ], - "best_of_k_cost": 4.108279537058144, - "best_seed": 6, - "median_seed": 5, - "worst_seed": 0 + "best_of_k_cost": 3.136341883525829, + "best_seed": 2, + "median_seed": 1, + "worst_seed": 789 }, { "model": "covid19", @@ -6611,252 +6611,252 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00615917490643215, + "node_connector_overlap": 0.0040316802525391954, "label_overlap": 0.0, - "label_connector_overlap": 0.6289152730416947, - "crossings": 1.0265957446808511, - "crowding": 0.0018166113015519614, - "sprawl": 2.9102186839705437, - "long_connectors": 0.000053799121073144075, - "edge_length_cv": 0.44921663909022064, + "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, - "misalignment": 0.7913907284768211, - "loop_compactness": 0.4907402593276686, + "misalignment": 0.8202614379084967, + "loop_compactness": 0.7583134932798062, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.084767421313261 + "weighted_cost": 3.303882082262709 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005597656125710306, - "label_overlap": 0.0, - "label_connector_overlap": 0.6580590619129433, - "crossings": 1.0398936170212767, - "crowding": 0.0019625715125136905, - "sprawl": 2.911464483169768, - "long_connectors": 0.00007503194099108844, - "edge_length_cv": 0.4671574140543176, + "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.7350993377483444, - "loop_compactness": 0.4818829991629495, + "misalignment": 0.755700325732899, + "loop_compactness": 0.709689589768022, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.1319539226811064 + "weighted_cost": 3.065663283598133 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006709313898815959, + "node_connector_overlap": 0.0070832425902664815, "label_overlap": 0.0, - "label_connector_overlap": 0.6539110851999062, - "crossings": 1.0186170212765957, - "crowding": 0.0024940404907049483, - "sprawl": 2.9029952473258573, - "long_connectors": 0.00008934390574667157, - "edge_length_cv": 0.45542895463532385, + "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, - "misalignment": 0.7350993377483444, - "loop_compactness": 0.4947394747738395, + "misalignment": 0.7875816993464052, + "loop_compactness": 0.7567324937102916, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.110242583657029 + "weighted_cost": 3.171029882416721 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00971615141603583, + "node_connector_overlap": 0.006380611447971917, "label_overlap": 0.0, - "label_connector_overlap": 0.6446003509338402, - "crossings": 0.976063829787234, - "crowding": 0.003533310969859126, - "sprawl": 2.1997592250686693, - "long_connectors": 0.0006982835206528556, - "edge_length_cv": 0.4974729463652315, + "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, - "misalignment": 0.6546052631578947, - "loop_compactness": 0.4889196619086824, + "misalignment": 0.7483660130718954, + "loop_compactness": 0.7124573505598858, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 2.8748943679202106 + "weighted_cost": 3.1088666127420232 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.007873160331053508, + "node_connector_overlap": 0.0040316802525391954, "label_overlap": 0.0, - "label_connector_overlap": 0.6523285037046619, - "crossings": 0.9308510638297872, - "crowding": 0.005603538754880324, - "sprawl": 2.141783106824253, - "long_connectors": 0.00011631198139608342, - "edge_length_cv": 0.487121510633476, + "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, - "misalignment": 0.6666666666666667, - "loop_compactness": 0.5120546200276667, + "misalignment": 0.8202614379084967, + "loop_compactness": 0.7583134932798062, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 2.835333185001792 + "weighted_cost": 3.303882082262709 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006709313898815959, + "node_connector_overlap": 0.0061840021330689405, "label_overlap": 0.0, - "label_connector_overlap": 0.6539110851999062, - "crossings": 1.0186170212765957, - "crowding": 0.0024940404907049483, - "sprawl": 2.9029952473258573, - "long_connectors": 0.00008934390574667157, - "edge_length_cv": 0.45542895463532385, + "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, - "misalignment": 0.7350993377483444, - "loop_compactness": 0.4947394747738395, + "misalignment": 0.801948051948052, + "loop_compactness": 0.7341690060554753, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.110242583657029 + "weighted_cost": 3.1672708500350346 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0075481421858177985, + "node_connector_overlap": 0.012860844607951102, "label_overlap": 0.0, - "label_connector_overlap": 0.6625775812161386, - "crossings": 0.9707446808510638, - "crowding": 0.002938227910864237, - "sprawl": 2.162979758873929, - "long_connectors": 0.00007756445961175807, - "edge_length_cv": 0.4740887253447899, + "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, - "misalignment": 0.735973597359736, - "loop_compactness": 0.48474729286323537, + "misalignment": 0.6568627450980392, + "loop_compactness": 0.7633480807697297, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 2.888572622610857 + "weighted_cost": 3.0484563978823753 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005195531969425724, + "node_connector_overlap": 0.007416353996088374, "label_overlap": 0.0, - "label_connector_overlap": 0.6407332663759905, - "crossings": 1.0053191489361701, - "crowding": 0.0005471579788172043, - "sprawl": 2.9284224720549807, - "long_connectors": 0.00019486613934488334, - "edge_length_cv": 0.45624388676580585, + "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, - "misalignment": 0.7623762376237624, - "loop_compactness": 0.4687516643629327, + "misalignment": 0.8137254901960784, + "loop_compactness": 0.7264437214971283, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.070945669832321 + "weighted_cost": 3.157135635943824 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005470471473618134, + "node_connector_overlap": 0.0040316802525391954, "label_overlap": 0.0, - "label_connector_overlap": 0.6259108991198233, - "crossings": 1.050531914893617, - "crowding": 0.0014532093357187952, - "sprawl": 2.8999670344196824, - "long_connectors": 0.00017215059444317726, - "edge_length_cv": 0.48900631526792304, + "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, - "misalignment": 0.7748344370860927, - "loop_compactness": 0.45428007734512843, + "misalignment": 0.8202614379084967, + "loop_compactness": 0.7583134932798062, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.0837127832286395 + "weighted_cost": 3.303882082262709 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.006709313898815959, + "node_connector_overlap": 0.006359096406258597, "label_overlap": 0.0, - "label_connector_overlap": 0.6539110851999062, - "crossings": 1.0186170212765957, - "crowding": 0.0024940404907049483, - "sprawl": 2.9029952473258573, - "long_connectors": 0.00008934390574667157, - "edge_length_cv": 0.45542895463532385, + "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, - "misalignment": 0.7350993377483444, - "loop_compactness": 0.4947394747738395, + "misalignment": 0.762214983713355, + "loop_compactness": 0.7775885400884955, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.110242583657029 + "weighted_cost": 3.09838461571977 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.00693048632096331, + "node_connector_overlap": 0.0044068880015103945, "label_overlap": 0.0, - "label_connector_overlap": 0.6496883182765716, - "crossings": 0.9893617021276596, - "crowding": 0.0023414575388026534, - "sprawl": 2.897626260734402, - "long_connectors": 0.0, - "edge_length_cv": 0.453974106587494, + "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, - "misalignment": 0.7483443708609272, - "loop_compactness": 0.4739657329847972, + "misalignment": 0.803921568627451, + "loop_compactness": 0.6810463180161683, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.0665709640103875 + "weighted_cost": 3.17766375834597 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.0036856484628799194, + "node_connector_overlap": 0.008931404876023322, "label_overlap": 0.0, - "label_connector_overlap": 0.6098338282666653, - "crossings": 1.0319148936170213, - "crowding": 0.0014899834210312473, - "sprawl": 3.7698565321425206, - "long_connectors": 0.00016859479082210503, - "edge_length_cv": 0.47987674254222495, + "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, - "misalignment": 0.794701986754967, - "loop_compactness": 0.5047261576024729, + "misalignment": 0.760655737704918, + "loop_compactness": 0.7501365477838865, "flow_bends": 0.11764705882352941, - "loop_straightness": 0.8 + "loop_straightness": 0.9 }, - "weighted_cost": 3.277083067334867 + "weighted_cost": 3.1213236534156676 } ], - "median_cost": 3.0842401022709502, + "median_cost": 3.1622032429894293, "spread": [ - 3.0220713786605047, - 3.110242583657029 + 3.10624611348646, + 3.2092183393251545 ], - "best_of_k_cost": 3.0665709640103875, - "best_seed": 4, - "median_seed": 0, - "worst_seed": 789 + "best_of_k_cost": 3.09838461571977, + "best_seed": 6, + "median_seed": 5, + "worst_seed": 0 }, { "model": "industrial_dynamics", @@ -6865,253 +6865,253 @@ "seed": 0, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.030513475695683034, + "node_connector_overlap": 0.008515647591446468, "label_overlap": 0.0, - "label_connector_overlap": 0.3698277099939075, - "crossings": 0.8770949720670391, - "crowding": 0.0028266241853821387, - "sprawl": 4.615469194271222, - "long_connectors": 0.010658559137354421, - "edge_length_cv": 0.6454274129419643, - "aspect_penalty": 0.0, - "misalignment": 0.4105960264900662, - "loop_compactness": 0.4976421698916555, + "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": 2.961669828043467 + "weighted_cost": 3.3013137794365375 }, { "seed": 1, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.014921270519999912, + "node_connector_overlap": 0.030694046846615117, "label_overlap": 0.0, - "label_connector_overlap": 0.30154881184249893, - "crossings": 0.9162011173184358, - "crowding": 0.0008741120120509924, - "sprawl": 6.11118743644531, - "long_connectors": 0.0057307361728089565, - "edge_length_cv": 0.5979554106971011, + "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, - "misalignment": 0.5165562913907285, - "loop_compactness": 0.49499103127366184, + "misalignment": 0.45070422535211263, + "loop_compactness": 0.7223916147741609, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 3.246221923647171 + "weighted_cost": 2.0963520074669413 }, { "seed": 2, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03125929190716765, + "node_connector_overlap": 0.033055604965445566, "label_overlap": 0.0, - "label_connector_overlap": 0.35509650943173393, - "crossings": 0.6312849162011173, - "crowding": 0.0023875038013415994, - "sprawl": 3.4247320332408275, - "long_connectors": 0.006310779311449283, - "edge_length_cv": 0.6053796222121666, - "aspect_penalty": 0.0, - "misalignment": 0.3841059602649006, - "loop_compactness": 0.5154635281687995, + "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": 2.399436839891003 + "weighted_cost": 2.1349815218061967 }, { "seed": 3, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.031759447397215576, + "node_connector_overlap": 0.00421426814257865, "label_overlap": 0.0, - "label_connector_overlap": 0.3881038674925595, - "crossings": 0.8603351955307262, - "crowding": 0.003694192232437518, - "sprawl": 3.3955146917480885, - "long_connectors": 0.009591945421482723, - "edge_length_cv": 0.6417279417964075, - "aspect_penalty": 0.0, - "misalignment": 0.35761589403973515, - "loop_compactness": 0.5118828556068377, + "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": 2.6705601277575735 + "weighted_cost": 4.64823939439368 }, { "seed": 4, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.005639225627946134, + "node_connector_overlap": 0.004389029571802292, "label_overlap": 0.0, - "label_connector_overlap": 0.2721808837861293, - "crossings": 1.0614525139664805, + "label_connector_overlap": 0.02577759418222489, + "crossings": 1.019704433497537, "crowding": 0.0, - "sprawl": 11.508983938725803, - "long_connectors": 0.011164629282722051, - "edge_length_cv": 0.6451737834650603, - "aspect_penalty": 0.0, - "misalignment": 0.576158940397351, - "loop_compactness": 0.5045938955838185, + "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": 4.689950709164307 + "weighted_cost": 4.9837759915815685 }, { "seed": 5, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.027548913749498367, + "node_connector_overlap": 0.004345823618383122, "label_overlap": 0.0, - "label_connector_overlap": 0.411499309096306, - "crossings": 0.7821229050279329, - "crowding": 0.003714019560968497, - "sprawl": 3.3677610800267526, - "long_connectors": 0.007908236530979652, - "edge_length_cv": 0.649429689509678, - "aspect_penalty": 0.0, - "misalignment": 0.29801324503311255, - "loop_compactness": 0.514216933638872, + "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": 2.606232868630062 + "weighted_cost": 5.0512663302651575 }, { "seed": 6, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.03827888021557868, + "node_connector_overlap": 0.047709297828687616, "label_overlap": 0.0, - "label_connector_overlap": 0.3775337159252767, - "crossings": 0.7262569832402235, - "crowding": 0.003961838068060162, - "sprawl": 3.414051028074197, - "long_connectors": 0.007953514351871274, - "edge_length_cv": 0.6536977116545847, + "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, - "misalignment": 0.3708609271523179, - "loop_compactness": 0.523070014607171, + "misalignment": 0.28873239436619713, + "loop_compactness": 0.7414965963961185, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 2.543547435046608 + "weighted_cost": 1.8906898498206914 }, { "seed": 7, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.018468716546348428, + "node_connector_overlap": 0.008170842572495412, "label_overlap": 0.0, - "label_connector_overlap": 0.38691768563178996, - "crossings": 0.8379888268156425, - "crowding": 0.001207700686972501, - "sprawl": 4.691067141302526, - "long_connectors": 0.006233283756800905, - "edge_length_cv": 0.6365547409398998, + "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, - "misalignment": 0.4105960264900662, - "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": 2.961588194076959 + "weighted_cost": 3.348225850300245 }, { "seed": 42, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02055503040569357, + "node_connector_overlap": 0.00960545897404366, "label_overlap": 0.0, - "label_connector_overlap": 0.33344805011767015, - "crossings": 0.8770949720670391, - "crowding": 0.0019356968854189634, - "sprawl": 4.55940948007257, - "long_connectors": 0.005520947861305558, - "edge_length_cv": 0.5981900880489274, + "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, - "misalignment": 0.42384105960264906, - "loop_compactness": 0.5232375534703377, + "misalignment": 0.5442176870748299, + "loop_compactness": 0.733558178521075, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 2.8812714429042123 + "weighted_cost": 3.4095364399681616 }, { "seed": 123, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.05081668349230503, + "node_connector_overlap": 0.0041305959187599075, "label_overlap": 0.0, - "label_connector_overlap": 0.4349520209023505, - "crossings": 0.7821229050279329, - "crowding": 0.012854533185762098, - "sprawl": 2.528638257365052, - "long_connectors": 0.011652730397795182, - "edge_length_cv": 0.6598183022979814, - "aspect_penalty": 0.0, - "misalignment": 0.2847682119205298, - "loop_compactness": 0.5277887129238317, + "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": 2.493283739120244 + "weighted_cost": 5.011799876739282 }, { "seed": 456, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.04691298125221594, + "node_connector_overlap": 0.0046051759560047706, "label_overlap": 0.0, - "label_connector_overlap": 0.3494696859885434, - "crossings": 0.8491620111731844, - "crowding": 0.019302890186407237, - "sprawl": 2.5425887316185447, - "long_connectors": 0.01259411572128257, - "edge_length_cv": 0.6709867725555383, - "aspect_penalty": 0.0, - "misalignment": 0.3112582781456954, - "loop_compactness": 0.5309608387153537, + "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": 2.438616463579494 + "weighted_cost": 5.056952335483576 }, { "seed": 789, "metrics": { "node_overlap": 0.0, - "node_connector_overlap": 0.02798788425768864, + "node_connector_overlap": 0.00968444216280995, "label_overlap": 0.0, - "label_connector_overlap": 0.36242030716896995, - "crossings": 0.8659217877094972, - "crowding": 0.0032944844686630336, - "sprawl": 3.385790349353551, - "long_connectors": 0.007510887314357774, - "edge_length_cv": 0.6262130148686557, + "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, - "misalignment": 0.3708609271523179, - "loop_compactness": 0.511007549966521, + "misalignment": 0.5555555555555556, + "loop_compactness": 0.7562928511833747, "flow_bends": 0.0, - "loop_straightness": 0.6666666666666666 + "loop_straightness": 1.0 }, - "weighted_cost": 2.6271813118110665 + "weighted_cost": 3.116963666973144 } ], - "median_cost": 2.6488707197843198, + "median_cost": 3.378881145134203, "spread": [ - 2.5309815110650167, - 2.9616086025685857 + 2.871468130681407, + 4.990781962870997 ], - "best_of_k_cost": 2.438616463579494, - "best_seed": 2, - "median_seed": 789, - "worst_seed": 4 + "best_of_k_cost": 3.116963666973144, + "best_seed": 6, + "median_seed": 7, + "worst_seed": 456 } ], - "aggregate_cost": 1.536536579786258 + "aggregate_cost": 1.2076721409035505 } \ No newline at end of file From c15571bdd0bcb00b19a3725a15483dcde4258be6 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 02:08:20 -0700 Subject: [PATCH 15/16] engine: polish free nodes off link crossings Hand-drawn corpus references almost never cross links (12 of 17 have none), while production layouts crossed on up to two fifths of their connectors -- typically a parameter drawn on the far side of a chain or cluster from its consumer. Instrumenting the fresh pipeline showed the crossings are all set by the force pass: its annealing counts crossings over straight chords between point nodes, and the declutter afterwards neither adds nor removes them. layout::polish visits each free node (auxiliary, module, ghost) sitting on a crossing, right after the connectors are built, and tries it at a ring of spots around its neighbors' centroid, keeping a spot only where its own connectors cross strictly less and its shape lands clear of every other shape and pipe. A node's move changes only its own connectors' crossings, so the diagram's count never rises. On the layout eval corpus the sweep medians fall 7% in aggregate on the small and medium tiers (groupon -26%, delays -20%, bathtub -17%, all significant, none significantly worse) and 16% on the large tier, for about 0.1s more on wrld3_03. build_view_segments now builds one connector's segments at a time so the polish can recharge just those. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/mod.rs | 49 +++- src/simlin-engine/src/layout/polish.rs | 243 +++++++++++++++++++ src/simlin-engine/src/layout/polish_tests.rs | 136 +++++++++++ 3 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 src/simlin-engine/src/layout/polish.rs create mode 100644 src/simlin-engine/src/layout/polish_tests.rs diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index b57f745ad..4d50caaa5 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -19,6 +19,7 @@ pub mod metrics; mod objective; mod orthogonal; pub mod placement; +mod polish; pub mod sfdp; #[cfg(any(test, feature = "layout_eval"))] pub mod taste; @@ -2864,6 +2865,11 @@ pub fn fresh_layout( let rerouted = generate_ghosts(&mut state, config, metadata); build_connectors(&mut state, model, metadata, &rerouted)?; + // Phase 3b: Move free nodes off crossings where a nearby spot uncrosses + // their connectors, on the drawn connector geometry. + polish::polish_crossings(&mut state.elements); + sync_free_node_positions(&mut state); + // Phase 4: Apply optimal label placement optimize_labels(&mut state, model, metadata); @@ -2920,6 +2926,22 @@ pub fn fresh_layout( }) } +/// Copy free nodes' element coordinates back into `state.positions` after a +/// pass that moved the elements directly. +fn sync_free_node_positions(state: &mut LayoutState) { + for elem in &state.elements { + let (uid, x, y) = match elem { + ViewElement::Aux(a) => (a.uid, a.x, a.y), + ViewElement::Module(m) => (m.uid, m.x, m.y), + ViewElement::Alias(a) => (a.uid, a.x, a.y), + _ => continue, + }; + if let Some(pos) = state.positions.get_mut(&uid) { + *pos = Position::new(x, y); + } + } +} + /// Check if a dependency edge is a structural flow->stock connection (already /// visually represented by the flow pipe). fn is_structural_flow_stock( @@ -3646,25 +3668,36 @@ fn build_view_segments(view: &datamodel::StockFlow) -> Vec { // Resolve every element by uid so a link can find its endpoints regardless // of the endpoint's kind (Module/Alias included). - let mut uid_elements: HashMap = HashMap::new(); - for elem in &view.elements { - uid_elements.insert(elem.get_uid(), elem); - } + let uid_elements: HashMap = + view.elements.iter().map(|e| (e.get_uid(), e)).collect(); + view.elements + .iter() + .flat_map(|elem| element_segments(elem, &uid_elements)) + .collect() +} +/// The crossing segments one connector draws -- a link's polyline or a flow's +/// pipe -- with vertices named so connectors sharing an element never count as +/// crossing there; empty for every other element. `uid_elements` resolves a +/// link's endpoints. +pub(crate) fn element_segments( + elem: &ViewElement, + uid_elements: &HashMap, +) -> Vec { // Crossing detection is center-based and deterministic; no element is // treated as arrayed (matching the historic behavior). let not_arrayed = |_: &str| false; let mut segments: Vec = Vec::new(); - for elem in &view.elements { + { match elem { ViewElement::Link(link) => { let (Some(&from), Some(&to)) = ( uid_elements.get(&link.from_uid), uid_elements.get(&link.to_uid), ) else { - continue; // an endpoint is genuinely missing + return segments; // an endpoint is genuinely missing }; let polyline = crate::diagram::connector::connector_polyline( @@ -3675,7 +3708,7 @@ fn build_view_segments(view: &datamodel::StockFlow) -> Vec { crate::diagram::connector::ARC_POLYLINE_SAMPLES, ); if polyline.len() < 2 { - continue; // MultiPoint / degenerate: nothing drawn + return segments; // MultiPoint / degenerate: nothing drawn } let last_idx = polyline.len() - 1; @@ -3706,7 +3739,7 @@ fn build_view_segments(view: &datamodel::StockFlow) -> Vec { } ViewElement::Flow(flow) => { if flow.points.len() < 2 { - continue; + return segments; } // Build the pipe as a sequence of named vertices. A point diff --git a/src/simlin-engine/src/layout/polish.rs b/src/simlin-engine/src/layout/polish.rs new file mode 100644 index 000000000..1d1e2e8b6 --- /dev/null +++ b/src/simlin-engine/src/layout/polish.rs @@ -0,0 +1,243 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// Crossing polish on drawn geometry. The force pass reduces crossings with +// annealing over straight chords between point nodes, and what it leaves is +// what the final diagram shows: the declutter pass moves free nodes only as +// far as overlaps demand, so it neither adds nor removes crossings. Hand-drawn +// diagrams in the corpus almost never cross links, while generated ones cross +// on a tenth to two fifths of their connectors, typically a parameter drawn on +// the far side of a chain or cluster from its consumer. +// +// This pass visits each free node (auxiliary, module, ghost) that sits on a +// crossing and tries it at a ring of spots around its neighbors, keeping a +// spot only where its own connectors cross strictly less and its shape lands +// clear of every other shape and pipe. A node's move changes only the +// crossings of the connectors it owns, so each candidate is charged by those +// alone; the diagram's crossing count therefore never rises. + +use super::*; +use crate::diagram::common::{Rect as Bounds, rect_overlap_area}; +use crate::layout::metrics::{COMFORTABLE_CLEARANCE, node_shape_box, pipe_rects}; + +/// Passes over the free nodes; each pass that moves nothing ends the polish. +const POLISH_ROUNDS: usize = 3; + +/// Spots tried on the ring around a node's neighbors. +const RING_SPOTS: usize = 16; + +/// The ring's radius is the node's current distance to its neighbors' +/// centroid, held within these bounds so a node drawn on top of its neighbors +/// still gets room and a far-flung one is brought in. +const MIN_RING_RADIUS: f64 = 60.0; +const MAX_RING_RADIUS: f64 = 180.0; + +/// Move free nodes off crossings where a nearby spot uncrosses their +/// connectors (see the module docs). Deterministic: nodes are visited in uid +/// order and ties keep the earliest spot, starting from where the node is. +pub(crate) fn polish_crossings(elements: &mut [ViewElement]) { + let free: Vec = { + let mut free: Vec<(i32, usize)> = elements + .iter() + .enumerate() + .filter(|(_, e)| { + matches!( + e, + ViewElement::Aux(_) | ViewElement::Module(_) | ViewElement::Alias(_) + ) + }) + .map(|(i, e)| (e.get_uid(), i)) + .collect(); + free.sort_unstable(); + free.into_iter().map(|(_, i)| i).collect() + }; + let links: Vec<(usize, i32, i32)> = elements + .iter() + .enumerate() + .filter_map(|(i, e)| match e { + ViewElement::Link(l) => Some((i, l.from_uid, l.to_uid)), + _ => None, + }) + .collect(); + + for _ in 0..POLISH_ROUNDS { + let mut moved = false; + for &node in &free { + moved |= polish_node(elements, node, &links); + } + if !moved { + break; + } + } +} + +/// Try `elements[node]` at the spots around its neighbors; returns whether it +/// moved. +fn polish_node(elements: &mut [ViewElement], node: usize, links: &[(usize, i32, i32)]) -> bool { + let uid = elements[node].get_uid(); + let incident: Vec = links + .iter() + .filter(|(_, from, to)| *from == uid || *to == uid) + .map(|(i, _, _)| *i) + .collect(); + if incident.is_empty() { + return false; + } + + let (others, obstacles) = { + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); + let others: Vec = elements + .iter() + .enumerate() + .filter(|(i, _)| !incident.contains(i)) + .flat_map(|(_, e)| element_segments(e, &uid_elements)) + .collect(); + let obstacles: Vec = elements + .iter() + .enumerate() + .filter(|(i, _)| *i != node) + .flat_map(|(_, e)| { + let mut rects: Vec = node_shape_box(e).into_iter().collect(); + if let ViewElement::Flow(f) = e { + rects.extend(pipe_rects(f)); + } + rects + }) + .collect(); + (others, obstacles) + }; + + let neighbors: Vec = { + let positions: HashMap = elements + .iter() + .filter_map(|e| element_center(e).map(|p| (e.get_uid(), p))) + .collect(); + incident + .iter() + .filter_map(|&i| match &elements[i] { + ViewElement::Link(l) => { + let other = if l.from_uid == uid { + l.to_uid + } else { + l.from_uid + }; + positions.get(&other).copied() + } + _ => None, + }) + .collect() + }; + let Some(start) = element_center(&elements[node]) else { + return false; + }; + if neighbors.is_empty() { + return false; + } + + // What the node's own connectors cross, and how long they are, with the + // node at `p`. + let charge = |elements: &mut [ViewElement], p: Position| -> (usize, f64) { + set_center(&mut elements[node], p); + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); + let mut crossings = 0; + let mut length = 0.0; + for &i in &incident { + for seg in element_segments(&elements[i], &uid_elements) { + length += (seg.end - seg.start).length(); + crossings += others + .iter() + .filter(|other| annealing::do_segments_intersect(&seg, other)) + .count(); + } + } + (crossings, length) + }; + + let (now_crossings, now_length) = charge(elements, start); + if now_crossings == 0 { + return false; + } + + let n = neighbors.len() as f64; + let centroid = Position::new( + neighbors.iter().map(|p| p.x).sum::() / n, + neighbors.iter().map(|p| p.y).sum::() / n, + ); + let radius = (start - centroid) + .length() + .clamp(MIN_RING_RADIUS, MAX_RING_RADIUS); + let mut best = (now_crossings, now_length, start); + for k in 0..RING_SPOTS { + let angle = k as f64 * 2.0 * PI / RING_SPOTS as f64; + let spot = Position::new( + centroid.x + radius * angle.cos(), + centroid.y + radius * angle.sin(), + ); + set_center(&mut elements[node], spot); + let blocked = node_shape_box(&elements[node]).is_some_and(|shape| { + let clear = grown(&shape, COMFORTABLE_CLEARANCE); + obstacles + .iter() + .any(|obstacle| rect_overlap_area(&clear, obstacle) > 0.0) + }); + if blocked { + continue; + } + let (crossings, length) = charge(elements, spot); + if (crossings, length) < (best.0, best.1) { + best = (crossings, length, spot); + } + } + let moved = best.0 < now_crossings; + set_center(&mut elements[node], if moved { best.2 } else { start }); + moved +} + +fn element_center(e: &ViewElement) -> Option { + match e { + ViewElement::Aux(a) => Some(Position::new(a.x, a.y)), + ViewElement::Module(m) => Some(Position::new(m.x, m.y)), + ViewElement::Alias(a) => Some(Position::new(a.x, a.y)), + ViewElement::Stock(s) => Some(Position::new(s.x, s.y)), + ViewElement::Flow(f) => Some(Position::new(f.x, f.y)), + ViewElement::Cloud(c) => Some(Position::new(c.x, c.y)), + ViewElement::Link(_) | ViewElement::Group(_) => None, + } +} + +/// Place a free node's center at `p`. +fn set_center(e: &mut ViewElement, p: Position) { + match e { + ViewElement::Aux(a) => { + a.x = p.x; + a.y = p.y; + } + ViewElement::Module(m) => { + m.x = p.x; + m.y = p.y; + } + ViewElement::Alias(a) => { + a.x = p.x; + a.y = p.y; + } + _ => {} + } +} + +fn grown(r: &Bounds, d: f64) -> Bounds { + Bounds { + left: r.left - d, + top: r.top - d, + right: r.right + d, + bottom: r.bottom + d, + } +} + +#[cfg(test)] +#[path = "polish_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/layout/polish_tests.rs b/src/simlin-engine/src/layout/polish_tests.rs new file mode 100644 index 000000000..954443e93 --- /dev/null +++ b/src/simlin-engine/src/layout/polish_tests.rs @@ -0,0 +1,136 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +use super::*; +use crate::datamodel::view_element::{self, LabelSide, LinkShape}; + +fn aux(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { + ViewElement::Aux(view_element::Aux { + name: name.to_string(), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +fn stock(uid: i32, name: &str, x: f64, y: f64) -> ViewElement { + ViewElement::Stock(view_element::Stock { + name: name.to_string(), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) +} + +fn link(uid: i32, from_uid: i32, to_uid: i32) -> ViewElement { + ViewElement::Link(view_element::Link { + uid, + from_uid, + to_uid, + shape: LinkShape::Straight, + polarity: None, + }) +} + +fn view_of(elements: Vec) -> datamodel::StockFlow { + datamodel::StockFlow { + name: None, + elements, + view_box: datamodel::Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + } +} + +fn position(elements: &[ViewElement], uid: i32) -> (f64, f64) { + elements + .iter() + .find_map(|e| match e { + ViewElement::Aux(a) if a.uid == uid => Some((a.x, a.y)), + ViewElement::Stock(s) if s.uid == uid => Some((s.x, s.y)), + _ => None, + }) + .expect("node drawn") +} + +#[test] +fn a_parameter_on_the_wrong_side_steps_off_the_crossing() { + // Two stocks side by side, each read by a parameter drawn below the OTHER + // stock, so the two links cross in an X. Moving either parameter around + // its stock uncrosses them; the stocks never move. + let elements = vec![ + stock(1, "left stock", 0.0, 0.0), + stock(2, "right stock", 200.0, 0.0), + aux(3, "left parameter", 200.0, 120.0), + aux(4, "right parameter", 0.0, 120.0), + link(10, 3, 1), + link(11, 4, 2), + ]; + let before = count_view_crossings(&view_of(elements.clone())); + assert_eq!(before, 1, "fixture: the two links cross"); + + let mut polished = elements.clone(); + polish_crossings(&mut polished); + + assert_eq!(count_view_crossings(&view_of(polished.clone())), 0); + assert_eq!(position(&polished, 1), position(&elements, 1)); + assert_eq!(position(&polished, 2), position(&elements, 2)); +} + +#[test] +fn a_crossing_free_diagram_is_left_alone() { + let elements = vec![ + stock(1, "left stock", 0.0, 0.0), + stock(2, "right stock", 200.0, 0.0), + aux(3, "left parameter", 0.0, 120.0), + aux(4, "right parameter", 200.0, 120.0), + link(10, 3, 1), + link(11, 4, 2), + ]; + let mut polished = elements.clone(); + polish_crossings(&mut polished); + assert!(polished == elements, "nothing crosses, so nothing moves"); +} + +#[test] +fn a_parameter_never_steps_onto_another_shape() { + // A parameter reads down into its stock across a long link between two + // far auxes. Every spot on its ring above that link would uncross it, and + // every one of them is taken by a module; the polish must keep the + // crossing rather than drop the parameter onto a module. + let mut elements = vec![ + stock(1, "consumer", 0.0, 0.0), + aux(2, "crossed parameter", 0.0, 100.0), + aux(3, "a", -400.0, 50.0), + aux(4, "b", 400.0, 50.0), + link(10, 2, 1), + link(11, 3, 4), + ]; + for k in 0..16 { + let angle = k as f64 * 2.0 * PI / 16.0; + let (x, y) = (100.0 * angle.cos(), 100.0 * angle.sin()); + if y < 50.0 { + elements.push(ViewElement::Module(view_element::Module { + name: format!("module {k}"), + uid: 100 + k, + x, + y, + label_side: LabelSide::Bottom, + })); + } + } + let before = elements.clone(); + polish_crossings(&mut elements); + assert_eq!( + position(&elements, 2), + position(&before, 2), + "the parameter stays put" + ); +} From 96d14bd07778f84e72fda00290c89d6cf365e0d8 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 11 Sep 2026 02:28:17 -0700 Subject: [PATCH 16/16] engine: charge the crossing polish like the metric The polish charged a spot only by its connectors' crossings and checked only that the node's shape landed clear, so a node could step off a crossing onto a spot where its links struck other names, or its own name took a link, or its label jammed a neighbor. The fresh layout's declutter ran afterwards and repaired much of that; the incremental layout, which polishes what an edit adds, cannot re-side the labels already drawn, and there the moves traded crossings for strikes (fishbanks' replay +0.21 on strikes). A spot is now charged what the metric charges the node locally -- crossings and struck names in the metric's per-connector and per-label units -- and the node's shape and label must both land clear of every shape, label, and pipe. In the fresh layout the polish runs on the settled geometry after the declutter, then the label sides are chosen again around the moved nodes. The incremental layout polishes the free nodes an edit creates, before its own declutter. The scene the spots are charged against is cached per pass and refreshed only for what a move changes. Against the crossings-only polish on the layout eval corpus: fresh medians -0.4% (catastrophe -6.7%, mortgage_econ -5.8%, significant; none significantly worse), large tier -6.5% (covid19 -29%, wrld3_03 -15%), incremental geomean -1.3%. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QB8VqnbKj6FUNZKRm5tteu --- src/simlin-engine/src/layout/incremental.rs | 7 +- src/simlin-engine/src/layout/metrics.rs | 4 +- src/simlin-engine/src/layout/mod.rs | 11 +- src/simlin-engine/src/layout/polish.rs | 337 ++++++++++++++----- src/simlin-engine/src/layout/polish_tests.rs | 19 ++ 5 files changed, 286 insertions(+), 92 deletions(-) diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs index 3ddb4c4e8..8b6c370a6 100644 --- a/src/simlin-engine/src/layout/incremental.rs +++ b/src/simlin-engine/src/layout/incremental.rs @@ -2244,11 +2244,12 @@ pub fn incremental_layout( diff_connectors(&mut state, &metadata); diff_clouds(&mut state, &metadata); - // Step 8: Polish. Elements created in this pass get their label sides - // chosen by what the metric charges, and the new free-floating ones step - // off whatever they landed on. Pinned elements keep their positions and + // 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) }); diff --git a/src/simlin-engine/src/layout/metrics.rs b/src/simlin-engine/src/layout/metrics.rs index 57fcace0b..597404c8c 100644 --- a/src/simlin-engine/src/layout/metrics.rs +++ b/src/simlin-engine/src/layout/metrics.rs @@ -68,11 +68,11 @@ 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. -const LABEL_INSET: f64 = 2.0; +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`). -const OWN_LINK_STRIKE_FACTOR: f64 = 0.5; +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; diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index 4d50caaa5..a83bf78e7 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -2865,11 +2865,6 @@ pub fn fresh_layout( let rerouted = generate_ghosts(&mut state, config, metadata); build_connectors(&mut state, model, metadata, &rerouted)?; - // Phase 3b: Move free nodes off crossings where a nearby spot uncrosses - // their connectors, on the drawn connector geometry. - polish::polish_crossings(&mut state.elements); - sync_free_node_positions(&mut state); - // Phase 4: Apply optimal label placement optimize_labels(&mut state, model, metadata); @@ -2880,6 +2875,12 @@ pub fn fresh_layout( // deterministically. if config.declutter { declutter::declutter_view(&mut state.elements); + // Phase 4c: Move free nodes off crossings where a nearby spot charges + // less, on the settled geometry and label sides, then choose the sides + // again around wherever they went. + polish::polish_crossings(&mut state.elements); + declutter::declutter_part(&mut state.elements, |_| true, |_| false); + sync_free_node_positions(&mut state); } // Phase 5: Normalize coordinates diff --git a/src/simlin-engine/src/layout/polish.rs b/src/simlin-engine/src/layout/polish.rs index 1d1e2e8b6..a63af2f53 100644 --- a/src/simlin-engine/src/layout/polish.rs +++ b/src/simlin-engine/src/layout/polish.rs @@ -13,15 +13,22 @@ // the far side of a chain or cluster from its consumer. // // This pass visits each free node (auxiliary, module, ghost) that sits on a -// crossing and tries it at a ring of spots around its neighbors, keeping a -// spot only where its own connectors cross strictly less and its shape lands -// clear of every other shape and pipe. A node's move changes only the -// crossings of the connectors it owns, so each candidate is charged by those -// alone; the diagram's crossing count therefore never rises. +// crossing and tries it at a ring of spots around its neighbors. A spot is +// charged what the metric charges for the node locally -- its own connectors' +// crossings, and the names struck by its connectors or through its own name -- +// and must keep the node's shape and label clear of every other shape, label, +// and pipe. A node's move changes only those charges, so keeping a spot only +// where they fall never raises the metric's crossing and strike terms. use super::*; -use crate::diagram::common::{Rect as Bounds, rect_overlap_area}; -use crate::layout::metrics::{COMFORTABLE_CLEARANCE, node_shape_box, pipe_rects}; +use crate::diagram::common::{ + Point, Rect as Bounds, rect_overlap_area, segment_clip_interval_in_rect, +}; +use crate::diagram::label::label_bounds; +use crate::layout::metrics::{ + COMFORTABLE_CLEARANCE, LABEL_INSET, MetricWeights, OWN_LINK_STRIKE_FACTOR, + alias_label_props_for, alias_source_names, element_label_props_for, node_shape_box, pipe_rects, +}; /// Passes over the free nodes; each pass that moves nothing ends the polish. const POLISH_ROUNDS: usize = 3; @@ -39,6 +46,13 @@ const MAX_RING_RADIUS: f64 = 180.0; /// connectors (see the module docs). Deterministic: nodes are visited in uid /// order and ties keep the earliest spot, starting from where the node is. pub(crate) fn polish_crossings(elements: &mut [ViewElement]) { + polish_crossings_for(elements, |_| true); +} + +/// [`polish_crossings`] moving only the free nodes whose uid `moves` accepts; +/// everything else is fixed, as the incremental layout needs for what was +/// already drawn. +pub(crate) fn polish_crossings_for(elements: &mut [ViewElement], moves: impl Fn(i32) -> bool) { let free: Vec = { let mut free: Vec<(i32, usize)> = elements .iter() @@ -47,7 +61,7 @@ pub(crate) fn polish_crossings(elements: &mut [ViewElement]) { matches!( e, ViewElement::Aux(_) | ViewElement::Module(_) | ViewElement::Alias(_) - ) + ) && moves(e.get_uid()) }) .map(|(i, e)| (e.get_uid(), i)) .collect(); @@ -63,10 +77,11 @@ pub(crate) fn polish_crossings(elements: &mut [ViewElement]) { }) .collect(); + let mut scene = PolishScene::new(elements); for _ in 0..POLISH_ROUNDS { let mut moved = false; for &node in &free { - moved |= polish_node(elements, node, &links); + moved |= polish_node(elements, &mut scene, node, &links); } if !moved { break; @@ -74,94 +89,186 @@ pub(crate) fn polish_crossings(elements: &mut [ViewElement]) { } } +/// What the polish charges spots against, by element index: each connector's +/// segments, each element's shape and pipe boxes, and its label box. Only the +/// entries of a node that moved and of its connectors change, so they are +/// refreshed after each move rather than rebuilt per spot. +struct PolishScene { + segments: Vec>, + shapes: Vec>, + labels: Vec>, + alias_names: HashMap, + connector_count: usize, + label_count: usize, +} + +impl PolishScene { + fn new(elements: &[ViewElement]) -> Self { + let alias_names = alias_source_names(elements); + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); + let segments: Vec> = elements + .iter() + .map(|e| element_segments(e, &uid_elements)) + .collect(); + let shapes = elements.iter().map(shape_boxes).collect(); + let labels: Vec> = elements + .iter() + .map(|e| label_box(e, &alias_names)) + .collect(); + PolishScene { + connector_count: segments.iter().filter(|s| !s.is_empty()).count().max(1), + label_count: labels.iter().flatten().count().max(1), + segments, + shapes, + labels, + alias_names, + } + } + + /// Recompute the entries of `node` and of the connectors in `connectors`. + fn refresh(&mut self, elements: &[ViewElement], node: usize, connectors: &[usize]) { + let uid_elements: HashMap = + elements.iter().map(|e| (e.get_uid(), e)).collect(); + for &i in connectors { + self.segments[i] = element_segments(&elements[i], &uid_elements); + } + self.shapes[node] = shape_boxes(&elements[node]); + self.labels[node] = label_box(&elements[node], &self.alias_names); + } +} + +fn shape_boxes(e: &ViewElement) -> Vec { + let mut rects: Vec = node_shape_box(e).into_iter().collect(); + if let ViewElement::Flow(f) = e { + rects.extend(pipe_rects(f)); + } + rects +} + /// Try `elements[node]` at the spots around its neighbors; returns whether it /// moved. -fn polish_node(elements: &mut [ViewElement], node: usize, links: &[(usize, i32, i32)]) -> bool { +/// +/// A spot is charged what the metric charges the node's move locally: the +/// crossings of its own connectors, and the names struck -- by its connectors +/// through other labels and by any connector through its own -- each in the +/// metric's per-connector and per-label units. The node's shape and label must +/// land at least the crowding clearance from every other shape, label, and +/// pipe. A move is kept only where the charge falls. +fn polish_node( + elements: &mut [ViewElement], + scene: &mut PolishScene, + node: usize, + links: &[(usize, i32, i32)], +) -> bool { let uid = elements[node].get_uid(); - let incident: Vec = links + // (connector index, the link's other endpoint) + let incident: Vec<(usize, i32)> = links .iter() - .filter(|(_, from, to)| *from == uid || *to == uid) - .map(|(i, _, _)| *i) + .filter_map(|&(i, from, to)| { + if from == uid { + Some((i, to)) + } else if to == uid { + Some((i, from)) + } else { + None + } + }) .collect(); if incident.is_empty() { return false; } - - let (others, obstacles) = { - let uid_elements: HashMap = - elements.iter().map(|e| (e.get_uid(), e)).collect(); - let others: Vec = elements - .iter() - .enumerate() - .filter(|(i, _)| !incident.contains(i)) - .flat_map(|(_, e)| element_segments(e, &uid_elements)) - .collect(); - let obstacles: Vec = elements + let is_incident = |i: usize| incident.iter().any(|&(j, _)| j == i); + let others = || { + scene + .segments .iter() .enumerate() - .filter(|(i, _)| *i != node) - .flat_map(|(_, e)| { - let mut rects: Vec = node_shape_box(e).into_iter().collect(); - if let ViewElement::Flow(f) = e { - rects.extend(pipe_rects(f)); - } - rects - }) - .collect(); - (others, obstacles) + .filter(move |(i, _)| !is_incident(*i)) + .flat_map(|(_, segs)| segs.iter()) }; - let neighbors: Vec = { - let positions: HashMap = elements + // Cheap gate: a node none of whose connectors cross anything stays put. + let crossing_now = incident.iter().any(|&(i, _)| { + scene.segments[i] .iter() - .filter_map(|e| element_center(e).map(|p| (e.get_uid(), p))) - .collect(); - incident - .iter() - .filter_map(|&i| match &elements[i] { - ViewElement::Link(l) => { - let other = if l.from_uid == uid { - l.to_uid - } else { - l.from_uid - }; - positions.get(&other).copied() - } - _ => None, - }) - .collect() - }; + .any(|seg| others().any(|other| annealing::do_segments_intersect(seg, other))) + }); + if !crossing_now { + return false; + } + + let weights = MetricWeights::default(); let Some(start) = element_center(&elements[node]) else { return false; }; + let neighbors: Vec = incident + .iter() + .filter_map(|&(_, other)| { + elements + .iter() + .find(|e| e.get_uid() == other) + .and_then(element_center) + }) + .collect(); if neighbors.is_empty() { return false; } - // What the node's own connectors cross, and how long they are, with the - // node at `p`. - let charge = |elements: &mut [ViewElement], p: Position| -> (usize, f64) { - set_center(&mut elements[node], p); - let uid_elements: HashMap = - elements.iter().map(|e| (e.get_uid(), e)).collect(); - let mut crossings = 0; + // The node's local charge with it at `p`, and its connectors' length. + let charge = |elements: &[ViewElement], moved: &ViewElement| -> (f64, f64) { + let mut uid_elements: HashMap = incident + .iter() + .filter_map(|&(_, other)| elements.iter().find(|e| e.get_uid() == other)) + .map(|e| (e.get_uid(), e)) + .collect(); + uid_elements.insert(uid, moved); + let mut crossings = 0usize; let mut length = 0.0; - for &i in &incident { + let mut own: Vec<(LineSegment, i32)> = Vec::new(); + for &(i, other_end) in &incident { for seg in element_segments(&elements[i], &uid_elements) { length += (seg.end - seg.start).length(); - crossings += others - .iter() + crossings += others() .filter(|other| annealing::do_segments_intersect(&seg, other)) .count(); + own.push((seg, other_end)); } } - (crossings, length) + let mut struck = 0.0; + for (j, label) in scene.labels.iter().enumerate() { + let Some(label) = label else { continue }; + if j == node { + continue; + } + let owner = elements[j].get_uid(); + let through: f64 = own + .iter() + .map(|(seg, other_end)| { + let factor = if *other_end == owner { + OWN_LINK_STRIKE_FACTOR + } else { + 1.0 + }; + factor * run_inside(seg, label) + }) + .sum(); + struck += strike_fraction(through, label); + } + if let Some(label) = label_box(moved, &scene.alias_names) { + let through: f64 = others().map(|seg| run_inside(seg, &label)).sum::() + + own + .iter() + .map(|(seg, _)| OWN_LINK_STRIKE_FACTOR * run_inside(seg, &label)) + .sum::(); + struck += strike_fraction(through, &label); + } + let cost = weights.crossings * crossings as f64 / scene.connector_count as f64 + + weights.label_connector_overlap * struck / scene.label_count as f64; + (cost, length) }; - let (now_crossings, now_length) = charge(elements, start); - if now_crossings == 0 { - return false; - } + let (now_cost, now_length) = charge(elements, &elements[node]); let n = neighbors.len() as f64; let centroid = Position::new( @@ -171,31 +278,97 @@ fn polish_node(elements: &mut [ViewElement], node: usize, links: &[(usize, i32, let radius = (start - centroid) .length() .clamp(MIN_RING_RADIUS, MAX_RING_RADIUS); - let mut best = (now_crossings, now_length, start); + let mut best: Option<(f64, f64, Position)> = None; + let mut candidate = elements[node].clone(); for k in 0..RING_SPOTS { let angle = k as f64 * 2.0 * PI / RING_SPOTS as f64; let spot = Position::new( centroid.x + radius * angle.cos(), centroid.y + radius * angle.sin(), ); - set_center(&mut elements[node], spot); - let blocked = node_shape_box(&elements[node]).is_some_and(|shape| { - let clear = grown(&shape, COMFORTABLE_CLEARANCE); - obstacles - .iter() - .any(|obstacle| rect_overlap_area(&clear, obstacle) > 0.0) - }); + set_center(&mut candidate, spot); + let footprint: Vec = node_shape_box(&candidate) + .into_iter() + .chain(label_box(&candidate, &scene.alias_names)) + .map(|r| grown(&r, COMFORTABLE_CLEARANCE)) + .collect(); + let blocked = scene + .shapes + .iter() + .zip(&scene.labels) + .enumerate() + .filter(|(j, _)| *j != node) + .flat_map(|(_, (shapes, label))| shapes.iter().chain(label.iter())) + .any(|obstacle| { + footprint + .iter() + .any(|clear| rect_overlap_area(clear, obstacle) > 0.0) + }); if blocked { continue; } - let (crossings, length) = charge(elements, spot); - if (crossings, length) < (best.0, best.1) { - best = (crossings, length, spot); + let (cost, length) = charge(elements, &candidate); + let (best_cost, best_length) = best.map_or((now_cost, now_length), |(c, l, _)| (c, l)); + if cost < best_cost - 1e-12 || (cost <= best_cost + 1e-12 && length < best_length - 1e-9) { + best = Some((cost, length, spot)); + } + } + match best { + Some((cost, _, spot)) if cost < now_cost - 1e-12 => { + set_center(&mut elements[node], spot); + let connectors: Vec = incident.iter().map(|&(i, _)| i).collect(); + scene.refresh(elements, node, &connectors); + true } + _ => false, } - let moved = best.0 < now_crossings; - set_center(&mut elements[node], if moved { best.2 } else { start }); - moved +} + +/// The metric's strike fraction for `through` px of line in `label`'s text. +fn strike_fraction(through: f64, label: &Bounds) -> f64 { + let side = (label.right - label.left).min(label.bottom - label.top) - 2.0 * LABEL_INSET; + if side <= 0.0 { + return 0.0; + } + (through / side).min(1.0) +} + +/// How far a segment runs inside `label`'s (inset) text box. +fn run_inside(seg: &LineSegment, label: &Bounds) -> f64 { + let text = Bounds { + left: label.left + LABEL_INSET, + top: label.top + LABEL_INSET, + right: label.right - LABEL_INSET, + bottom: label.bottom - LABEL_INSET, + }; + let (p0, p1) = ( + Point { + x: seg.start.x, + y: seg.start.y, + }, + Point { + x: seg.end.x, + y: seg.end.y, + }, + ); + segment_clip_interval_in_rect(&p0, &p1, &text) + .map_or(0.0, |(t0, t1)| (t1 - t0) * (seg.end - seg.start).length()) +} + +/// The label box an element draws at its current side. +fn label_box(e: &ViewElement, alias_names: &HashMap) -> Option { + if let ViewElement::Alias(a) = e { + let name = alias_names.get(&a.uid)?; + return Some(label_bounds(&alias_label_props_for(a, name, a.label_side))); + } + let side = match e { + ViewElement::Aux(a) => a.label_side, + ViewElement::Stock(s) => s.label_side, + ViewElement::Flow(f) => f.label_side, + ViewElement::Module(m) => m.label_side, + _ => return None, + }; + element_label_props_for(e, side).map(|props| label_bounds(&props)) } fn element_center(e: &ViewElement) -> Option { diff --git a/src/simlin-engine/src/layout/polish_tests.rs b/src/simlin-engine/src/layout/polish_tests.rs index 954443e93..351990e5b 100644 --- a/src/simlin-engine/src/layout/polish_tests.rs +++ b/src/simlin-engine/src/layout/polish_tests.rs @@ -134,3 +134,22 @@ fn a_parameter_never_steps_onto_another_shape() { "the parameter stays put" ); } + +#[test] +fn only_the_nodes_it_may_move_move() { + // The crossed X of the first test, with only the left parameter free to + // move: it uncrosses the links alone, and the right parameter stays put. + let elements = vec![ + stock(1, "left stock", 0.0, 0.0), + stock(2, "right stock", 200.0, 0.0), + aux(3, "left parameter", 200.0, 120.0), + aux(4, "right parameter", 0.0, 120.0), + link(10, 3, 1), + link(11, 4, 2), + ]; + let mut polished = elements.clone(); + polish_crossings_for(&mut polished, |uid| uid == 3); + assert_eq!(count_view_crossings(&view_of(polished.clone())), 0); + assert_ne!(position(&polished, 3), position(&elements, 3)); + assert_eq!(position(&polished, 4), position(&elements, 4)); +}