diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs
index 6e584c3..cd3ad33 100644
--- a/src/algorithms/mod.rs
+++ b/src/algorithms/mod.rs
@@ -2,6 +2,7 @@
pub mod differential_evolution;
pub mod nsga2;
+pub mod nsga3;
pub mod paes;
pub(crate) mod parallel_eval;
pub mod random_search;
@@ -9,6 +10,7 @@ pub mod spea2;
pub use differential_evolution::*;
pub use nsga2::*;
+pub use nsga3::*;
pub use paes::*;
pub use random_search::*;
pub use spea2::*;
diff --git a/src/algorithms/nsga3.rs b/src/algorithms/nsga3.rs
new file mode 100644
index 0000000..3c66bc3
--- /dev/null
+++ b/src/algorithms/nsga3.rs
@@ -0,0 +1,486 @@
+//! NSGA-III — Deb & Jain 2014, the canonical many-objective MOEA.
+
+use rand::Rng as _;
+use rand::seq::IndexedRandom;
+
+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;
+use crate::core::rng::{Rng, rng_from_seed};
+use crate::pareto::front::{best_candidate, pareto_front};
+use crate::pareto::reference_points::das_dennis;
+use crate::pareto::sort::non_dominated_sort;
+use crate::traits::{Initializer, Optimizer, Variation};
+
+/// Configuration for [`Nsga3`].
+#[derive(Debug, Clone)]
+pub struct Nsga3Config {
+ /// Constant population size carried across generations.
+ pub population_size: usize,
+ /// Number of generations to run.
+ pub generations: usize,
+ /// Number of divisions `H` for Das–Dennis reference points.
+ /// Final reference set has `binomial(H + M - 1, M - 1)` points for
+ /// `M = objectives`. Typical: `H = 12` for `M = 3` (91 points),
+ /// `H = 6` for `M = 5` (210 points).
+ pub reference_divisions: usize,
+ /// Seed for the deterministic RNG.
+ pub seed: u64,
+}
+
+impl Default for Nsga3Config {
+ fn default() -> Self {
+ Self {
+ population_size: 100,
+ generations: 250,
+ reference_divisions: 12,
+ seed: 42,
+ }
+ }
+}
+
+/// NSGA-III optimizer.
+#[derive(Debug, Clone)]
+pub struct Nsga3 {
+ /// Algorithm configuration.
+ pub config: Nsga3Config,
+ /// Initial-decision sampler.
+ pub initializer: I,
+ /// Offspring-producing variation operator.
+ pub variation: V,
+}
+
+impl Nsga3 {
+ /// Construct an `Nsga3` optimizer.
+ pub fn new(config: Nsga3Config, initializer: I, variation: V) -> Self {
+ Self { config, initializer, variation }
+ }
+}
+
+impl
Optimizer
for Nsga3
+where
+ P: Problem + Sync,
+ P::Decision: Send,
+ I: Initializer,
+ V: Variation,
+{
+ fn run(&mut self, problem: &P) -> OptimizationResult {
+ assert!(
+ self.config.population_size > 0,
+ "Nsga3 population_size must be greater than 0",
+ );
+ let n = self.config.population_size;
+ let objectives = problem.objectives();
+ let m = objectives.len();
+ let reference_points = das_dennis(m, self.config.reference_divisions);
+ assert!(
+ !reference_points.is_empty(),
+ "Nsga3 reference set is empty — check reference_divisions",
+ );
+ let mut rng = rng_from_seed(self.config.seed);
+
+ // Initial population.
+ let initial_decisions = self.initializer.initialize(n, &mut rng);
+ assert_eq!(
+ initial_decisions.len(),
+ n,
+ "NSGA-III initializer must return exactly population_size decisions",
+ );
+ let mut population: Vec> =
+ evaluate_batch(problem, initial_decisions);
+ let mut evaluations = population.len();
+
+ for _ in 0..self.config.generations {
+ // --- Random parent selection + variation ---
+ let mut offspring_decisions: Vec = Vec::with_capacity(n);
+ while offspring_decisions.len() < n {
+ let p1 = rng.random_range(0..population.len());
+ let p2 = rng.random_range(0..population.len());
+ let parents =
+ vec![population[p1].decision.clone(), population[p2].decision.clone()];
+ let children = self.variation.vary(&parents, &mut rng);
+ assert!(
+ !children.is_empty(),
+ "NSGA-III variation returned no children",
+ );
+ for child_decision in children {
+ if offspring_decisions.len() >= n {
+ break;
+ }
+ offspring_decisions.push(child_decision);
+ }
+ }
+ let offspring = evaluate_batch(problem, offspring_decisions);
+ evaluations += offspring.len();
+
+ // --- Combine + survival selection ---
+ let mut combined: Vec> =
+ Vec::with_capacity(2 * n);
+ combined.extend(population.into_iter());
+ combined.extend(offspring);
+ population = environmental_selection(&combined, &objectives, &reference_points, n, &mut rng);
+ }
+
+ let front = pareto_front(&population, &objectives);
+ let best = best_candidate(&population, &objectives);
+ OptimizationResult::new(
+ Population::new(population),
+ front,
+ best,
+ evaluations,
+ self.config.generations,
+ )
+ }
+}
+
+/// NSGA-III environmental selection: front-by-front + reference-point niching
+/// on the splitting front.
+fn environmental_selection(
+ combined: &[Candidate],
+ objectives: &ObjectiveSpace,
+ reference_points: &[Vec],
+ n: usize,
+ rng: &mut Rng,
+) -> Vec> {
+ let fronts = non_dominated_sort(combined, objectives);
+ let mut selected: Vec = Vec::with_capacity(n);
+ let mut splitting: &[usize] = &[];
+ for front in &fronts {
+ if selected.len() + front.len() <= n {
+ selected.extend(front.iter().copied());
+ } else {
+ splitting = front;
+ break;
+ }
+ if selected.len() == n {
+ break;
+ }
+ }
+
+ if selected.len() == n {
+ return selected.into_iter().map(|i| combined[i].clone()).collect();
+ }
+
+ // The "working pool" is everything that might end up in the next pop:
+ // already-selected plus the splitting front. Normalization and
+ // association are computed on this pool only.
+ let mut working: Vec = selected.clone();
+ working.extend(splitting.iter().copied());
+ let normalized = normalize(combined, &working, objectives);
+ let m = objectives.len();
+ let (assoc, dist): (Vec, Vec) = associate(&normalized, reference_points, m);
+
+ // Niche counts over already-selected members only.
+ let mut niche_count = vec![0_usize; reference_points.len()];
+ for k in 0..selected.len() {
+ niche_count[assoc[k]] += 1;
+ }
+
+ // Set of reference indices still available; we won't actually drop them
+ // permanently — instead we track which references currently have any
+ // candidate in F_l associated.
+ let f_l_offset = selected.len();
+ let mut available_in_fl: Vec> = vec![Vec::new(); reference_points.len()];
+ for k in 0..splitting.len() {
+ let working_idx = f_l_offset + k;
+ available_in_fl[assoc[working_idx]].push(k); // store F_l-local index
+ }
+
+ while selected.len() < n {
+ // Find min niche count among references with at least one F_l candidate.
+ let mut min_count = usize::MAX;
+ for j in 0..reference_points.len() {
+ if !available_in_fl[j].is_empty() && niche_count[j] < min_count {
+ min_count = niche_count[j];
+ }
+ }
+ if min_count == usize::MAX {
+ // No more F_l candidates anywhere. Should not happen if we still
+ // need members, but guard anyway.
+ break;
+ }
+ let candidate_refs: Vec = (0..reference_points.len())
+ .filter(|&j| !available_in_fl[j].is_empty() && niche_count[j] == min_count)
+ .collect();
+ let &chosen_ref = candidate_refs.choose(rng).expect("non-empty by construction");
+
+ let pool = &available_in_fl[chosen_ref];
+ let pick_local = if niche_count[chosen_ref] == 0 {
+ // Take the F_l member closest to the reference direction.
+ *pool
+ .iter()
+ .min_by(|&&a, &&b| {
+ let da = dist[f_l_offset + a];
+ let db = dist[f_l_offset + b];
+ da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
+ })
+ .unwrap()
+ } else {
+ *pool.choose(rng).unwrap()
+ };
+
+ let combined_idx = splitting[pick_local];
+ selected.push(combined_idx);
+ niche_count[chosen_ref] += 1;
+
+ // Remove pick_local from available_in_fl[chosen_ref].
+ let pos = available_in_fl[chosen_ref]
+ .iter()
+ .position(|&v| v == pick_local)
+ .unwrap();
+ available_in_fl[chosen_ref].swap_remove(pos);
+ }
+
+ selected.into_iter().map(|i| combined[i].clone()).collect()
+}
+
+/// Translate by ideal, compute extreme points + intercepts, return per-member
+/// normalized objective vectors. Falls back to per-axis range when the
+/// extreme-point hyperplane is degenerate.
+fn normalize(
+ combined: &[Candidate],
+ working: &[usize],
+ objectives: &ObjectiveSpace,
+) -> Vec> {
+ let m = objectives.len();
+ let mut oriented: Vec> = working
+ .iter()
+ .map(|&i| objectives.as_minimization(&combined[i].evaluation.objectives))
+ .collect();
+
+ // Ideal point z*: per-axis min over `working`.
+ let mut ideal = vec![f64::INFINITY; m];
+ for o in &oriented {
+ for (k, &v) in o.iter().enumerate() {
+ if v < ideal[k] {
+ ideal[k] = v;
+ }
+ }
+ }
+ // Translate.
+ for o in oriented.iter_mut() {
+ for (k, v) in o.iter_mut().enumerate() {
+ *v -= ideal[k];
+ }
+ }
+
+ // Extreme points by Achievement Scalarizing Function:
+ // ASF_k(x) = max_i(x[i] / w_k[i]), w_k[i] = 1 if i==k else 1e-6
+ let extremes: Vec = (0..m)
+ .map(|axis| {
+ let mut best = 0usize;
+ let mut best_asf = f64::INFINITY;
+ for (idx, o) in oriented.iter().enumerate() {
+ let asf = o
+ .iter()
+ .enumerate()
+ .map(|(k, &v)| {
+ let w = if k == axis { 1.0 } else { 1e-6 };
+ v / w
+ })
+ .fold(f64::NEG_INFINITY, f64::max);
+ if asf < best_asf {
+ best_asf = asf;
+ best = idx;
+ }
+ }
+ best
+ })
+ .collect();
+
+ // Intercepts: solve A * a = 1 where rows of A are the extreme points.
+ // If the system is singular or yields non-positive intercepts, fall back
+ // to per-axis range (max value per axis in `oriented`).
+ let intercepts = solve_intercepts(&oriented, &extremes).unwrap_or_else(|| {
+ (0..m)
+ .map(|k| {
+ oriented
+ .iter()
+ .map(|o| o[k])
+ .fold(f64::NEG_INFINITY, f64::max)
+ .max(1e-12)
+ })
+ .collect()
+ });
+
+ for o in oriented.iter_mut() {
+ for (k, v) in o.iter_mut().enumerate() {
+ *v /= intercepts[k].max(1e-12);
+ }
+ }
+ oriented
+}
+
+/// Try to compute axis intercepts from M extreme points by Gaussian
+/// elimination. Returns `None` if singular or degenerate.
+fn solve_intercepts(oriented: &[Vec], extremes: &[usize]) -> Option> {
+ let m = extremes.len();
+ if m == 0 {
+ return None;
+ }
+ // Build the M×M matrix of extreme points (each row = one extreme).
+ let mut a: Vec> = extremes.iter().map(|&i| oriented[i].clone()).collect();
+ let mut b: Vec = vec![1.0; m];
+ // Forward elimination with partial pivoting.
+ for k in 0..m {
+ let mut pivot = k;
+ for i in (k + 1)..m {
+ if a[i][k].abs() > a[pivot][k].abs() {
+ pivot = i;
+ }
+ }
+ if a[pivot][k].abs() < 1e-12 {
+ return None;
+ }
+ a.swap(k, pivot);
+ b.swap(k, pivot);
+ for i in (k + 1)..m {
+ let factor = a[i][k] / a[k][k];
+ for j in k..m {
+ a[i][j] -= factor * a[k][j];
+ }
+ b[i] -= factor * b[k];
+ }
+ }
+ // Back-substitution.
+ let mut x = vec![0.0_f64; m];
+ for i in (0..m).rev() {
+ let mut sum = b[i];
+ for j in (i + 1)..m {
+ sum -= a[i][j] * x[j];
+ }
+ if a[i][i].abs() < 1e-12 {
+ return None;
+ }
+ x[i] = sum / a[i][i];
+ }
+ // Intercept along axis k is 1 / x[k].
+ let intercepts: Vec = x
+ .into_iter()
+ .map(|v| if v.abs() < 1e-12 { return f64::NAN } else { 1.0 / v })
+ .collect();
+ if intercepts.iter().any(|v| !v.is_finite() || *v <= 0.0) {
+ return None;
+ }
+ Some(intercepts)
+}
+
+/// Associate each normalized point with the closest reference direction by
+/// perpendicular distance. Returns parallel `(ref_index, perp_dist)` vectors.
+fn associate(
+ normalized: &[Vec],
+ reference_points: &[Vec],
+ _m: usize,
+) -> (Vec, Vec) {
+ let mut assoc = vec![0_usize; normalized.len()];
+ let mut dist = vec![0.0_f64; normalized.len()];
+ let ref_norms: Vec = reference_points
+ .iter()
+ .map(|r| r.iter().map(|v| v * v).sum::().sqrt().max(1e-12))
+ .collect();
+ for (i, x) in normalized.iter().enumerate() {
+ let mut best = 0usize;
+ let mut best_d = f64::INFINITY;
+ for (j, r) in reference_points.iter().enumerate() {
+ // Perpendicular distance from x to the line spanned by r:
+ // t = (x · r) / ||r||²
+ // d = ||x - t·r||
+ let dot: f64 = x.iter().zip(r.iter()).map(|(a, b)| a * b).sum();
+ let t = dot / (ref_norms[j] * ref_norms[j]);
+ let mut sq = 0.0_f64;
+ for (a, b) in x.iter().zip(r.iter()) {
+ let proj = t * b;
+ let diff = a - proj;
+ sq += diff * diff;
+ }
+ let d = sq.sqrt();
+ if d < best_d {
+ best_d = d;
+ best = j;
+ }
+ }
+ assoc[i] = best;
+ dist[i] = best_d;
+ }
+ (assoc, dist)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::operators::{
+ CompositeVariation, PolynomialMutation, RealBounds, SimulatedBinaryCrossover,
+ };
+ use crate::tests_support::SchafferN1;
+
+ fn make_optimizer(
+ seed: u64,
+ ) -> Nsga3<
+ RealBounds,
+ CompositeVariation,
+ > {
+ 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),
+ };
+ Nsga3::new(
+ Nsga3Config {
+ population_size: 20,
+ generations: 8,
+ reference_divisions: 12,
+ seed,
+ },
+ initializer,
+ variation,
+ )
+ }
+
+ #[test]
+ fn produces_pareto_front() {
+ let mut opt = make_optimizer(1);
+ let r = opt.run(&SchafferN1);
+ assert_eq!(r.population.len(), 20);
+ assert!(!r.pareto_front.is_empty());
+ assert_eq!(r.generations, 8);
+ }
+
+ #[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 = "population_size must be greater than 0")]
+ fn zero_population_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 = Nsga3::new(
+ Nsga3Config {
+ population_size: 0,
+ generations: 1,
+ reference_divisions: 4,
+ seed: 0,
+ },
+ initializer,
+ variation,
+ );
+ let _ = opt.run(&SchafferN1);
+ }
+}
diff --git a/src/prelude.rs b/src/prelude.rs
index 8535c1a..724a6b2 100644
--- a/src/prelude.rs
+++ b/src/prelude.rs
@@ -22,6 +22,6 @@ pub use crate::operators::{
};
pub use crate::algorithms::{
- DifferentialEvolution, DifferentialEvolutionConfig, Nsga2, Nsga2Config, Paes, PaesConfig,
- RandomSearch, RandomSearchConfig, Spea2, Spea2Config,
+ DifferentialEvolution, DifferentialEvolutionConfig, Nsga2, Nsga2Config, Nsga3,
+ Nsga3Config, Paes, PaesConfig, RandomSearch, RandomSearchConfig, Spea2, Spea2Config,
};