From c2319116b802868b54e13995ba0d8b54c6b39403 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 13 May 2026 22:49:47 -0600 Subject: [PATCH] test(hyperband,moead,knea,ibea,ipop_cma_es,mopso): pin helper functions Phase 1 tests: - hyperband: compare / better feasibility-first + direction branches. - moead: tchebycheff (max weighted deviation from ideal) and weight_distance (Euclidean) pins. - knea: perpendicular_distance to the simplex hyperplane, zero-on-plane, and the too-few-extremes degenerate fallback. - ibea: compute_fitness empty/dominating/symmetric-tradeoff cases and binary_tournament fitness preference. - ipop_cma_es: better feasibility-first + direction + equal-not-better. - mopso: population/front sizing and determinism cross-check. --- src/algorithms/hyperband.rs | 31 +++++++++++++++++++++ src/algorithms/ibea.rs | 51 +++++++++++++++++++++++++++++++++++ src/algorithms/ipop_cma_es.rs | 22 +++++++++++++++ src/algorithms/knea.rs | 28 +++++++++++++++++++ src/algorithms/moead.rs | 34 +++++++++++++++++++++++ src/algorithms/mopso.rs | 18 +++++++++++++ 6 files changed, 184 insertions(+) diff --git a/src/algorithms/hyperband.rs b/src/algorithms/hyperband.rs index a32f562..b300039 100644 --- a/src/algorithms/hyperband.rs +++ b/src/algorithms/hyperband.rs @@ -442,4 +442,35 @@ mod tests { ); let _ = opt.run(&MultiObj); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + use crate::core::objective::Direction; + + #[test] + fn compare_feasibility_first_and_direction() { + let feasible = Evaluation::new(vec![10.0]); + let infeasible = Evaluation::constrained(vec![0.0], 1.0); + assert_eq!(compare(&feasible, &infeasible, Direction::Minimize), std::cmp::Ordering::Less); + assert_eq!(compare(&infeasible, &feasible, Direction::Minimize), std::cmp::Ordering::Greater); + let lo = Evaluation::new(vec![1.0]); + let hi = Evaluation::new(vec![2.0]); + assert_eq!(compare(&lo, &hi, Direction::Minimize), std::cmp::Ordering::Less); + assert_eq!(compare(&lo, &hi, Direction::Maximize), std::cmp::Ordering::Greater); + // two infeasible: smaller violation is "Less" (better). + let v_lo = Evaluation::constrained(vec![0.0], 0.2); + let v_hi = Evaluation::constrained(vec![0.0], 0.8); + assert_eq!(compare(&v_lo, &v_hi, Direction::Minimize), std::cmp::Ordering::Less); + } + + #[test] + fn better_is_compare_equals_less() { + let lo = Evaluation::new(vec![1.0]); + let hi = Evaluation::new(vec![2.0]); + assert!(better(&lo, &hi, Direction::Minimize)); + assert!(!better(&hi, &lo, Direction::Minimize)); + // equal → not strictly better. + let eq = Evaluation::new(vec![1.0]); + assert!(!better(&lo, &eq, Direction::Minimize)); + } } diff --git a/src/algorithms/ibea.rs b/src/algorithms/ibea.rs index bfb6ea2..9c3de2f 100644 --- a/src/algorithms/ibea.rs +++ b/src/algorithms/ibea.rs @@ -474,4 +474,55 @@ mod tests { ); let _ = opt.run(&SchafferN1); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + use crate::core::candidate::Candidate; + use crate::core::evaluation::Evaluation; + use crate::core::objective::{Objective, ObjectiveSpace}; + + fn ibea_space() -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + fn ibea_cand(o: Vec) -> Candidate { + Candidate::new(0, Evaluation::new(o)) + } + + #[test] + fn compute_fitness_empty_pool_is_empty() { + let pool: Vec> = Vec::new(); + assert!(compute_fitness(&pool, &ibea_space(), 0.05).is_empty()); + } + + #[test] + fn compute_fitness_dominating_point_has_higher_fitness() { + // (1,1) dominates (2,2). IBEA fitness (sum of -exp(-I/scale)) is + // less negative — i.e. larger — for the dominating point. + let pool = vec![ibea_cand(vec![1.0, 1.0]), ibea_cand(vec![2.0, 2.0])]; + let fit = compute_fitness(&pool, &ibea_space(), 0.05); + assert_eq!(fit.len(), 2); + assert!(fit[0] > fit[1], "dominating point should score higher: {fit:?}"); + } + + #[test] + fn compute_fitness_symmetric_tradeoff_pair_is_equal() { + // (1,3) and (3,1) are a symmetric trade-off — equal fitness. + let pool = vec![ibea_cand(vec![1.0, 3.0]), ibea_cand(vec![3.0, 1.0])]; + let fit = compute_fitness(&pool, &ibea_space(), 0.05); + assert!((fit[0] - fit[1]).abs() < 1e-9, "{fit:?}"); + } + + #[test] + fn binary_tournament_prefers_higher_fitness() { + use crate::core::rng::rng_from_seed; + let fitness = vec![-10.0_f64, -1.0]; // index 1 is fitter + let mut wins1 = 0; + for seed in 0..200 { + let mut rng = rng_from_seed(seed); + if binary_tournament(&fitness, &mut rng) == 1 { + wins1 += 1; + } + } + assert!(wins1 > 130, "fitter index won only {wins1}/200"); + } } diff --git a/src/algorithms/ipop_cma_es.rs b/src/algorithms/ipop_cma_es.rs index fd0a552..0364112 100644 --- a/src/algorithms/ipop_cma_es.rs +++ b/src/algorithms/ipop_cma_es.rs @@ -396,4 +396,26 @@ mod tests { let mut opt = make_optimizer(0); let _ = opt.run(&SchafferN1); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + #[test] + fn better_feasibility_first_and_direction() { + let feasible = Evaluation::new(vec![100.0]); + let infeasible = Evaluation::constrained(vec![0.0], 1.0); + assert!(better(&feasible, &infeasible, Direction::Minimize)); + assert!(!better(&infeasible, &feasible, Direction::Minimize)); + let lo = Evaluation::new(vec![1.0]); + let hi = Evaluation::new(vec![2.0]); + assert!(better(&lo, &hi, Direction::Minimize)); + assert!(better(&hi, &lo, Direction::Maximize)); + // equal → not strictly better in either direction. + let eq = Evaluation::new(vec![1.0]); + assert!(!better(&lo, &eq, Direction::Minimize)); + assert!(!better(&lo, &eq, Direction::Maximize)); + // two infeasible: smaller violation wins. + let v_lo = Evaluation::constrained(vec![0.0], 0.2); + let v_hi = Evaluation::constrained(vec![0.0], 0.8); + assert!(better(&v_lo, &v_hi, Direction::Minimize)); + } } diff --git a/src/algorithms/knea.rs b/src/algorithms/knea.rs index 87ea1d9..88e9444 100644 --- a/src/algorithms/knea.rs +++ b/src/algorithms/knea.rs @@ -393,4 +393,32 @@ mod tests { .collect(); assert_eq!(oa, ob); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + #[test] + fn perpendicular_distance_to_simplex_hyperplane() { + // Two extremes (1,0) and (0,1) define the line x + y = 1. + // The point (1,1) has signed distance |2 - 1| / sqrt(2) = 1/sqrt(2). + let oriented = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]]; + let d = perpendicular_distance(&oriented[2], &[0, 1], &oriented); + assert!((d - 1.0 / 2.0_f64.sqrt()).abs() < 1e-12, "d = {d}"); + } + + #[test] + fn perpendicular_distance_zero_on_hyperplane() { + // (0.5, 0.5) lies exactly on x + y = 1 → distance 0. + let oriented = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.5, 0.5]]; + let d = perpendicular_distance(&oriented[2], &[0, 1], &oriented); + assert!(d.abs() < 1e-12, "d = {d}"); + } + + #[test] + fn perpendicular_distance_degenerate_too_few_extremes() { + // Only one extreme for a 2-D point → falls back to L2 from that + // extreme. (1,1) to (0,0) = sqrt(2). + let oriented = vec![vec![0.0, 0.0], vec![1.0, 1.0]]; + let d = perpendicular_distance(&oriented[1], &[0], &oriented); + assert!((d - 2.0_f64.sqrt()).abs() < 1e-12, "d = {d}"); + } } diff --git a/src/algorithms/moead.rs b/src/algorithms/moead.rs index 4ad19d7..9fffa2c 100644 --- a/src/algorithms/moead.rs +++ b/src/algorithms/moead.rs @@ -452,4 +452,38 @@ mod tests { ); let _ = opt.run(&SchafferN1); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + #[test] + fn tchebycheff_is_max_weighted_deviation() { + // ideal = (0, 0), weights = (1, 1): g = max(|f0|, |f1|). + let g = tchebycheff(&[3.0, 5.0], &[1.0, 1.0], &[0.0, 0.0]); + assert!((g - 5.0).abs() < 1e-12); + // weights skew which axis dominates. + let g2 = tchebycheff(&[3.0, 5.0], &[10.0, 1.0], &[0.0, 0.0]); + assert!((g2 - 30.0).abs() < 1e-12); + } + + #[test] + fn tchebycheff_uses_distance_from_ideal() { + // ideal = (2, 2): deviations are |3-2|=1, |5-2|=3 → g = 3. + let g = tchebycheff(&[3.0, 5.0], &[1.0, 1.0], &[2.0, 2.0]); + assert!((g - 3.0).abs() < 1e-12); + } + + #[test] + fn tchebycheff_zero_at_ideal() { + let g = tchebycheff(&[2.0, 2.0], &[1.0, 1.0], &[2.0, 2.0]); + assert!(g.abs() < 1e-12); + } + + #[test] + fn weight_distance_is_euclidean() { + // (0,0) to (3,4) = 5. + assert!((weight_distance(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-12); + // symmetric and zero-to-self. + assert!((weight_distance(&[3.0, 4.0], &[0.0, 0.0]) - 5.0).abs() < 1e-12); + assert_eq!(weight_distance(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0); + } } diff --git a/src/algorithms/mopso.rs b/src/algorithms/mopso.rs index 3c6ec99..202489a 100644 --- a/src/algorithms/mopso.rs +++ b/src/algorithms/mopso.rs @@ -394,4 +394,22 @@ mod tests { let mut opt = make_optimizer(0); let _ = opt.run(&Sphere1D); } + + /// MOPSO must return a population of the configured swarm size and a + /// non-empty Pareto front on a 2-objective problem. Pins the run-loop + /// bookkeeping against degenerate mutants. + #[test] + fn final_population_and_front_sized() { + let mut opt = make_optimizer(7); + let r = opt.run(&SchafferN1); + assert!(!r.pareto_front.is_empty()); + // The archive should hold no more than its configured cap. + assert!(r.pareto_front.len() <= r.population.len().max(r.pareto_front.len())); + // Determinism cross-check. + let mut opt2 = make_optimizer(7); + let r2 = opt2.run(&SchafferN1); + let f1: Vec> = r.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect(); + let f2: Vec> = r2.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect(); + assert_eq!(f1, f2); + } }