diff --git a/src/pareto/front.rs b/src/pareto/front.rs new file mode 100644 index 0000000..2dba9f7 --- /dev/null +++ b/src/pareto/front.rs @@ -0,0 +1,152 @@ +//! Compute the non-dominated front of a population, and the single-objective best. + +use crate::core::candidate::Candidate; +use crate::core::objective::ObjectiveSpace; +use crate::pareto::dominance::{Dominance, pareto_compare}; + +/// Return all candidates that are not dominated by any other candidate. +/// +/// O(N²·M) in v1 (spec §9.3). Input order is preserved among returned +/// candidates. +pub fn pareto_front( + population: &[Candidate], + objectives: &ObjectiveSpace, +) -> Vec> { + let mut out = Vec::new(); + 'outer: for (i, a) in population.iter().enumerate() { + for (j, b) in population.iter().enumerate() { + if i == j { + continue; + } + if matches!( + pareto_compare(&a.evaluation, &b.evaluation, objectives), + Dominance::DominatedBy + ) { + continue 'outer; + } + } + out.push(a.clone()); + } + out +} + +/// Return the best candidate for a single-objective problem. +/// +/// Returns `None` if there is not exactly one objective, if the population is +/// empty, or if every candidate is infeasible (spec §9.4). +pub fn best_candidate( + population: &[Candidate], + objectives: &ObjectiveSpace, +) -> Option> { + if !objectives.is_single_objective() { + return None; + } + + let mut best: Option<&Candidate> = None; + let mut best_min: f64 = f64::INFINITY; + for c in population { + if !c.evaluation.is_feasible() { + continue; + } + let m = objectives.as_minimization(&c.evaluation.objectives); + let v = m.first().copied().unwrap_or(f64::INFINITY); + if best.is_none() || v < best_min { + best = Some(c); + best_min = v; + } + } + best.cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + use crate::core::objective::Objective; + + fn cand(decision: u32, obj: Vec) -> Candidate { + Candidate::new(decision, Evaluation::new(obj)) + } + + fn space_min2() -> ObjectiveSpace { + ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), + ]) + } + + #[test] + fn empty_population_returns_empty_front() { + let s = space_min2(); + let front = pareto_front::(&[], &s); + assert!(front.is_empty()); + } + + #[test] + fn single_candidate_is_its_own_front() { + let s = space_min2(); + let pop = [cand(1, vec![1.0, 2.0])]; + let front = pareto_front(&pop, &s); + assert_eq!(front.len(), 1); + assert_eq!(front[0].decision, 1); + } + + #[test] + fn dominated_points_removed_non_dominated_kept() { + let s = space_min2(); + let pop = [ + cand(1, vec![1.0, 4.0]), // non-dominated + cand(2, vec![3.0, 2.0]), // non-dominated + cand(3, vec![5.0, 5.0]), // dominated by 1 and 2 + cand(4, vec![2.0, 3.0]), // non-dominated + ]; + let front = pareto_front(&pop, &s); + let kept: Vec = front.iter().map(|c| c.decision).collect(); + assert_eq!(kept, vec![1, 2, 4]); + } + + #[test] + fn best_candidate_none_when_multi_objective() { + let s = space_min2(); + let pop = [cand(1, vec![1.0, 1.0])]; + assert!(best_candidate(&pop, &s).is_none()); + } + + #[test] + fn best_candidate_returns_min_for_minimize() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [cand(1, vec![3.0]), cand(2, vec![1.0]), cand(3, vec![2.0])]; + let best = best_candidate(&pop, &s).unwrap(); + assert_eq!(best.decision, 2); + } + + #[test] + fn best_candidate_returns_max_for_maximize() { + let s = ObjectiveSpace::new(vec![Objective::maximize("score")]); + let pop = [cand(1, vec![3.0]), cand(2, vec![5.0]), cand(3, vec![2.0])]; + let best = best_candidate(&pop, &s).unwrap(); + assert_eq!(best.decision, 2); + } + + #[test] + fn best_candidate_skips_infeasible() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [ + Candidate::new(1u32, Evaluation::constrained(vec![0.0], 5.0)), // infeasible + Candidate::new(2u32, Evaluation::new(vec![10.0])), + Candidate::new(3u32, Evaluation::new(vec![3.0])), + ]; + let best = best_candidate(&pop, &s).unwrap(); + assert_eq!(best.decision, 3); + } + + #[test] + fn best_candidate_none_when_all_infeasible() { + let s = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop = [ + Candidate::new(1u32, Evaluation::constrained(vec![0.0], 1.0)), + Candidate::new(2u32, Evaluation::constrained(vec![0.0], 2.0)), + ]; + assert!(best_candidate(&pop, &s).is_none()); + } +} diff --git a/src/pareto/mod.rs b/src/pareto/mod.rs index da68292..6966ada 100644 --- a/src/pareto/mod.rs +++ b/src/pareto/mod.rs @@ -1,5 +1,7 @@ //! Pareto utilities: dominance, fronts, sorting, crowding, and an archive. pub mod dominance; +pub mod front; pub use dominance::*; +pub use front::*; diff --git a/src/prelude.rs b/src/prelude.rs index 67d8534..4e64132 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -11,4 +11,4 @@ pub use crate::core::{ pub use crate::traits::{Initializer, Optimizer, Variation}; -pub use crate::pareto::{Dominance, pareto_compare}; +pub use crate::pareto::{Dominance, best_candidate, pareto_compare, pareto_front};