diff --git a/src/metrics/hypervolume.rs b/src/metrics/hypervolume.rs index c6a4449..008eafa 100644 --- a/src/metrics/hypervolume.rs +++ b/src/metrics/hypervolume.rs @@ -483,4 +483,107 @@ mod nd_tests { let hv_with = hypervolume_nd(&with_dominated, &s, &[2.0, 2.0, 2.0]); assert!((hv_base - hv_with).abs() < 1e-12, "{hv_base} vs {hv_with}"); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + /// `dominates(a, b)` is true iff `a` is ≤ `b` on every axis and strictly + /// better on at least one. Pin all the boundary cases so the `<` / `>` + /// comparison flips are caught. + #[test] + fn dominates_strict_and_boundary_cases() { + // a strictly dominates b on both axes. + assert!(dominates(&[1.0, 1.0], &[2.0, 2.0], 2)); + // b does not dominate a (reverse). + assert!(!dominates(&[2.0, 2.0], &[1.0, 1.0], 2)); + // Equal points: neither dominates (no strict improvement). + assert!(!dominates(&[1.0, 1.0], &[1.0, 1.0], 2)); + // a better on axis 0, equal on axis 1 → a dominates b. + assert!(dominates(&[1.0, 2.0], &[2.0, 2.0], 2)); + // a better on axis 0 but worse on axis 1 → no domination. + assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0], 2)); + } + + /// `non_dominated_projection` drops dominated members and keeps the + /// rest. Pin the exact retained set. + #[test] + fn non_dominated_projection_drops_dominated() { + let pts = vec![ + vec![1.0, 3.0], // non-dominated + vec![3.0, 1.0], // non-dominated + vec![2.0, 2.0], // non-dominated (trade-off) + vec![4.0, 4.0], // dominated by all three + ]; + let nd = non_dominated_projection(&pts); + assert_eq!(nd.len(), 3); + assert!(!nd.contains(&vec![4.0, 4.0])); + assert!(nd.contains(&vec![1.0, 3.0])); + assert!(nd.contains(&vec![3.0, 1.0])); + assert!(nd.contains(&vec![2.0, 2.0])); + } + + #[test] + fn non_dominated_projection_empty_input_is_empty() { + let pts: Vec> = Vec::new(); + assert!(non_dominated_projection(&pts).is_empty()); + } + + #[test] + fn non_dominated_projection_all_nondominated_keeps_all() { + let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]]; + let nd = non_dominated_projection(&pts); + assert_eq!(nd.len(), 3); + } + + /// `hso_recursive` 1-D base case: HV is `reference - min_point`, + /// clamped at 0. + #[test] + fn hso_recursive_1d_base_case() { + let pts = vec![vec![0.5], vec![1.5], vec![0.2]]; + // min is 0.2, reference is 2.0 → HV = 1.8 + assert!((hso_recursive(&pts, &[2.0]) - 1.8).abs() < 1e-12); + // A point past the reference → clamped to 0 contribution; min still 0.2. + let pts2 = vec![vec![3.0]]; + assert_eq!(hso_recursive(&pts2, &[2.0]), 0.0); + } + + /// `hso_recursive` 2-D base case: classic staircase area. + #[test] + fn hso_recursive_2d_staircase() { + // Three points (1,3), (2,2), (3,1) against reference (4,4). + // Dominated area = 6 (same as the hypervolume_2d doctest). + let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]]; + let hv = hso_recursive(&pts, &[4.0, 4.0]); + assert!((hv - 6.0).abs() < 1e-12, "hv = {hv}"); + } + + /// `hypervolume_nd_from_evaluations` returns 0 for an empty slice and a + /// positive value for a dominating point. + #[test] + fn hypervolume_nd_from_evaluations_empty_and_nonempty() { + let s = ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), + ]); + let empty: Vec<&Evaluation> = Vec::new(); + assert_eq!(hypervolume_nd_from_evaluations(&empty, &s, &[2.0, 2.0]), 0.0); + + let e = Evaluation::new(vec![1.0, 1.0]); + let evals = vec![&e]; + let hv = hypervolume_nd_from_evaluations(&evals, &s, &[2.0, 2.0]); + // Single point (1,1) vs reference (2,2) → 1×1 = 1. + assert!((hv - 1.0).abs() < 1e-12, "hv = {hv}"); + } + + /// A point that does not strictly dominate the reference contributes 0. + #[test] + fn hypervolume_nd_from_evaluations_skips_non_dominating() { + let s = ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), + ]); + // (2, 1): axis 0 equals the reference → not strictly dominating. + let e = Evaluation::new(vec![2.0, 1.0]); + let evals = vec![&e]; + assert_eq!(hypervolume_nd_from_evaluations(&evals, &s, &[2.0, 2.0]), 0.0); + } } diff --git a/src/metrics/spacing.rs b/src/metrics/spacing.rs index add9af0..9b5862a 100644 --- a/src/metrics/spacing.rs +++ b/src/metrics/spacing.rs @@ -119,4 +119,36 @@ mod tests { let s_val = spacing(&pts, &s); assert!(s_val > 0.0); } + + /// Pins the exact spacing for a front with *varying* nearest-neighbor + /// distances, exercising the `(a-b).abs()` sum, the `d < nearest` + /// comparison, and both `/ n` divisions in the mean/variance. + #[test] + fn varying_nn_distances_pinned() { + let s = space_min2(); + // (0,10), (1,9), (10,0): L1 nearest distances are 2, 2, 18. + // mean = 22/3, variance = 1536/27, spacing = sqrt(1536/27). + let front = [ + cand(vec![0.0, 10.0]), + cand(vec![1.0, 9.0]), + cand(vec![10.0, 0.0]), + ]; + let got = spacing(&front, &s); + let expected = (1536.0_f64 / 27.0).sqrt(); + assert!((got - expected).abs() < 1e-9, "got {got}, expected {expected}"); + } + + /// A perfectly even front has zero spacing — the variance term is 0. + /// Distinct from the doctest case in that it uses three points whose + /// nearest-neighbor L1 distances are all equal to 4. + #[test] + fn evenly_spaced_front_is_zero_spacing() { + let s = space_min2(); + let front = [ + cand(vec![0.0, 4.0]), + cand(vec![2.0, 2.0]), + cand(vec![4.0, 0.0]), + ]; + assert!(spacing(&front, &s) < 1e-12); + } } diff --git a/src/pareto/archive.rs b/src/pareto/archive.rs index 8c90b12..c8c6fc6 100644 --- a/src/pareto/archive.rs +++ b/src/pareto/archive.rs @@ -265,4 +265,62 @@ mod tests { a.extend(vec![cand(1, vec![1.0, 4.0]), cand(2, vec![3.0, 2.0])]); assert_eq!(a.members().len(), 2); } + + /// `truncate` keeps the archive untouched when it is already at or + /// below `max_size`, and trims it when over. Pins the `>` boundary. + #[test] + fn truncate_boundary_behavior() { + let mut a = ParetoArchive::::new(space_min2()); + // Three mutually non-dominated members. + a.insert(cand(1, vec![1.0, 3.0])); + a.insert(cand(2, vec![2.0, 2.0])); + a.insert(cand(3, vec![3.0, 1.0])); + assert_eq!(a.members().len(), 3); + // max_size == len → no-op (kills `>` → `>=`). + a.truncate(3); + assert_eq!(a.members().len(), 3); + // max_size > len → no-op. + a.truncate(10); + assert_eq!(a.members().len(), 3); + // max_size < len → trims. + a.truncate(2); + assert_eq!(a.members().len(), 2); + } + + /// A trade-off candidate (better on one axis, worse on the other) is + /// neither dominated nor dominating — it must be *added* alongside the + /// existing member. Pins the per-axis `<` / `>` scan in both + /// `member_dominates_or_equals` and `candidate_dominates_member`. + #[test] + fn trade_off_candidate_is_kept_alongside() { + let mut a = ParetoArchive::::new(space_min2()); + a.insert(cand(1, vec![1.0, 5.0])); + a.insert(cand(2, vec![5.0, 1.0])); // trade-off — must be kept + assert_eq!(a.members().len(), 2); + } + + /// An equal-objectives candidate is rejected (a member dominates-or- + /// equals it). Pins the Equal branch — distinguishes `<=` from `<` in + /// `candidate_dominates_member` and the `<=` in + /// `member_dominates_or_equals`'s infeasible branch. + #[test] + fn equal_candidate_is_rejected() { + let mut a = ParetoArchive::::new(space_min2()); + a.insert(cand(1, vec![2.0, 2.0])); + a.insert(cand(2, vec![2.0, 2.0])); // identical objectives → rejected + assert_eq!(a.members().len(), 1); + assert_eq!(a.members()[0].decision, 1); + } + + /// Two infeasible candidates: the one with smaller constraint violation + /// wins. Pins the `<` / `<=` in the infeasible branches. + #[test] + fn infeasible_candidate_with_smaller_violation_evicts_larger() { + let mut a = ParetoArchive::::new(space_min2()); + a.insert(Candidate::new(1u32, Evaluation::constrained(vec![0.0, 0.0], 1.0))); + // Smaller violation → dominates the existing infeasible member. + a.insert(Candidate::new(2u32, Evaluation::constrained(vec![9.0, 9.0], 0.5))); + assert_eq!(a.members().len(), 1); + assert_eq!(a.members()[0].decision, 2); + } } diff --git a/src/pareto/crowding.rs b/src/pareto/crowding.rs index db23ee5..aa06bce 100644 --- a/src/pareto/crowding.rs +++ b/src/pareto/crowding.rs @@ -160,4 +160,33 @@ mod tests { assert!(d[2].is_infinite()); assert!(d[1].is_finite()); } + + /// Crowding distance pins the exact interior contribution: for a 3-point + /// 2-objective front, the middle point's distance is the sum over both + /// objectives of (next - prev) / span. With evenly-spaced points the + /// value is exactly 2.0 (1.0 per objective). + #[test] + fn interior_point_distance_is_pinned() { + let s = space_min2(); + // Front along the line f1 + f2 = 4: (0,4), (2,2), (4,0). + let pop = [cand(vec![0.0, 4.0]), cand(vec![2.0, 2.0]), cand(vec![4.0, 0.0])]; + let d = crowding_distance(&pop, &[0, 1, 2], &s); + // Boundary points are infinite; the middle point gets + // (4-0)/4 + (4-0)/4 = 2.0 (objective 0 span 4, objective 1 span 4). + assert!(d[0].is_infinite()); + assert!(d[2].is_infinite()); + assert!((d[1] - 2.0).abs() < 1e-12, "interior distance = {}", d[1]); + } + + /// An asymmetric front pins the per-objective `(next - prev) / span` + /// arithmetic: catches the `-` ↔ `+`/`/` and `/` ↔ `*` mutants. + #[test] + fn asymmetric_interior_distance_is_pinned() { + let s = space_min2(); + // (0,10), (1,2), (10,0): objective-0 span = 10, objective-1 span = 10. + let pop = [cand(vec![0.0, 10.0]), cand(vec![1.0, 2.0]), cand(vec![10.0, 0.0])]; + let d = crowding_distance(&pop, &[0, 1, 2], &s); + // middle point: obj0 (10-0)/10 = 1.0; obj1 (10-0)/10 = 1.0 → 2.0. + assert!((d[1] - 2.0).abs() < 1e-12, "got {}", d[1]); + } } diff --git a/src/pareto/dominance.rs b/src/pareto/dominance.rs index 119470e..ba8aa6a 100644 --- a/src/pareto/dominance.rs +++ b/src/pareto/dominance.rs @@ -152,4 +152,35 @@ mod tests { let b = Evaluation::new(vec![2.0, 0.8]); assert_eq!(pareto_compare(&a, &b, &s), Dominance::Dominates); } + + /// `a` better on one axis, worse on the other → NonDominated. Pins the + /// `av < bv` / `av > bv` comparisons in the per-objective scan. + #[test] + fn trade_off_is_non_dominated() { + let s = space_min2(); + let a = Evaluation::new(vec![1.0, 5.0]); + let b = Evaluation::new(vec![5.0, 1.0]); + assert_eq!(pareto_compare(&a, &b, &s), Dominance::NonDominated); + assert_eq!(pareto_compare(&b, &a, &s), Dominance::NonDominated); + } + + /// `a` better on one axis, equal on the other → Dominates. This is the + /// boundary case that distinguishes `<` from `<=` in the scan. + #[test] + fn better_on_one_equal_on_other_dominates() { + let s = space_min2(); + let a = Evaluation::new(vec![1.0, 2.0]); + let b = Evaluation::new(vec![2.0, 2.0]); + assert_eq!(pareto_compare(&a, &b, &s), Dominance::Dominates); + assert_eq!(pareto_compare(&b, &a, &s), Dominance::DominatedBy); + } + + /// Identical objectives → Equal (neither `<` nor `>` ever fires). + #[test] + fn identical_objectives_are_equal() { + let s = space_min2(); + let a = Evaluation::new(vec![3.0, 3.0]); + let b = Evaluation::new(vec![3.0, 3.0]); + assert_eq!(pareto_compare(&a, &b, &s), Dominance::Equal); + } } diff --git a/src/pareto/front.rs b/src/pareto/front.rs index 9f5f7d4..1148076 100644 --- a/src/pareto/front.rs +++ b/src/pareto/front.rs @@ -180,4 +180,18 @@ mod tests { ]; assert!(best_candidate(&pop, &s).is_none()); } + + /// `best_candidate` keeps the *first* minimum on a tie — pins the strict + /// `v < best_min` (a `<=` mutant would keep the last tied candidate). + #[test] + fn best_candidate_keeps_first_on_tie() { + use crate::core::objective::Objective; + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [ + Candidate::new(1u32, Evaluation::new(vec![1.0])), + Candidate::new(2u32, Evaluation::new(vec![1.0])), + ]; + let best = best_candidate(&pop, &s).unwrap(); + assert_eq!(best.decision, 1, "should keep the first of two tied minima"); + } } diff --git a/src/pareto/sort.rs b/src/pareto/sort.rs index e1c7f32..703438a 100644 --- a/src/pareto/sort.rs +++ b/src/pareto/sort.rs @@ -227,4 +227,39 @@ mod tests { assert_eq!(f1, vec![3]); assert_eq!(f2, vec![4]); } + + /// Three mutually non-dominated points all land in front 0; a fourth + /// point dominated by all three lands in front 1. Pins the `<` / `>` + /// comparisons in the inline dominance check. + #[test] + fn three_nondominated_then_one_dominated() { + let s = space_min2(); + let pop = [ + cand(vec![1.0, 3.0]), + cand(vec![2.0, 2.0]), + cand(vec![3.0, 1.0]), + cand(vec![5.0, 5.0]), // dominated by all three + ]; + let fronts = non_dominated_sort(&pop, &s); + assert_eq!(fronts.len(), 2); + assert_eq!(fronts[0].len(), 3); + assert_eq!(fronts[1], vec![3]); + } + + /// A strict chain a ▷ b ▷ c produces three singleton fronts. Pins the + /// front-peeling `while` loop and the `&&` guard at line 127. + #[test] + fn strict_chain_produces_three_singleton_fronts() { + let s = space_min2(); + let pop = [ + cand(vec![1.0, 1.0]), // dominates everything + cand(vec![2.0, 2.0]), + cand(vec![3.0, 3.0]), + ]; + let fronts = non_dominated_sort(&pop, &s); + assert_eq!(fronts.len(), 3); + assert_eq!(fronts[0], vec![0]); + assert_eq!(fronts[1], vec![1]); + assert_eq!(fronts[2], vec![2]); + } } diff --git a/src/selection/tournament.rs b/src/selection/tournament.rs index 53975c7..ab9ef9c 100644 --- a/src/selection/tournament.rs +++ b/src/selection/tournament.rs @@ -269,4 +269,105 @@ mod tests { let mut rng = rng_from_seed(0); let _ = stochastic_ranking_select(&pop, &s, 1.5, 1, &mut rng); } + + // ---- Mutation-test pinned helpers -------------------------------------- + + fn constrained(d: u32, obj: f64, cv: f64) -> Candidate { + Candidate::new(d, Evaluation::constrained(vec![obj], cv)) + } + + #[test] + fn challenger_wins_feasibility_first() { + // Feasible challenger beats infeasible best, regardless of objective. + let feasible = cand_min(1, 100.0); + let infeasible = constrained(2, 0.0, 1.0); + assert!(challenger_wins(&feasible, &infeasible, Direction::Minimize)); + assert!(!challenger_wins(&infeasible, &feasible, Direction::Minimize)); + } + + #[test] + fn challenger_wins_two_infeasible_compares_violation() { + let less_violating = constrained(1, 0.0, 0.5); + let more_violating = constrained(2, 0.0, 1.0); + assert!(challenger_wins(&less_violating, &more_violating, Direction::Minimize)); + assert!(!challenger_wins(&more_violating, &less_violating, Direction::Minimize)); + } + + #[test] + fn challenger_wins_two_feasible_under_min_and_max() { + let lower = cand_min(1, 1.0); + let higher = cand_min(2, 2.0); + assert!(challenger_wins(&lower, &higher, Direction::Minimize)); + assert!(!challenger_wins(&higher, &lower, Direction::Minimize)); + assert!(challenger_wins(&higher, &lower, Direction::Maximize)); + assert!(!challenger_wins(&lower, &higher, Direction::Maximize)); + } + + #[test] + fn challenger_wins_equal_objectives_does_not_win() { + // Strict comparison: equal objectives → challenger does NOT win. + let a = cand_min(1, 1.0); + let b = cand_min(2, 1.0); + assert!(!challenger_wins(&a, &b, Direction::Minimize)); + assert!(!challenger_wins(&a, &b, Direction::Maximize)); + } + + #[test] + fn better_by_objective_min_and_max() { + let a = Evaluation::new(vec![1.0]); + let b = Evaluation::new(vec![2.0]); + assert!(better_by_objective(&a, &b, Direction::Minimize)); + assert!(!better_by_objective(&b, &a, Direction::Minimize)); + assert!(better_by_objective(&b, &a, Direction::Maximize)); + assert!(!better_by_objective(&a, &b, Direction::Maximize)); + // Equal → not strictly better. + let c = Evaluation::new(vec![1.0]); + assert!(!better_by_objective(&a, &c, Direction::Minimize)); + } + + #[test] + fn better_by_feasibility_all_four_branches() { + let feasible_a = Evaluation::new(vec![10.0]); + let infeasible_b = Evaluation::constrained(vec![0.0], 1.0); + // feasible vs infeasible + assert!(better_by_feasibility(&feasible_a, &infeasible_b, Direction::Minimize)); + assert!(!better_by_feasibility(&infeasible_b, &feasible_a, Direction::Minimize)); + // two infeasible: smaller violation wins + let low_cv = Evaluation::constrained(vec![0.0], 0.3); + let high_cv = Evaluation::constrained(vec![0.0], 0.9); + assert!(better_by_feasibility(&low_cv, &high_cv, Direction::Minimize)); + assert!(!better_by_feasibility(&high_cv, &low_cv, Direction::Minimize)); + // two feasible: delegates to better_by_objective + let feasible_lower = Evaluation::new(vec![1.0]); + let feasible_higher = Evaluation::new(vec![2.0]); + assert!(better_by_feasibility(&feasible_lower, &feasible_higher, Direction::Minimize)); + } + + #[test] + fn stochastic_ranking_select_pf_zero_is_pure_feasibility_order() { + // pf = 0 → always compare by feasibility. The feasible candidate + // must rank first regardless of objective value. + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [ + constrained(1, 0.0, 2.0), // infeasible, great objective + cand_min(2, 100.0), // feasible, terrible objective + ]; + let mut rng = rng_from_seed(7); + let picks = stochastic_ranking_select(&pop, &s, 0.0, 1, &mut rng); + // With pf=0, feasibility dominates → candidate 2 ranked first. + assert_eq!(picks, vec![2]); + } + + #[test] + fn stochastic_ranking_select_count_wraps_modulo_population() { + // count > population size wraps around via `order[k % n]`. + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [cand_min(1, 1.0), cand_min(2, 2.0)]; + let mut rng = rng_from_seed(0); + let picks = stochastic_ranking_select(&pop, &s, 0.0, 5, &mut rng); + assert_eq!(picks.len(), 5); + // Best (candidate 1) is at index 0; index 2 wraps to it again. + assert_eq!(picks[0], 1); + assert_eq!(picks[2], 1); + } }