diff --git a/src/algorithms/paes.rs b/src/algorithms/paes.rs index 04a5e7c..39551a7 100644 --- a/src/algorithms/paes.rs +++ b/src/algorithms/paes.rs @@ -305,4 +305,26 @@ mod tests { let r = opt.run(&Sphere1D); assert!(r.best.is_some()); } + + /// PAES must return a non-empty Pareto archive on a 2-objective problem + /// and be deterministic with a fixed seed. Pins the run-loop + /// bookkeeping against degenerate / comparison mutants. + #[test] + fn produces_deterministic_nonempty_front() { + let make = || { + Paes::new( + PaesConfig { iterations: 40, archive_size: 10, seed: 5 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ) + }; + let r1 = make().run(&SchafferN1); + let r2 = make().run(&SchafferN1); + assert!(!r1.pareto_front.is_empty()); + let f1: Vec> = r1.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); + // Archive never exceeds its configured cap. + assert!(r1.pareto_front.len() <= 10); + } } diff --git a/src/algorithms/pesa2.rs b/src/algorithms/pesa2.rs index daf7d61..94110ba 100644 --- a/src/algorithms/pesa2.rs +++ b/src/algorithms/pesa2.rs @@ -499,4 +499,67 @@ 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 space2() -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + + #[test] + fn build_grid_empty_archive_is_empty() { + let archive = ParetoArchive::::new(space2()); + let (boxes, counts) = build_grid(&archive, &space2(), 4); + assert!(boxes.is_empty()); + assert!(counts.is_empty()); + } + + #[test] + fn build_grid_assigns_corner_points_to_distinct_boxes() { + let mut archive = ParetoArchive::::new(space2()); + // Three non-dominated corner points span the grid extremes. + archive.insert(Candidate::new(1u32, Evaluation::new(vec![0.0, 4.0]))); + archive.insert(Candidate::new(2u32, Evaluation::new(vec![2.0, 2.0]))); + archive.insert(Candidate::new(3u32, Evaluation::new(vec![4.0, 0.0]))); + let (boxes, counts) = build_grid(&archive, &space2(), 4); + assert_eq!(boxes.len(), 3); + // The min and max corners land in different boxes — total count + // across all boxes equals the member count. + let total: usize = counts.values().sum(); + assert_eq!(total, 3); + // The two extreme points are in different boxes (grid spreads them). + assert_ne!(boxes[0], boxes[2]); + } + + #[test] + fn region_tournament_prefers_less_crowded_box() { + use crate::core::rng::rng_from_seed; + // Members 0 and 1 share a crowded box (count 2); member 2 is alone. + let mut archive = ParetoArchive::::new(space2()); + archive.insert(Candidate::new(1u32, Evaluation::new(vec![0.0, 4.0]))); + archive.insert(Candidate::new(2u32, Evaluation::new(vec![2.0, 2.0]))); + archive.insert(Candidate::new(3u32, Evaluation::new(vec![4.0, 0.0]))); + // Hand-build boxes/counts where index 2 is in a singleton box and + // indices 0,1 share a crowded box. + let boxes = vec![vec![0usize, 0], vec![0usize, 0], vec![3usize, 3]]; + let mut counts = std::collections::BTreeMap::new(); + counts.insert(vec![0usize, 0], 2usize); + counts.insert(vec![3usize, 3], 1usize); + // Across many seeds, the less-crowded index (2) must win whenever + // the two random draws differ between the crowded/uncrowded boxes. + let mut picked_uncrowded = 0; + for seed in 0..300 { + let mut rng = rng_from_seed(seed); + if region_tournament(&archive, &boxes, &counts, &mut rng) == 2 { + picked_uncrowded += 1; + } + } + // Index 2 wins whenever it's drawn against 0 or 1, plus half its + // self-draws — clear majority. + assert!(picked_uncrowded > 150, "uncrowded picked {picked_uncrowded}/300"); + } } diff --git a/src/algorithms/random_search.rs b/src/algorithms/random_search.rs index a13a31c..4b0d2fc 100644 --- a/src/algorithms/random_search.rs +++ b/src/algorithms/random_search.rs @@ -218,4 +218,23 @@ mod tests { let r = opt.run(&Sphere1D); assert!(r.best.is_some()); } + + /// RandomSearch's evaluation count is exactly `iterations * batch_size`, + /// and the returned best is no worse than every sampled candidate. + #[test] + fn best_is_no_worse_than_any_sample() { + let mut opt = RandomSearch::new( + RandomSearchConfig { iterations: 50, batch_size: 2, seed: 9 }, + RealBounds::new(vec![(-3.0, 3.0)]), + ); + let r = opt.run(&Sphere1D); + assert_eq!(r.evaluations, 100); + let best = r.best.unwrap().evaluation.objectives[0]; + let pop_min = r + .population + .iter() + .map(|c| c.evaluation.objectives[0]) + .fold(f64::INFINITY, f64::min); + assert!(best <= pop_min + 1e-12, "best {best} > pop min {pop_min}"); + } } diff --git a/src/algorithms/sms_emoa.rs b/src/algorithms/sms_emoa.rs index 58df323..166708d 100644 --- a/src/algorithms/sms_emoa.rs +++ b/src/algorithms/sms_emoa.rs @@ -400,4 +400,51 @@ 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 sms_space() -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + fn sms_cand(o: Vec) -> Candidate { + Candidate::new(0, Evaluation::new(o)) + } + + /// `pick_drop_index` drops the member of the worst front with the + /// smallest hypervolume contribution. With one clearly-dominated point + /// in the pool, that point forms a singleton worst front and is + /// returned directly. + #[test] + fn pick_drop_index_returns_singleton_worst_front() { + // (1,1) and (2,2)-trade-offs are front 0; (9,9) is dominated → the + // sole member of front 1. + let pool = vec![ + sms_cand(vec![1.0, 3.0]), + sms_cand(vec![3.0, 1.0]), + sms_cand(vec![9.0, 9.0]), // dominated — worst front, singleton + ]; + let drop = pick_drop_index(&pool, &sms_space(), &[100.0, 100.0]); + assert_eq!(drop, 2, "should drop the dominated singleton"); + } + + /// When the worst front has multiple members, the one with the + /// smallest hypervolume contribution is dropped — and the scan must + /// find it even at a non-zero index. Here `(1.0, 9.0)` at index 1 is + /// "shadowed" by its near-neighbour `(1.5, 8.5)` and contributes the + /// least unique HV (≈ 0.5 vs ≈ 3.75 and ≈ 7.5). + #[test] + fn pick_drop_index_drops_least_hv_contributor() { + // All three mutually non-dominated → single (worst) front. + let pool = vec![ + sms_cand(vec![1.5, 8.5]), + sms_cand(vec![1.0, 9.0]), // least HV contribution → drop target + sms_cand(vec![9.0, 1.0]), + ]; + let drop = pick_drop_index(&pool, &sms_space(), &[10.0, 10.0]); + assert_eq!(drop, 1, "should drop the lowest-HV-contribution member"); + } }