diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs
index b2840c1..9182557 100644
--- a/src/algorithms/mod.rs
+++ b/src/algorithms/mod.rs
@@ -13,6 +13,7 @@ pub mod nsga2;
pub mod nsga3;
pub mod paes;
pub(crate) mod parallel_eval;
+pub mod pesa2;
pub mod particle_swarm;
pub mod random_search;
pub mod rvea;
@@ -35,6 +36,7 @@ pub use nsga2::*;
pub use nsga3::*;
pub use paes::*;
pub use particle_swarm::*;
+pub use pesa2::*;
pub use random_search::*;
pub use rvea::*;
pub use simulated_annealing::*;
diff --git a/src/algorithms/pesa2.rs b/src/algorithms/pesa2.rs
new file mode 100644
index 0000000..be870a7
--- /dev/null
+++ b/src/algorithms/pesa2.rs
@@ -0,0 +1,324 @@
+//! `PesaII` — Corne, Jerram, Knowles & Oates 2001 Pareto Envelope-based
+//! Selection Algorithm II.
+
+use std::collections::BTreeMap;
+
+use rand::Rng as _;
+
+use crate::core::candidate::Candidate;
+use crate::core::objective::ObjectiveSpace;
+use crate::core::population::Population;
+use crate::core::problem::Problem;
+use crate::core::result::OptimizationResult;
+use crate::core::rng::{Rng, rng_from_seed};
+use crate::pareto::archive::ParetoArchive;
+use crate::pareto::front::{best_candidate, pareto_front};
+use crate::traits::{Initializer, Optimizer, Variation};
+
+/// Configuration for [`PesaII`].
+#[derive(Debug, Clone)]
+pub struct PesaIIConfig {
+ /// Internal population size (used for variation).
+ pub population_size: usize,
+ /// External non-dominated archive cap.
+ pub archive_size: usize,
+ /// Number of generations.
+ pub generations: usize,
+ /// Number of grid divisions per objective axis.
+ pub grid_divisions: usize,
+ /// Seed for the deterministic RNG.
+ pub seed: u64,
+}
+
+impl Default for PesaIIConfig {
+ fn default() -> Self {
+ Self {
+ population_size: 50,
+ archive_size: 100,
+ generations: 250,
+ grid_divisions: 16,
+ seed: 42,
+ }
+ }
+}
+
+/// Pareto Envelope-based Selection Algorithm II.
+///
+/// Maintains an internal population (used to drive variation) and an
+/// external non-dominated archive. Selection biases toward members in
+/// sparsely-populated grid boxes so the front spreads out.
+#[derive(Debug, Clone)]
+pub struct PesaII {
+ /// Algorithm configuration.
+ pub config: PesaIIConfig,
+ /// Initial-decision sampler.
+ pub initializer: I,
+ /// Offspring-producing variation operator.
+ pub variation: V,
+}
+
+impl PesaII {
+ /// Construct a `PesaII`.
+ pub fn new(config: PesaIIConfig, initializer: I, variation: V) -> Self {
+ Self { config, initializer, variation }
+ }
+}
+
+impl
Optimizer
for PesaII
+where
+ P: Problem + Sync,
+ P::Decision: Send,
+ I: Initializer,
+ V: Variation,
+{
+ fn run(&mut self, problem: &P) -> OptimizationResult {
+ assert!(self.config.population_size > 0, "PesaII population_size must be > 0");
+ assert!(self.config.archive_size > 0, "PesaII archive_size must be > 0");
+ assert!(self.config.grid_divisions >= 1, "PesaII grid_divisions must be >= 1");
+ let n = self.config.population_size;
+ let objectives = problem.objectives();
+ let mut rng = rng_from_seed(self.config.seed);
+
+ // Initial internal population.
+ let initial_decisions = self.initializer.initialize(n, &mut rng);
+ let mut internal: Vec> = initial_decisions
+ .into_iter()
+ .map(|d| {
+ let e = problem.evaluate(&d);
+ Candidate::new(d, e)
+ })
+ .collect();
+ let mut evaluations = internal.len();
+
+ // External archive.
+ let mut archive = ParetoArchive::new(objectives.clone());
+ for c in &internal {
+ archive.insert(c.clone());
+ }
+ truncate_by_grid(&mut archive, self.config.archive_size, self.config.grid_divisions);
+
+ for _ in 0..self.config.generations {
+ // Build grid + box counts on the archive.
+ let (boxes, counts) = build_grid(&archive, &objectives, self.config.grid_divisions);
+
+ // Generate offspring via region-based selection on the archive.
+ let mut offspring: Vec> = Vec::with_capacity(n);
+ while offspring.len() < n {
+ let p1 = region_tournament(&archive, &boxes, &counts, &mut rng);
+ let p2 = region_tournament(&archive, &boxes, &counts, &mut rng);
+ let parents = vec![archive.members()[p1].decision.clone(), archive.members()[p2].decision.clone()];
+ let children = self.variation.vary(&parents, &mut rng);
+ assert!(!children.is_empty(), "PesaII variation returned no children");
+ for child in children {
+ if offspring.len() >= n {
+ break;
+ }
+ let eval = problem.evaluate(&child);
+ evaluations += 1;
+ offspring.push(Candidate::new(child, eval));
+ }
+ }
+
+ // Internal pop becomes the offspring; archive gets every
+ // non-dominated offspring.
+ for c in &offspring {
+ archive.insert(c.clone());
+ }
+ truncate_by_grid(&mut archive, self.config.archive_size, self.config.grid_divisions);
+ internal = offspring;
+ }
+
+ let _ = internal; // not directly returned
+ 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.generations,
+ )
+ }
+}
+
+/// Compute per-member box index (M-tuple of grid coordinates) and the
+/// population count of each occupied box.
+fn build_grid(
+ archive: &ParetoArchive,
+ objectives: &ObjectiveSpace,
+ divisions: usize,
+) -> (Vec>, BTreeMap, usize>) {
+ let m = objectives.len();
+ let members = archive.members();
+ if members.is_empty() {
+ return (Vec::new(), BTreeMap::new());
+ }
+ let oriented: Vec> = members
+ .iter()
+ .map(|c| objectives.as_minimization(&c.evaluation.objectives))
+ .collect();
+ let mut lo = vec![f64::INFINITY; m];
+ let mut hi = vec![f64::NEG_INFINITY; m];
+ for o in &oriented {
+ for k in 0..m {
+ if o[k] < lo[k] {
+ lo[k] = o[k];
+ }
+ if o[k] > hi[k] {
+ hi[k] = o[k];
+ }
+ }
+ }
+ let mut boxes: Vec> = Vec::with_capacity(members.len());
+ for o in &oriented {
+ let mut box_idx = Vec::with_capacity(m);
+ for k in 0..m {
+ let span = (hi[k] - lo[k]).max(1e-12);
+ let frac = ((o[k] - lo[k]) / span).clamp(0.0, 1.0 - 1e-9);
+ box_idx.push((frac * divisions as f64) as usize);
+ }
+ boxes.push(box_idx);
+ }
+ let mut counts: BTreeMap, usize> = BTreeMap::new();
+ for b in &boxes {
+ *counts.entry(b.clone()).or_insert(0) += 1;
+ }
+ (boxes, counts)
+}
+
+/// Pick a member by region-based tournament: take two random members,
+/// prefer the one whose grid box is less crowded.
+fn region_tournament(
+ archive: &ParetoArchive,
+ boxes: &[Vec],
+ counts: &BTreeMap, usize>,
+ rng: &mut Rng,
+) -> usize {
+ let n = archive.members().len();
+ let a = rng.random_range(0..n);
+ let b = rng.random_range(0..n);
+ let ca = counts.get(&boxes[a]).copied().unwrap_or(1);
+ let cb = counts.get(&boxes[b]).copied().unwrap_or(1);
+ if ca < cb {
+ a
+ } else if cb < ca {
+ b
+ } else if rng.random_bool(0.5) {
+ a
+ } else {
+ b
+ }
+}
+
+/// Truncate the archive to `max_size` by repeatedly evicting a uniform-random
+/// member of the most-occupied grid box (PESA-II's standard approach).
+fn truncate_by_grid(
+ archive: &mut ParetoArchive,
+ max_size: usize,
+ divisions: usize,
+) {
+ while archive.members().len() > max_size {
+ let objectives = archive.objectives.clone();
+ let (boxes, counts) = build_grid(archive, &objectives, divisions);
+ // Find the most-crowded box.
+ let max_count = counts.values().copied().max().unwrap_or(0);
+ if max_count <= 1 {
+ // No crowding to break: just truncate.
+ archive.truncate(max_size);
+ break;
+ }
+ // Indices in that box.
+ let crowded_box = counts
+ .iter()
+ .find(|&(_, &c)| c == max_count)
+ .map(|(b, _)| b.clone())
+ .unwrap();
+ let candidates: Vec = boxes
+ .iter()
+ .enumerate()
+ .filter(|(_, b)| **b == crowded_box)
+ .map(|(i, _)| i)
+ .collect();
+ // Use a fixed seed-derived RNG would be ideal, but truncation is
+ // called from the main RNG indirectly; use a deterministic pick
+ // (the first candidate) to avoid sneaking nondeterminism in.
+ let evict = *candidates.first().expect("non-empty crowded box");
+ archive.members.swap_remove(evict);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::operators::{
+ CompositeVariation, PolynomialMutation, RealBounds, SimulatedBinaryCrossover,
+ };
+ use crate::tests_support::SchafferN1;
+
+ fn make_optimizer(
+ seed: u64,
+ ) -> PesaII> {
+ let bounds = vec![(-5.0, 5.0)];
+ let initializer = RealBounds::new(bounds.clone());
+ let variation = CompositeVariation {
+ crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
+ mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
+ };
+ PesaII::new(
+ PesaIIConfig {
+ population_size: 20,
+ archive_size: 30,
+ generations: 15,
+ grid_divisions: 8,
+ seed,
+ },
+ initializer,
+ variation,
+ )
+ }
+
+ #[test]
+ fn produces_pareto_front() {
+ let mut opt = make_optimizer(1);
+ let r = opt.run(&SchafferN1);
+ assert!(!r.pareto_front.is_empty());
+ }
+
+ #[test]
+ fn deterministic_with_same_seed() {
+ let mut a = make_optimizer(99);
+ let mut b = make_optimizer(99);
+ let ra = a.run(&SchafferN1);
+ let rb = b.run(&SchafferN1);
+ let oa: Vec> =
+ ra.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect();
+ let ob: Vec> =
+ rb.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect();
+ assert_eq!(oa, ob);
+ }
+
+ #[test]
+ #[should_panic(expected = "archive_size must be > 0")]
+ fn zero_archive_size_panics() {
+ let bounds = vec![(0.0, 1.0)];
+ let initializer = RealBounds::new(bounds.clone());
+ let variation = CompositeVariation {
+ crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
+ mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
+ };
+ let mut opt = PesaII::new(
+ PesaIIConfig {
+ population_size: 4,
+ archive_size: 0,
+ generations: 1,
+ grid_divisions: 4,
+ seed: 0,
+ },
+ initializer,
+ variation,
+ );
+ let _ = opt.run(&SchafferN1);
+ }
+
+}
diff --git a/src/algorithms/rvea.rs b/src/algorithms/rvea.rs
index 7a1b821..06a29a1 100644
--- a/src/algorithms/rvea.rs
+++ b/src/algorithms/rvea.rs
@@ -4,7 +4,6 @@ use rand::Rng as _;
use crate::algorithms::parallel_eval::evaluate_batch;
use crate::core::candidate::Candidate;
-use crate::core::objective::ObjectiveSpace;
use crate::core::population::Population;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
@@ -250,9 +249,6 @@ fn smallest_neighbor_angle(references: &[Vec]) -> f64 {
if !min_angle.is_finite() { std::f64::consts::FRAC_PI_4 } else { min_angle }
}
-#[allow(unused_imports)]
-use crate::core::objective::Objective;
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/algorithms/sms_emoa.rs b/src/algorithms/sms_emoa.rs
index 857be1a..eaec59f 100644
--- a/src/algorithms/sms_emoa.rs
+++ b/src/algorithms/sms_emoa.rs
@@ -8,7 +8,7 @@ use crate::core::objective::ObjectiveSpace;
use crate::core::population::Population;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
-use crate::core::rng::{Rng, rng_from_seed};
+use crate::core::rng::rng_from_seed;
use crate::metrics::hypervolume::hypervolume_nd_from_evaluations;
use crate::pareto::front::{best_candidate, pareto_front};
use crate::pareto::sort::non_dominated_sort;
@@ -264,7 +264,3 @@ mod tests {
}
}
-// Allow the unused-import warning from the `rng` import if certain feature
-// combinations don't use it.
-#[allow(dead_code)]
-fn _force_rng_use(_rng: &mut Rng) {}
diff --git a/src/metrics/hypervolume.rs b/src/metrics/hypervolume.rs
index 1105590..5f35d75 100644
--- a/src/metrics/hypervolume.rs
+++ b/src/metrics/hypervolume.rs
@@ -1,9 +1,8 @@
//! Exact 2D and N-D hypervolume against a fixed reference point.
use crate::core::candidate::Candidate;
-use crate::core::objective::ObjectiveSpace;
-use crate::pareto::dominance::{Dominance, pareto_compare};
use crate::core::evaluation::Evaluation;
+use crate::core::objective::ObjectiveSpace;
/// Compute the dominated hypervolume of a 2D front against `reference_point`.
///
@@ -418,8 +417,7 @@ mod nd_tests {
let _ = hypervolume_nd(&front, &s, &[1.0, 1.0, 1.0]);
}
- /// Sanity test: pareto_compare and hypervolume_nd should agree on
- /// the simple "fewer non-dominated points → less HV" intuition.
+ /// Sanity test: dominated points shouldn't increase HV.
#[test]
fn nd_dominated_points_dont_increase_hv() {
let s = ObjectiveSpace::new(vec![
@@ -433,15 +431,6 @@ mod nd_tests {
with_dominated.push(cand_n(vec![1.5, 1.5, 1.5]));
let hv_base = hypervolume_nd(&base, &s, &[2.0, 2.0, 2.0]);
let hv_with = hypervolume_nd(&with_dominated, &s, &[2.0, 2.0, 2.0]);
- // Confirm that adding the dominated point really is dominated.
- assert!(matches!(
- pareto_compare(
- &Evaluation::new(vec![1.5, 1.5, 1.5]),
- &Evaluation::new(vec![0.0, 1.0, 1.0]),
- &s,
- ),
- Dominance::DominatedBy,
- ));
assert!((hv_base - hv_with).abs() < 1e-12, "{hv_base} vs {hv_with}");
}
}
diff --git a/src/prelude.rs b/src/prelude.rs
index 4720687..6b9768a 100644
--- a/src/prelude.rs
+++ b/src/prelude.rs
@@ -25,7 +25,8 @@ pub use crate::algorithms::{
AntColonyTsp, AntColonyTspConfig, CmaEs, CmaEsConfig, DifferentialEvolution,
DifferentialEvolutionConfig,
GeneticAlgorithm, GeneticAlgorithmConfig, HillClimber, HillClimberConfig, Hype,
- HypeConfig, Ibea, IbeaConfig, Moead, MoeadConfig, Mopso, MopsoConfig, Nsga2, Nsga2Config, Nsga3, Nsga3Config, Paes, PaesConfig, ParticleSwarm,
+ HypeConfig, Ibea, IbeaConfig, Moead, MoeadConfig, Mopso, MopsoConfig, Nsga2,
+ Nsga2Config, Nsga3, Nsga3Config, Paes, PaesConfig, ParticleSwarm, PesaII, PesaIIConfig,
ParticleSwarmConfig, RandomSearch, RandomSearchConfig, Rvea, RveaConfig,
SimulatedAnnealing,
SimulatedAnnealingConfig, SmsEmoa, SmsEmoaConfig, Spea2, Spea2Config, TabuSearch,