mod clustering;
mod cmaes;
pub mod final_layout;
mod initial_layout;
mod layout;
pub mod normalize;
mod packing;
#[cfg(test)]
mod corpus_quality;
#[cfg(test)]
mod synthetic_groundtruth;
pub use final_layout::Optimizer;
pub use initial_layout::{InitialSampler, MdsSolver};
pub use layout::Layout;
use crate::error::DiagramError;
use crate::geometry::shapes::Circle;
use crate::geometry::traits::DiagramShape;
use crate::loss::LossType;
use crate::spec::{DiagramSpec, PreprocessedSpec};
use crate::venn::VennDiagram;
use nalgebra::DVector;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
use std::collections::HashMap;
const VENN_SEED_MAX_SETS_CIRCLE: usize = 4;
const VENN_SEED_MAX_SETS_ELLIPSE: usize = 5;
const VENN_SEED_MAX_SETS_SQUARE: usize = 3;
pub struct Fitter<'a, S: DiagramShape = Circle> {
spec: &'a DiagramSpec,
max_iterations: usize,
tolerance: f64,
xtol: Option<f64>,
ftol: Option<f64>,
gtol: Option<f64>,
seed: Option<u64>,
loss_type: LossType,
optimizer_pool: Vec<Optimizer>,
n_restarts: usize,
initial_solvers: Vec<MdsSolver>,
cmaes_fallback_threshold: f64,
initial_sampler: InitialSampler,
_shape: std::marker::PhantomData<S>,
}
impl<'a, S: DiagramShape + Copy + 'static> Fitter<'a, S> {
pub fn new(spec: &'a DiagramSpec) -> Self {
Fitter {
spec,
max_iterations: 200,
tolerance: 1e-3,
xtol: None,
ftol: None,
gtol: None,
seed: None,
loss_type: LossType::SumSquared,
optimizer_pool: vec![Optimizer::CmaEsLm],
cmaes_fallback_threshold: 1e-3,
n_restarts: 10,
initial_solvers: vec![MdsSolver::LevenbergMarquardt],
initial_sampler: InitialSampler::default(),
_shape: std::marker::PhantomData,
}
}
pub fn initial_solver(mut self, solver: MdsSolver) -> Self {
self.initial_solvers = vec![solver];
self
}
pub fn initial_solver_pool(mut self, pool: Vec<MdsSolver>) -> Self {
assert!(!pool.is_empty(), "initial_solver_pool must be non-empty");
self.initial_solvers = pool;
self
}
pub fn max_iterations(mut self, max: usize) -> Self {
self.max_iterations = max;
self
}
pub fn tolerance(mut self, tolerance: f64) -> Self {
self.tolerance = tolerance;
self
}
pub fn xtol(mut self, xtol: f64) -> Self {
self.xtol = Some(xtol);
self
}
pub fn ftol(mut self, ftol: f64) -> Self {
self.ftol = Some(ftol);
self
}
pub fn gtol(mut self, gtol: f64) -> Self {
self.gtol = Some(gtol);
self
}
pub fn optimizer(mut self, optimizer: Optimizer) -> Self {
self.optimizer_pool = vec![optimizer];
self
}
pub fn optimizer_pool(mut self, pool: Vec<Optimizer>) -> Self {
assert!(!pool.is_empty(), "optimizer_pool must be non-empty");
self.optimizer_pool = pool;
self
}
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
pub fn loss_type(mut self, loss_type: LossType) -> Self {
self.loss_type = loss_type;
self
}
pub fn n_restarts(mut self, n: usize) -> Self {
self.n_restarts = n.max(1);
self
}
pub fn cmaes_fallback_threshold(mut self, threshold: f64) -> Self {
self.cmaes_fallback_threshold = threshold;
self
}
pub fn initial_sampler(mut self, sampler: InitialSampler) -> Self {
self.initial_sampler = sampler;
self
}
pub fn fit(self) -> Result<Layout<S>, DiagramError> {
self.fit_with_optimization(true)
}
pub fn fit_initial_only(self) -> Result<Layout<S>, DiagramError> {
self.fit_with_optimization(false)
}
fn fit_with_optimization(self, optimize: bool) -> Result<Layout<S>, DiagramError> {
let spec = self.spec.preprocess()?;
let n_sets = spec.n_sets;
let max_iterations = self.max_iterations;
let tolerance = self.tolerance;
let xtol = self.xtol;
let ftol = self.ftol;
let gtol = self.gtol;
let loss_type = self.loss_type;
let optimizer_pool = self.optimizer_pool.clone();
let cmaes_fallback_threshold = self.cmaes_fallback_threshold;
let master_seed = self.seed.unwrap_or_else(|| rand::rng().random());
let mut master_rng = StdRng::seed_from_u64(master_seed);
let optimal_distances = Self::compute_optimal_distances(&spec)?;
let initial_radii: Vec<f64> = spec
.set_areas
.iter()
.map(|area| (area / std::f64::consts::PI).sqrt())
.collect();
let n_attempts = if optimize { self.n_restarts.max(1) } else { 1 };
let attempt_seeds: Vec<u64> = (0..n_attempts).map(|_| master_rng.random()).collect();
let lhs_rows: Option<Vec<Vec<f64>>> = match self.initial_sampler {
InitialSampler::Uniform => None,
InitialSampler::LatinHypercube => {
let scale = initial_layout::sampling_scale(&spec.set_areas);
let half_width = initial_layout::LHS_HALF_WIDTH_FRAC * scale;
let lo = 0.5 * scale - half_width;
let hi = 0.5 * scale + half_width;
Some(initial_layout::latin_hypercube_rows(
n_attempts,
n_sets * 2,
lo,
hi,
&mut master_rng,
))
}
};
let venn_initial: Option<Vec<f64>> = if optimize {
venn_warm_start_params::<S>(&spec)
} else {
None
};
let initial_solvers = self.initial_solvers.clone();
let run_attempt =
|(attempt_idx, attempt_seed): (usize, u64)| -> Result<(Vec<f64>, f64), DiagramError> {
let attempt_optimizer = optimizer_pool[attempt_idx % optimizer_pool.len()];
let final_config = final_layout::FinalLayoutConfig {
max_iterations,
tolerance,
xtol,
ftol,
gtol,
loss_type,
optimizer: attempt_optimizer,
seed: attempt_seed,
n_restarts: 1,
cmaes_fallback_threshold,
};
if attempt_idx == 0 {
if let Some(venn_params) = venn_initial.as_ref() {
let initial_param = DVector::from_vec(venn_params.clone());
return final_layout::optimize_from_initial::<S>(
&spec,
&initial_param,
&final_config,
)
.map(|(p, l)| (p.as_slice().to_vec(), l))
.map_err(|e| {
DiagramError::InvalidCombination(format!(
"Venn-seed final optimisation failed: {}",
e
))
});
}
}
let mut attempt_rng = StdRng::seed_from_u64(attempt_seed);
let initial_solver = initial_solvers[attempt_idx % initial_solvers.len()];
let initial_positions: Option<&[f64]> =
lhs_rows.as_ref().map(|rows| rows[attempt_idx].as_slice());
let initial_params = match initial_layout::compute_initial_layout_with_solver(
&optimal_distances,
&spec.relationships,
&spec.set_areas,
&mut attempt_rng,
initial_solver,
initial_positions,
) {
Ok(p) => p,
Err(e) => {
return Err(DiagramError::InvalidCombination(format!(
"Initial layout failed: {}",
e
)));
}
};
let (x, y) = initial_params.split_at(n_sets);
let initial_positions: Vec<f64> = x
.iter()
.zip(y.iter())
.flat_map(|(xi, yi)| vec![*xi, *yi])
.collect();
if !optimize {
let mut params = Vec::new();
for i in 0..n_sets {
let xi = initial_positions[i * 2];
let yi = initial_positions[i * 2 + 1];
let r = initial_radii[i];
params.extend(S::params_from_circle(xi, yi, r));
}
return Ok((params, 0.0));
}
match final_layout::optimize_layout::<S>(
&spec,
&initial_positions,
&initial_radii,
final_config,
) {
Ok((params, loss)) => Ok((params, loss)),
Err(e) => Err(DiagramError::InvalidCombination(format!(
"Optimization failed: {}",
e
))),
}
};
let indexed_seeds: Vec<(usize, u64)> = attempt_seeds
.iter()
.enumerate()
.map(|(i, &s)| (i, s))
.collect();
let attempt_results: Vec<Result<(Vec<f64>, f64), DiagramError>> = if optimize {
#[cfg(not(target_arch = "wasm32"))]
{
indexed_seeds
.par_iter()
.map(|&pair| run_attempt(pair))
.collect()
}
#[cfg(target_arch = "wasm32")]
{
indexed_seeds
.iter()
.map(|&pair| run_attempt(pair))
.collect()
}
} else {
vec![run_attempt(indexed_seeds[0])]
};
let mut best: Option<(Vec<f64>, f64)> = None;
let mut last_err: Option<DiagramError> = None;
for result in attempt_results {
match result {
Ok((params, loss)) => match &best {
None => best = Some((params, loss)),
Some((_, best_loss)) if loss < *best_loss => best = Some((params, loss)),
_ => {}
},
Err(e) => last_err = Some(e),
}
}
let (final_params, _loss) = best.ok_or_else(|| {
last_err.unwrap_or_else(|| {
DiagramError::InvalidCombination(
"All restarts failed to produce a layout".to_string(),
)
})
})?;
let params_per_shape = S::n_params();
let mut optimized_shapes: Vec<S> = Vec::with_capacity(n_sets);
for i in 0..n_sets {
let start = i * params_per_shape;
let end = start + params_per_shape;
optimized_shapes.push(S::from_params(&final_params[start..end]));
}
let pre_normalize_regions = S::compute_exclusive_regions(&optimized_shapes);
crate::fitter::normalize::normalize_layout_with_clusters(
&mut optimized_shapes,
0.05,
Some(&pre_normalize_regions),
);
let zero_params = vec![0.0; params_per_shape];
let mut shapes: Vec<S> = Vec::with_capacity(self.spec.set_names().len());
let mut set_to_shape = HashMap::new();
for (original_idx, set_name) in self.spec.set_names().iter().enumerate() {
let shape = match spec.set_to_idx.get(set_name) {
Some(&preproc_idx) => optimized_shapes[preproc_idx],
None => S::from_params(&zero_params),
};
shapes.push(shape);
set_to_shape.insert(set_name.clone(), original_idx);
}
let layout = Layout::new(
shapes,
set_to_shape,
self.spec,
self.max_iterations,
self.loss_type,
);
Ok(layout)
}
#[allow(clippy::needless_range_loop)]
fn compute_optimal_distances(
spec: &crate::spec::PreprocessedSpec,
) -> Result<Vec<Vec<f64>>, DiagramError> {
let n_sets = spec.n_sets;
let mut optimal_distances = vec![vec![0.0; n_sets]; n_sets];
for i in 0..n_sets {
for j in (i + 1)..n_sets {
let overlap = spec.relationships.overlap_area(i, j);
let desired_distance =
S::mds_target_distance(spec.set_areas[i], spec.set_areas[j], overlap)?;
optimal_distances[i][j] = desired_distance;
optimal_distances[j][i] = desired_distance;
}
}
Ok(optimal_distances)
}
}
fn venn_warm_start_params<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
) -> Option<Vec<f64>> {
use crate::geometry::shapes::{Ellipse, Square};
use std::any::TypeId;
let n_sets = spec.n_sets;
if n_sets < 2 {
return None;
}
if spec_has_disjoint_pair(spec) {
return None;
}
let type_id = TypeId::of::<S>();
let pp = S::n_params();
if type_id == TypeId::of::<Square>() {
if n_sets > VENN_SEED_MAX_SETS_SQUARE {
return None;
}
let mean_side: f64 = if !spec.set_areas.is_empty() {
let total: f64 = spec.set_areas.iter().map(|a| a.sqrt()).sum();
(total / spec.set_areas.len() as f64).max(1e-6)
} else {
1.0
};
let venn = VennDiagram::<Square>::new(n_sets).ok()?;
let mut params = Vec::with_capacity(n_sets * pp);
for sq in venn.shapes() {
let c = sq.center();
params.extend([c.x() * mean_side, c.y() * mean_side, sq.side() * mean_side]);
}
return Some(params);
}
if type_id != TypeId::of::<Circle>() && type_id != TypeId::of::<Ellipse>() {
return None;
}
let max_n = match pp {
3 => VENN_SEED_MAX_SETS_CIRCLE,
5 => VENN_SEED_MAX_SETS_ELLIPSE,
_ => return None,
};
if n_sets > max_n {
return None;
}
let venn = VennDiagram::<Ellipse>::new(n_sets).ok()?;
let mean_radius: f64 = if !spec.set_areas.is_empty() {
let total: f64 = spec
.set_areas
.iter()
.map(|a| (a / std::f64::consts::PI).sqrt())
.sum();
(total / spec.set_areas.len() as f64).max(1e-6)
} else {
1.0
};
let mut params = Vec::with_capacity(n_sets * pp);
for ell in venn.shapes() {
let h = ell.center().x() * mean_radius;
let k = ell.center().y() * mean_radius;
let a = ell.semi_major() * mean_radius;
let b = ell.semi_minor() * mean_radius;
let phi = ell.rotation();
match pp {
3 => {
if (a - b).abs() > 1e-9 * mean_radius.max(1.0) {
return None;
}
params.extend(S::params_from_circle(h, k, a));
}
5 => {
params.extend([h, k, a.ln(), b.ln(), phi]);
}
_ => return None,
}
}
Some(params)
}
fn spec_has_disjoint_pair(spec: &PreprocessedSpec) -> bool {
let n = spec.n_sets;
for i in 0..n {
for j in (i + 1)..n {
if spec.relationships.is_disjoint(i, j) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::DiagramSpecBuilder;
#[test]
fn test_fitter_basic() {
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).fit().unwrap();
assert_eq!(layout.shapes().len(), 2);
assert!(layout.loss() >= 0.0);
}
#[test]
fn test_fitter_with_intersection() {
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.intersection(&["A", "B"], 2.0)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).fit().unwrap();
assert_eq!(layout.shapes().len(), 2);
assert_eq!(layout.requested().len(), 3); }
#[test]
fn test_russian_doll_initial_fit() {
let spec = DiagramSpecBuilder::new()
.set("A", 1.0)
.intersection(&["A", "B"], 1.0)
.intersection(&["A", "B", "C"], 1.0)
.input_type(crate::InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec)
.seed(42)
.fit_initial_only()
.unwrap();
assert!(layout.loss().is_finite());
assert!(layout.loss() < 25.0);
}
#[test]
fn test_seed_reproducibility() {
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.intersection(&["A", "B"], 2.0)
.build()
.unwrap();
let layout1 = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
let layout2 = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
assert_eq!(layout1.loss(), layout2.loss());
for (s1, s2) in layout1.shapes().iter().zip(layout2.shapes().iter()) {
assert_eq!(s1.center(), s2.center());
assert_eq!(s1.radius(), s2.radius());
}
}
#[test]
fn test_fitter_with_ellipses_basic() {
use crate::geometry::shapes::Ellipse;
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.build()
.unwrap();
let layout = Fitter::<Ellipse>::new(&spec).fit().unwrap();
assert_eq!(layout.shapes().len(), 2);
assert!(layout.loss() >= 0.0);
}
#[test]
fn test_fitter_with_ellipses_intersection() {
use crate::geometry::shapes::Ellipse;
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.intersection(&["A", "B"], 2.0)
.build()
.unwrap();
let layout = Fitter::<Ellipse>::new(&spec).seed(42).fit().unwrap();
assert_eq!(layout.shapes().len(), 2);
assert_eq!(layout.requested().len(), 3); assert!(layout.loss() < 10.0); }
#[test]
#[ignore = "slow regression coverage"]
fn test_issue28_six_set_ellipse_regression() {
use crate::geometry::shapes::Ellipse;
use crate::test_utils::corpus;
let spec = (corpus::get("wilkinson_6_set").expect("corpus entry").build)();
let layout = Fitter::<Ellipse>::new(&spec)
.seed(1)
.tolerance(1e-10)
.max_iterations(2000)
.fit()
.unwrap();
assert!(
layout.diag_error() < 1e-6,
"issue #28 case 1: diag_error = {:e} (expected < 1e-6)",
layout.diag_error()
);
}
#[test]
#[ignore = "slow regression coverage"]
fn test_issue28_four_set_superset_ellipse_regression() {
use crate::geometry::shapes::Ellipse;
use crate::test_utils::corpus;
let spec = (corpus::get("three_inside_fourth")
.expect("corpus entry")
.build)();
let layout = Fitter::<Ellipse>::new(&spec)
.seed(1)
.tolerance(1e-10)
.max_iterations(2000)
.fit()
.unwrap();
assert!(
layout.diag_error() < 1e-6,
"issue #28 case 2: diag_error = {:e} (expected < 1e-6)",
layout.diag_error()
);
}
#[test]
#[ignore = "slow regression coverage"]
fn test_fitter_with_ellipses_three_sets() {
use crate::geometry::shapes::Ellipse;
let spec = DiagramSpecBuilder::new()
.set("A", 15.0)
.set("B", 12.0)
.set("C", 10.0)
.intersection(&["A", "B"], 3.0)
.intersection(&["B", "C"], 2.5)
.intersection(&["A", "C"], 2.0)
.intersection(&["A", "B", "C"], 1.0)
.build()
.unwrap();
let layout = Fitter::<Ellipse>::new(&spec).seed(123).fit().unwrap();
assert_eq!(layout.shapes().len(), 3);
assert!(layout.loss() < 20.0); }
#[test]
fn test_ellipse_to_polygon_workflow() {
use crate::geometry::shapes::{Ellipse, Polygon};
use crate::geometry::traits::Polygonize;
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.intersection(&["A", "B"], 2.0)
.build()
.unwrap();
let layout = Fitter::<Ellipse>::new(&spec).seed(42).fit().unwrap();
let polygons: Vec<Polygon> = layout
.shapes()
.iter()
.map(|ellipse| ellipse.polygonize(64))
.collect();
assert_eq!(polygons.len(), 2);
assert_eq!(polygons[0].vertices().len(), 64);
assert_eq!(polygons[1].vertices().len(), 64);
use crate::geometry::traits::Area;
for (ellipse, polygon) in layout.shapes().iter().zip(polygons.iter()) {
let error = (ellipse.area() - polygon.area()).abs() / ellipse.area();
assert!(
error < 0.01,
"Polygon area error too large: {:.2}%",
error * 100.0
); }
}
#[test]
#[ignore = "slow regression coverage"]
fn test_spurious_ac_intersection() {
use crate::geometry::shapes::Ellipse;
use crate::spec::{DiagramSpecBuilder, InputType};
let spec = DiagramSpecBuilder::new()
.set("A", 2.2)
.set("B", 2.0)
.set("C", 2.0)
.intersection(&["A", "B", "C"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let seeds = vec![42, 123, 456, 789, 1000];
let mut best_loss = f64::INFINITY;
let mut best_seed = 0;
for &seed in &seeds {
let fitter = Fitter::<Ellipse>::new(&spec).seed(seed);
let layout = fitter.fit().unwrap();
if layout.loss() < best_loss {
best_loss = layout.loss();
best_seed = seed;
}
}
let fitter = Fitter::<Ellipse>::new(&spec).seed(best_seed);
let layout = fitter.fit().unwrap();
assert!(layout.loss().is_finite());
}
fn three_set_overlapping_spec() -> DiagramSpec {
DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 10.0)
.set("C", 10.0)
.intersection(&["A", "B"], 4.0)
.intersection(&["A", "C"], 4.0)
.intersection(&["B", "C"], 4.0)
.intersection(&["A", "B", "C"], 2.0)
.input_type(crate::InputType::Exclusive)
.build()
.unwrap()
}
#[test]
fn venn_warm_start_returns_some_for_supported_ellipse_n() {
use crate::geometry::shapes::Ellipse;
for n in 2..=5usize {
let names: Vec<&str> = ["A", "B", "C", "D", "E"][..n].to_vec();
let mut builder = DiagramSpecBuilder::new();
for &name in &names {
builder = builder.set(name, 10.0);
}
for i in 0..n {
for j in (i + 1)..n {
builder = builder.intersection(&[names[i], names[j]], 1.0);
}
}
let spec = builder
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
let params = venn_warm_start_params::<Ellipse>(&preprocessed);
assert!(params.is_some(), "ellipse n={} should produce params", n);
let params = params.unwrap();
assert_eq!(params.len(), n * 5, "ellipse n={} param length", n);
}
}
#[test]
fn venn_warm_start_returns_some_for_circle_n_2_and_3() {
for n in 2..=3usize {
let names: Vec<&str> = ["A", "B", "C"][..n].to_vec();
let mut builder = DiagramSpecBuilder::new();
for &name in &names {
builder = builder.set(name, 10.0);
}
for i in 0..n {
for j in (i + 1)..n {
builder = builder.intersection(&[names[i], names[j]], 1.0);
}
}
let spec = builder
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
let params = venn_warm_start_params::<Circle>(&preprocessed);
assert!(params.is_some(), "circle n={} should produce params", n);
assert_eq!(params.unwrap().len(), n * 3);
}
}
#[test]
fn venn_warm_start_rejects_n4_and_n5_circles() {
for n in [4usize, 5] {
let names: Vec<&str> = ["A", "B", "C", "D", "E"][..n].to_vec();
let mut builder = DiagramSpecBuilder::new();
for &name in &names {
builder = builder.set(name, 10.0);
}
for i in 0..n {
for j in (i + 1)..n {
builder = builder.intersection(&[names[i], names[j]], 1.0);
}
}
let spec = builder
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
assert!(
venn_warm_start_params::<Circle>(&preprocessed).is_none(),
"circle n={} must reject (Venn is non-circular)",
n
);
}
}
#[test]
fn venn_warm_start_rejects_n_above_5_for_ellipses() {
use crate::geometry::shapes::Ellipse;
let names = ["A", "B", "C", "D", "E", "F"];
let mut builder = DiagramSpecBuilder::new();
for &name in &names {
builder = builder.set(name, 10.0);
}
for i in 0..6 {
for j in (i + 1)..6 {
builder = builder.intersection(&[names[i], names[j]], 1.0);
}
}
let spec = builder
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
assert!(
venn_warm_start_params::<Ellipse>(&preprocessed).is_none(),
"ellipse n=6 must reject (no canonical Venn)"
);
}
#[test]
fn venn_warm_start_skips_specs_with_disjoint_pairs() {
use crate::geometry::shapes::Ellipse;
let spec = DiagramSpecBuilder::new()
.set("A", 1.0)
.set("B", 1.0)
.set("C", 1.0)
.input_type(crate::InputType::Exclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
assert!(spec_has_disjoint_pair(&preprocessed));
assert!(
venn_warm_start_params::<Ellipse>(&preprocessed).is_none(),
"ellipse + disjoint spec must skip Venn warm-start"
);
}
#[test]
fn venn_warm_start_returns_some_for_square_n_2_and_3() {
use crate::geometry::shapes::Square;
for n in 2..=3usize {
let names: Vec<&str> = ["A", "B", "C"][..n].to_vec();
let mut builder = DiagramSpecBuilder::new();
for &name in &names {
builder = builder.set(name, 10.0);
}
for i in 0..n {
for j in (i + 1)..n {
builder = builder.intersection(&[names[i], names[j]], 1.0);
}
}
let spec = builder
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
let params = venn_warm_start_params::<Square>(&preprocessed);
assert!(params.is_some(), "square n={} should produce params", n);
let params = params.unwrap();
assert_eq!(params.len(), n * 3, "square n={} param length", n);
for i in 0..n {
let side = params[3 * i + 2];
assert!(side > 0.0, "square n={n} shape {i}: side {side} ≤ 0");
assert!(
(1.0..10.0).contains(&side),
"square n={n} shape {i}: side {side} far from expected ≈ √10"
);
}
}
}
#[test]
fn venn_warm_start_rejects_n4_squares() {
use crate::geometry::shapes::Square;
let names = ["A", "B", "C", "D"];
let mut builder = DiagramSpecBuilder::new();
for &name in &names {
builder = builder.set(name, 10.0);
}
for i in 0..4 {
for j in (i + 1)..4 {
builder = builder.intersection(&[names[i], names[j]], 1.0);
}
}
let spec = builder
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
assert!(
venn_warm_start_params::<Square>(&preprocessed).is_none(),
"square n=4 must reject (no axis-aligned-square Venn)"
);
}
#[test]
fn venn_warm_start_skips_squares_for_specs_with_disjoint_pairs() {
use crate::geometry::shapes::Square;
let spec = DiagramSpecBuilder::new()
.set("A", 1.0)
.set("B", 1.0)
.set("C", 1.0)
.input_type(crate::InputType::Exclusive)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
assert!(spec_has_disjoint_pair(&preprocessed));
assert!(
venn_warm_start_params::<Square>(&preprocessed).is_none(),
"square + disjoint spec must skip Venn warm-start"
);
}
#[test]
fn venn_seed_default_path_yields_finite_loss_for_square() {
use crate::geometry::shapes::Square;
let spec = three_set_overlapping_spec();
let layout = Fitter::<Square>::new(&spec).seed(42).fit().unwrap();
assert!(layout.loss().is_finite());
assert_eq!(layout.shapes().len(), 3);
}
#[test]
fn venn_seed_default_path_yields_finite_loss() {
let spec = three_set_overlapping_spec();
let layout = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
assert!(layout.loss().is_finite());
}
#[test]
fn venn_seed_falls_back_when_n_sets_too_large_for_circle() {
let spec = DiagramSpecBuilder::new()
.set("A", 5.0)
.set("B", 5.0)
.set("C", 5.0)
.set("D", 5.0)
.intersection(&["A", "B"], 1.0)
.intersection(&["A", "C"], 1.0)
.intersection(&["A", "D"], 1.0)
.intersection(&["B", "C"], 1.0)
.intersection(&["B", "D"], 1.0)
.intersection(&["C", "D"], 1.0)
.input_type(crate::InputType::Inclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
assert!(layout.loss().is_finite());
}
}
#[test]
fn test_circles_ac_issue_seed42() {
use crate::fitter::Fitter;
use crate::geometry::shapes::Circle;
use crate::spec::{Combination, DiagramSpecBuilder, InputType};
let spec = DiagramSpecBuilder::new()
.set("A", 2.2)
.set("B", 2.0)
.set("C", 3.0)
.intersection(&["A", "B", "C"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let fitter = Fitter::<Circle>::new(&spec).seed(42);
let layout = fitter.fit().unwrap();
let shape_a = layout.shape_for_set("A").unwrap();
let shape_c = layout.shape_for_set("C").unwrap();
let dist_ac = ((shape_a.center().x() - shape_c.center().x()).powi(2)
+ (shape_a.center().y() - shape_c.center().y()).powi(2))
.sqrt();
let ac_combo = Combination::new(&["A", "C"]);
let ac_fitted = layout.fitted().get(&ac_combo).copied().unwrap_or(0.0);
if dist_ac > shape_a.radius() + shape_c.radius() {
assert!(
ac_fitted <= 0.001,
"A&C fitted area is {:.3} but circles are separated",
ac_fitted
);
}
}
#[test]
#[ignore = "slow regression coverage"]
fn test_compare_optimizers() {
use crate::fitter::Fitter;
use crate::geometry::shapes::Ellipse;
use crate::spec::{DiagramSpecBuilder, InputType};
let spec = DiagramSpecBuilder::new()
.set("A", 2.2)
.set("B", 2.0)
.set("C", 2.0)
.intersection(&["A", "B", "C"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let fitter_default = Fitter::<Ellipse>::new(&spec).seed(42);
let layout_default = fitter_default.fit().unwrap();
assert!(layout_default.loss().is_finite());
}
#[test]
#[ignore = "slow regression coverage"]
fn test_cmaes_lm_escapes_issue92_dropped_pair() {
use crate::fitter::{Fitter, Optimizer};
use crate::geometry::shapes::Ellipse;
use crate::spec::{DiagramSpecBuilder, InputType};
let spec = DiagramSpecBuilder::new()
.set("A", 164.0)
.set("B", 561.0)
.set("C", 166.0)
.intersection(&["A", "B"], 12.0)
.intersection(&["A", "C"], 459.0)
.intersection(&["B", "C"], 703.0)
.intersection(&["A", "B", "C"], 162.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Ellipse>::new(&spec)
.optimizer(Optimizer::CmaEsLm)
.seed(1)
.fit()
.unwrap();
assert!(
layout.loss() < 1e-6,
"CmaEsLm loss = {:e} (expected < 1e-6 — global step regressed)",
layout.loss()
);
}