feat(algorithms): add Paes (Pareto Archived Evolution Strategy)

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`.
This commit is contained in:
2026-05-04 19:23:53 -06:00
parent f17c960ec7
commit bb3a01f90e
3 changed files with 165 additions and 1 deletions
+2
View File
@@ -1,5 +1,7 @@
//! Built-in reference optimizers.
pub mod paes;
pub mod random_search;
pub use paes::*;
pub use random_search::*;
+162
View File
@@ -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<I, V> {
/// 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<I, V> Paes<I, V> {
/// Construct a `Paes` optimizer.
pub fn new(config: PaesConfig, initializer: I, variation: V) -> Self {
Self { config, initializer, variation }
}
}
impl<P, I, V> Optimizer<P> for Paes<I, V>
where
P: Problem,
I: Initializer<P::Decision>,
V: Variation<P::Decision>,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
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(&current_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, &current_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());
}
}
+1 -1
View File
@@ -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};