From 6819ce4091a85e6be02625614015a9bc7e2b04f6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 13 May 2026 22:51:24 -0600 Subject: [PATCH] test(nelder_mead,nsga2,nsga3,one_plus_one_es,particle_swarm): pin selection/geometry helpers Phase 1 tests: - nelder_mead: compare / better feasibility-first + direction. - nsga2: binary_tournament prefers lower rank, then higher crowding distance at equal rank (statistical majority over 200 seeds). - nsga3: solve_intercepts on axis-aligned extremes / singular / empty; associate picks the closest reference direction with correct perpendicular distance. - one_plus_one_es: worse_than across feasibility + direction + equal. - particle_swarm: best_index min/max/tie/single-element. --- src/algorithms/nelder_mead.rs | 28 +++++++++++++++ src/algorithms/nsga2.rs | 60 +++++++++++++++++++++++++++++++ src/algorithms/nsga3.rs | 53 +++++++++++++++++++++++++++ src/algorithms/one_plus_one_es.rs | 28 +++++++++++++++ src/algorithms/particle_swarm.rs | 29 +++++++++++++++ 5 files changed, 198 insertions(+) diff --git a/src/algorithms/nelder_mead.rs b/src/algorithms/nelder_mead.rs index f65865e..7760a12 100644 --- a/src/algorithms/nelder_mead.rs +++ b/src/algorithms/nelder_mead.rs @@ -551,4 +551,32 @@ mod tests { ); let _ = opt.run(&SchafferN1); } + + // ---- 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); + 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); + 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_strict_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)); + let eq = Evaluation::new(vec![1.0]); + assert!(!better(&lo, &eq, Direction::Minimize)); + } } diff --git a/src/algorithms/nsga2.rs b/src/algorithms/nsga2.rs index afe579c..aae136d 100644 --- a/src/algorithms/nsga2.rs +++ b/src/algorithms/nsga2.rs @@ -463,4 +463,64 @@ mod tests { ); let _ = opt.run(&SchafferN1); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + #[test] + fn binary_tournament_prefers_lower_rank() { + use crate::core::candidate::Candidate; + use crate::core::evaluation::Evaluation; + use crate::core::rng::rng_from_seed; + // Entry 0: rank 0; entry 1: rank 1. Lower rank must win every time + // the two draws differ. + let entries = vec![ + Nsga2Entry { + candidate: Candidate::new(0u32, Evaluation::new(vec![1.0, 1.0])), + rank: 0, + crowding_distance: 0.0, + }, + Nsga2Entry { + candidate: Candidate::new(1u32, Evaluation::new(vec![2.0, 2.0])), + rank: 1, + crowding_distance: 100.0, + }, + ]; + let mut wins0 = 0; + for seed in 0..200 { + let mut rng = rng_from_seed(seed); + if binary_tournament(&entries, &mut rng) == 0 { + wins0 += 1; + } + } + // Rank dominates crowding distance — index 0 wins the clear majority. + assert!(wins0 > 130, "lower-rank index won only {wins0}/200"); + } + + #[test] + fn binary_tournament_prefers_higher_crowding_at_equal_rank() { + use crate::core::candidate::Candidate; + use crate::core::evaluation::Evaluation; + use crate::core::rng::rng_from_seed; + // Both rank 0; entry 0 has higher crowding distance → preferred. + let entries = vec![ + Nsga2Entry { + candidate: Candidate::new(0u32, Evaluation::new(vec![1.0, 1.0])), + rank: 0, + crowding_distance: 10.0, + }, + Nsga2Entry { + candidate: Candidate::new(1u32, Evaluation::new(vec![1.0, 1.0])), + rank: 0, + crowding_distance: 1.0, + }, + ]; + let mut wins0 = 0; + for seed in 0..200 { + let mut rng = rng_from_seed(seed); + if binary_tournament(&entries, &mut rng) == 0 { + wins0 += 1; + } + } + assert!(wins0 > 130, "higher-crowding index won only {wins0}/200"); + } } diff --git a/src/algorithms/nsga3.rs b/src/algorithms/nsga3.rs index 69b2692..aed328b 100644 --- a/src/algorithms/nsga3.rs +++ b/src/algorithms/nsga3.rs @@ -554,6 +554,59 @@ impl crate::traits::AlgorithmInfo for Nsga3 { } } +#[cfg(test)] +mod helper_tests { + use super::*; + + #[test] + fn solve_intercepts_axis_aligned_extremes() { + // Extremes (2, 0) and (0, 3): the plane through them on the + // canonical simplex has intercepts (2, 3). + let oriented = vec![vec![2.0, 0.0], vec![0.0, 3.0]]; + let intercepts = solve_intercepts(&oriented, &[0, 1]).expect("solvable"); + assert!((intercepts[0] - 2.0).abs() < 1e-9, "got {:?}", intercepts); + assert!((intercepts[1] - 3.0).abs() < 1e-9, "got {:?}", intercepts); + } + + #[test] + fn solve_intercepts_singular_matrix_returns_none() { + // Two identical extremes → singular system → None. + let oriented = vec![vec![1.0, 1.0], vec![1.0, 1.0]]; + assert!(solve_intercepts(&oriented, &[0, 1]).is_none()); + } + + #[test] + fn solve_intercepts_empty_extremes_returns_none() { + let oriented: Vec> = Vec::new(); + assert!(solve_intercepts(&oriented, &[]).is_none()); + } + + #[test] + fn associate_picks_closest_reference_direction() { + // Two reference directions: the x-axis and the y-axis. + let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + // A point near the x-axis associates with reference 0; + // a point near the y-axis associates with reference 1. + let normalized = vec![vec![1.0, 0.05], vec![0.05, 1.0]]; + let (assoc, dist) = associate(&normalized, &refs, 2); + assert_eq!(assoc[0], 0); + assert_eq!(assoc[1], 1); + // Perpendicular distance from (1, 0.05) to the x-axis is 0.05. + assert!((dist[0] - 0.05).abs() < 1e-9, "dist0 = {}", dist[0]); + assert!((dist[1] - 0.05).abs() < 1e-9, "dist1 = {}", dist[1]); + } + + #[test] + fn associate_point_on_reference_line_has_zero_distance() { + let refs = vec![vec![1.0, 0.0]]; + // (3, 0) lies exactly on the x-axis direction → perp distance 0. + let normalized = vec![vec![3.0, 0.0]]; + let (assoc, dist) = associate(&normalized, &refs, 2); + assert_eq!(assoc[0], 0); + assert!(dist[0].abs() < 1e-9, "dist = {}", dist[0]); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/one_plus_one_es.rs b/src/algorithms/one_plus_one_es.rs index fc932ff..bbc4d77 100644 --- a/src/algorithms/one_plus_one_es.rs +++ b/src/algorithms/one_plus_one_es.rs @@ -345,4 +345,32 @@ mod tests { let mut opt = make_optimizer(0); let _ = opt.run(&SchafferN1); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + use crate::core::evaluation::Evaluation; + use crate::core::objective::Direction; + + #[test] + fn worse_than_feasibility_and_direction() { + let feasible = Evaluation::new(vec![100.0]); + let infeasible = Evaluation::constrained(vec![0.0], 1.0); + // infeasible is worse than feasible regardless of objective. + assert!(worse_than(&infeasible, &feasible, Direction::Minimize)); + assert!(!worse_than(&feasible, &infeasible, Direction::Minimize)); + // two feasible, minimize: larger objective is worse. + let lo = Evaluation::new(vec![1.0]); + let hi = Evaluation::new(vec![2.0]); + assert!(worse_than(&hi, &lo, Direction::Minimize)); + assert!(!worse_than(&lo, &hi, Direction::Minimize)); + // maximize inverts. + assert!(worse_than(&lo, &hi, Direction::Maximize)); + // equal → not worse. + let eq = Evaluation::new(vec![1.0]); + assert!(!worse_than(&lo, &eq, Direction::Minimize)); + // two infeasible: larger violation is worse. + let v_lo = Evaluation::constrained(vec![0.0], 0.2); + let v_hi = Evaluation::constrained(vec![0.0], 0.8); + assert!(worse_than(&v_hi, &v_lo, Direction::Minimize)); + } } diff --git a/src/algorithms/particle_swarm.rs b/src/algorithms/particle_swarm.rs index 8a9798f..1ca6752 100644 --- a/src/algorithms/particle_swarm.rs +++ b/src/algorithms/particle_swarm.rs @@ -418,4 +418,33 @@ mod tests { let mut opt = make_optimizer(0); let _ = opt.run(&SchafferN1); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + use crate::core::objective::Direction; + + #[test] + fn best_index_minimize_picks_smallest() { + let v = [3.0, 1.0, 4.0, 1.5]; + assert_eq!(best_index(&v, Direction::Minimize), 1); + } + + #[test] + fn best_index_maximize_picks_largest() { + let v = [3.0, 1.0, 4.0, 1.5]; + assert_eq!(best_index(&v, Direction::Maximize), 2); + } + + #[test] + fn best_index_keeps_first_on_tie() { + // Strict comparison → the earliest index of a tied extreme wins. + let v = [1.0, 1.0, 1.0]; + assert_eq!(best_index(&v, Direction::Minimize), 0); + assert_eq!(best_index(&v, Direction::Maximize), 0); + } + + #[test] + fn best_index_single_element() { + assert_eq!(best_index(&[42.0], Direction::Minimize), 0); + } }