heuropt 0.1.0

A practical Rust toolkit for heuristic single-, multi-, and many-objective optimization.
Documentation
//! 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());
    }
}