From bb3a01f90e4b1c5aca57eafe89e471435a060de1 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 19:23:29 -0600 Subject: [PATCH] feat(algorithms): add Paes (Pareto Archived Evolution Strategy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A readable v1 PAES (spec §12.2): - Single starting decision from the initializer. - Each iteration mutates the current decision via the Variation operator, evaluates the child, and pareto_compares to the current. - Dominating children become current; for non-dominated comparisons we move to the child (acceptable v1 behavior per spec). - Both current and child are inserted into a ParetoArchive truncated to `archive_size` (simple tail-truncation in v1). The final result returns the archive as both `population` and `pareto_front`. Tests verify the archive never exceeds `archive_size`. --- src/algorithms/mod.rs | 2 + src/algorithms/paes.rs | 162 +++++++++++++++++++++++++++++++++++++++++ src/prelude.rs | 2 +- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/algorithms/paes.rs diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index 373701c..76e15a8 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -1,5 +1,7 @@ //! Built-in reference optimizers. +pub mod paes; pub mod random_search; +pub use paes::*; pub use random_search::*; diff --git a/src/algorithms/paes.rs b/src/algorithms/paes.rs new file mode 100644 index 0000000..110c7f3 --- /dev/null +++ b/src/algorithms/paes.rs @@ -0,0 +1,162 @@ +//! Pareto Archived Evolution Strategy — a small (1+1)-with-archive optimizer. + +use crate::core::candidate::Candidate; +use crate::core::population::Population; +use crate::core::problem::Problem; +use crate::core::result::OptimizationResult; +use crate::core::rng::rng_from_seed; +use crate::pareto::archive::ParetoArchive; +use crate::pareto::dominance::{Dominance, pareto_compare}; +use crate::pareto::front::{best_candidate, pareto_front}; +use crate::traits::{Initializer, Optimizer, Variation}; + +/// Configuration for [`Paes`]. +#[derive(Debug, Clone)] +pub struct PaesConfig { + /// Number of mutation iterations. + pub iterations: usize, + /// Maximum size of the Pareto archive. + pub archive_size: usize, + /// Seed for the deterministic RNG. + pub seed: u64, +} + +impl Default for PaesConfig { + fn default() -> Self { + Self { iterations: 1000, archive_size: 100, seed: 42 } + } +} + +/// A simple Pareto Archived Evolution Strategy. +/// +/// One current candidate, one mutation per iteration, one bounded archive. +/// Intentionally a readable baseline rather than a research-perfect PAES +/// (spec §12.2). +#[derive(Debug, Clone)] +pub struct Paes { + /// Algorithm configuration. + pub config: PaesConfig, + /// How the initial decision is sampled. + pub initializer: I, + /// How children are produced from the current decision. + pub variation: V, +} + +impl Paes { + /// Construct a `Paes` optimizer. + pub fn new(config: PaesConfig, initializer: I, variation: V) -> Self { + Self { config, initializer, variation } + } +} + +impl Optimizer

for Paes +where + P: Problem, + I: Initializer, + V: Variation, +{ + fn run(&mut self, problem: &P) -> OptimizationResult { + assert!( + self.config.archive_size > 0, + "PAES archive_size must be greater than 0", + ); + + let objectives = problem.objectives(); + let mut rng = rng_from_seed(self.config.seed); + + let mut initial = self.initializer.initialize(1, &mut rng); + assert!( + !initial.is_empty(), + "PAES initializer returned no decisions", + ); + let mut current_decision = initial.remove(0); + let mut current_eval = problem.evaluate(¤t_decision); + let mut evaluations = 1usize; + + let mut archive = ParetoArchive::new(objectives.clone()); + archive.insert(Candidate::new(current_decision.clone(), current_eval.clone())); + + for _ in 0..self.config.iterations { + let parents = vec![current_decision.clone()]; + let children = self.variation.vary(&parents, &mut rng); + assert!( + !children.is_empty(), + "PAES variation returned no children", + ); + let child_decision = children.into_iter().next().unwrap(); + let child_eval = problem.evaluate(&child_decision); + evaluations += 1; + + match pareto_compare(&child_eval, ¤t_eval, &objectives) { + Dominance::Dominates => { + current_decision = child_decision.clone(); + current_eval = child_eval.clone(); + } + Dominance::DominatedBy => { + // Stay at current. + } + Dominance::NonDominated | Dominance::Equal => { + // v1: move to child on non-dominated comparison. + current_decision = child_decision.clone(); + current_eval = child_eval.clone(); + } + } + + archive.insert(Candidate::new(child_decision, child_eval)); + archive.insert(Candidate::new(current_decision.clone(), current_eval.clone())); + archive.truncate(self.config.archive_size); + } + + let members = archive.into_vec(); + let front = pareto_front(&members, &objectives); + let best = best_candidate(&members, &objectives); + OptimizationResult::new( + Population::new(members), + front, + best, + evaluations, + self.config.iterations, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::operators::{GaussianMutation, RealBounds}; + use crate::tests_support::{SchafferN1, Sphere1D}; + + #[test] + fn produces_at_least_one_candidate() { + let mut opt = Paes::new( + PaesConfig { iterations: 50, archive_size: 16, seed: 1 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ); + let r = opt.run(&SchafferN1); + assert!(!r.population.is_empty()); + assert!(!r.pareto_front.is_empty()); + } + + #[test] + fn archive_size_respected() { + let mut opt = Paes::new( + PaesConfig { iterations: 200, archive_size: 8, seed: 2 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.2 }, + ); + let r = opt.run(&SchafferN1); + assert!(r.population.len() <= 8); + } + + #[test] + fn single_objective_returns_best() { + let mut opt = Paes::new( + PaesConfig { iterations: 200, archive_size: 8, seed: 3 }, + RealBounds::new(vec![(-2.0, 2.0)]), + GaussianMutation { sigma: 0.1 }, + ); + let r = opt.run(&Sphere1D); + assert!(r.best.is_some()); + } +} diff --git a/src/prelude.rs b/src/prelude.rs index 51ad9e1..a3cb6c6 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -18,4 +18,4 @@ pub use crate::pareto::{ pub use crate::operators::{BitFlipMutation, GaussianMutation, RealBounds, SwapMutation}; -pub use crate::algorithms::{RandomSearch, RandomSearchConfig}; +pub use crate::algorithms::{Paes, PaesConfig, RandomSearch, RandomSearchConfig};