diff --git a/src/pareto/archive.rs b/src/pareto/archive.rs new file mode 100644 index 0000000..f07c90f --- /dev/null +++ b/src/pareto/archive.rs @@ -0,0 +1,146 @@ +//! A concrete Pareto archive that maintains an approximate non-dominated set. + +use crate::core::candidate::Candidate; +use crate::core::objective::ObjectiveSpace; +use crate::pareto::dominance::{Dominance, pareto_compare}; + +/// A growable, dominance-pruned archive of candidates. +/// +/// Built around a single concrete struct rather than a trait (spec ยง13). The +/// archive insert/extend operations maintain the non-domination property among +/// members; `truncate` enforces a maximum size by simple tail-truncation in +/// v1. +#[derive(Debug, Clone)] +pub struct ParetoArchive { + /// The current approximate non-dominated set. + pub members: Vec>, + /// The objective space used for dominance comparisons. + pub objectives: ObjectiveSpace, +} + +impl ParetoArchive { + /// Build an empty archive against the given objective space. + pub fn new(objectives: ObjectiveSpace) -> Self { + Self { members: Vec::new(), objectives } + } + + /// Insert a candidate, preserving the non-domination property. + /// + /// - If any existing member dominates the new candidate, discard it. + /// - Otherwise, drop existing members that the new candidate dominates, + /// then keep the new candidate. + pub fn insert(&mut self, candidate: Candidate) { + for m in &self.members { + if matches!( + pareto_compare(&candidate.evaluation, &m.evaluation, &self.objectives), + Dominance::DominatedBy | Dominance::Equal + ) { + return; + } + } + self.members.retain(|m| { + !matches!( + pareto_compare(&candidate.evaluation, &m.evaluation, &self.objectives), + Dominance::Dominates + ) + }); + self.members.push(candidate); + } + + /// Insert each candidate from `candidates`. + pub fn extend(&mut self, candidates: I) + where + I: IntoIterator>, + { + for c in candidates { + self.insert(c); + } + } + + /// Truncate the archive to at most `max_size` members. + /// + /// In v1 this is simple tail-truncation; future versions may use crowding + /// distance to preferentially keep diverse members. + pub fn truncate(&mut self, max_size: usize) { + if self.members.len() > max_size { + self.members.truncate(max_size); + } + } + + /// View the current members. + pub fn members(&self) -> &[Candidate] { + &self.members + } + + /// Consume the archive, returning the members. + pub fn into_vec(self) -> Vec> { + self.members + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + use crate::core::objective::Objective; + + fn space_min2() -> ObjectiveSpace { + ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), + ]) + } + + fn cand(decision: u32, obj: Vec) -> Candidate { + Candidate::new(decision, Evaluation::new(obj)) + } + + #[test] + fn dominated_insertion_is_rejected() { + let mut a = ParetoArchive::::new(space_min2()); + a.insert(cand(1, vec![1.0, 1.0])); + a.insert(cand(2, vec![2.0, 2.0])); // dominated, discarded + assert_eq!(a.members().len(), 1); + assert_eq!(a.members()[0].decision, 1); + } + + #[test] + fn dominating_insertion_evicts_existing() { + let mut a = ParetoArchive::::new(space_min2()); + a.insert(cand(1, vec![3.0, 3.0])); + a.insert(cand(2, vec![5.0, 0.0])); // non-dominated with 1, kept + a.insert(cand(3, vec![1.0, 1.0])); // dominates 1, non-dom with 2 (worse f2) + let dec: Vec = a.members().iter().map(|c| c.decision).collect(); + assert!(dec.contains(&3)); + assert!(dec.contains(&2)); + assert!(!dec.contains(&1)); + } + + #[test] + fn equal_candidate_is_treated_as_dominated() { + let mut a = ParetoArchive::::new(space_min2()); + a.insert(cand(1, vec![1.0, 1.0])); + a.insert(cand(2, vec![1.0, 1.0])); // equal, treated as already covered + assert_eq!(a.members().len(), 1); + } + + #[test] + fn truncate_simple_tail() { + let mut a = ParetoArchive::::new(space_min2()); + // Mutually non-dominated front of 4 points along a trade-off curve. + a.insert(cand(1, vec![0.0, 4.0])); + a.insert(cand(2, vec![1.0, 3.0])); + a.insert(cand(3, vec![2.0, 2.0])); + a.insert(cand(4, vec![3.0, 1.0])); + assert_eq!(a.members().len(), 4); + a.truncate(2); + assert_eq!(a.members().len(), 2); + } + + #[test] + fn extend_accepts_iterator() { + let mut a = ParetoArchive::::new(space_min2()); + a.extend(vec![cand(1, vec![1.0, 4.0]), cand(2, vec![3.0, 2.0])]); + assert_eq!(a.members().len(), 2); + } +} diff --git a/src/pareto/mod.rs b/src/pareto/mod.rs index 961c6a2..41dfa51 100644 --- a/src/pareto/mod.rs +++ b/src/pareto/mod.rs @@ -1,10 +1,12 @@ //! Pareto utilities: dominance, fronts, sorting, crowding, and an archive. +pub mod archive; pub mod crowding; pub mod dominance; pub mod front; pub mod sort; +pub use archive::*; pub use crowding::*; pub use dominance::*; pub use front::*; diff --git a/src/prelude.rs b/src/prelude.rs index ac1e85c..148677b 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -12,6 +12,6 @@ pub use crate::core::{ pub use crate::traits::{Initializer, Optimizer, Variation}; pub use crate::pareto::{ - Dominance, best_candidate, crowding_distance, non_dominated_sort, pareto_compare, - pareto_front, + Dominance, ParetoArchive, best_candidate, crowding_distance, non_dominated_sort, + pareto_compare, pareto_front, };