Skip to main content

fugue_evo/algorithms/
nsga2.rs

1//! NSGA-II (Non-dominated Sorting Genetic Algorithm II)
2//!
3//! Implements the NSGA-II algorithm for multi-objective optimization.
4//!
5//! Reference: Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. (2002).
6//! A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II.
7//! IEEE Transactions on Evolutionary Computation, 6(2).
8
9use std::marker::PhantomData;
10
11use rand::Rng;
12
13use crate::error::EvoResult;
14use crate::fitness::traits::ParetoFitness;
15use crate::genome::bounds::MultiBounds;
16use crate::genome::traits::EvolutionaryGenome;
17use crate::operators::traits::{
18    BoundedCrossoverOperator, BoundedMutationOperator, CrossoverOperator, MutationOperator,
19};
20use crate::population::individual::Individual;
21
22// Moved to the always-available core (`crate::fitness::multi_objective`) so
23// the `ppl` inference layer (inference::pareto) can use it without the
24// `classic` feature; re-exported here so existing paths keep working.
25pub use crate::fitness::multi_objective::{ClosureMultiObjective, MultiObjectiveFitness};
26
27/// NSGA-II individual with objectives and crowding info
28#[derive(Clone, Debug)]
29pub struct Nsga2Individual<G: EvolutionaryGenome> {
30    /// The genome
31    pub genome: G,
32    /// Objective values
33    pub objectives: Vec<f64>,
34    /// Pareto rank (0 = first front)
35    pub rank: usize,
36    /// Crowding distance
37    pub crowding_distance: f64,
38}
39
40impl<G: EvolutionaryGenome> Nsga2Individual<G> {
41    /// Create a new individual with evaluated objectives
42    pub fn new(genome: G, objectives: Vec<f64>) -> Self {
43        Self {
44            genome,
45            objectives,
46            rank: usize::MAX,
47            crowding_distance: 0.0,
48        }
49    }
50
51    /// Check if this individual dominates another
52    /// (all objectives <= and at least one <, since we minimize)
53    pub fn dominates(&self, other: &Self) -> bool {
54        let at_least_as_good = self
55            .objectives
56            .iter()
57            .zip(other.objectives.iter())
58            .all(|(a, b)| a <= b);
59        let strictly_better = self
60            .objectives
61            .iter()
62            .zip(other.objectives.iter())
63            .any(|(a, b)| a < b);
64        at_least_as_good && strictly_better
65    }
66
67    /// Convert to ParetoFitness
68    pub fn to_pareto_fitness(&self) -> ParetoFitness {
69        let mut pf = ParetoFitness::new(self.objectives.clone());
70        pf.rank = self.rank;
71        pf.crowding_distance = self.crowding_distance;
72        pf
73    }
74
75    /// Convert to Individual with ParetoFitness
76    pub fn to_individual(self) -> Individual<G, ParetoFitness> {
77        let fitness = self.to_pareto_fitness();
78        Individual::with_fitness(self.genome, fitness)
79    }
80}
81
82/// Fast non-dominated sort
83///
84/// Returns fronts where `front[0]` is the Pareto-optimal front
85pub fn fast_non_dominated_sort<G: EvolutionaryGenome>(
86    population: &mut [Nsga2Individual<G>],
87) -> Vec<Vec<usize>> {
88    let n = population.len();
89    if n == 0 {
90        return vec![];
91    }
92
93    // domination_count[i] = number of individuals that dominate i
94    let mut domination_count = vec![0usize; n];
95    // dominated_set[i] = set of individuals that i dominates
96    let mut dominated_set: Vec<Vec<usize>> = vec![vec![]; n];
97
98    // Calculate domination relationships
99    for i in 0..n {
100        for j in (i + 1)..n {
101            if population[i].dominates(&population[j]) {
102                dominated_set[i].push(j);
103                domination_count[j] += 1;
104            } else if population[j].dominates(&population[i]) {
105                dominated_set[j].push(i);
106                domination_count[i] += 1;
107            }
108        }
109    }
110
111    // Build fronts
112    let mut fronts: Vec<Vec<usize>> = vec![];
113    let mut current_front: Vec<usize> = (0..n).filter(|&i| domination_count[i] == 0).collect();
114
115    let mut rank = 0;
116    while !current_front.is_empty() {
117        // Assign rank to current front
118        for &i in &current_front {
119            population[i].rank = rank;
120        }
121
122        // Build next front
123        let mut next_front = vec![];
124        for &i in &current_front {
125            for &j in &dominated_set[i] {
126                domination_count[j] -= 1;
127                if domination_count[j] == 0 {
128                    next_front.push(j);
129                }
130            }
131        }
132
133        fronts.push(current_front);
134        current_front = next_front;
135        rank += 1;
136    }
137
138    fronts
139}
140
141/// Calculate crowding distance for a front
142pub fn calculate_crowding_distance<G: EvolutionaryGenome>(
143    population: &mut [Nsga2Individual<G>],
144    front: &[usize],
145) {
146    let n = front.len();
147    if n <= 2 {
148        for &i in front {
149            population[i].crowding_distance = f64::INFINITY;
150        }
151        return;
152    }
153
154    // Reset distances
155    for &i in front {
156        population[i].crowding_distance = 0.0;
157    }
158
159    let num_objectives = population[front[0]].objectives.len();
160
161    for obj in 0..num_objectives {
162        // Sort front by this objective
163        let mut sorted_indices: Vec<usize> = front.to_vec();
164        sorted_indices.sort_by(|&a, &b| {
165            population[a].objectives[obj]
166                .partial_cmp(&population[b].objectives[obj])
167                .unwrap_or(std::cmp::Ordering::Equal)
168        });
169
170        // Boundary individuals get infinite distance
171        population[sorted_indices[0]].crowding_distance = f64::INFINITY;
172        population[sorted_indices[n - 1]].crowding_distance = f64::INFINITY;
173
174        // Calculate range
175        let obj_min = population[sorted_indices[0]].objectives[obj];
176        let obj_max = population[sorted_indices[n - 1]].objectives[obj];
177        let obj_range = obj_max - obj_min;
178
179        if obj_range > 0.0 {
180            for i in 1..(n - 1) {
181                let idx = sorted_indices[i];
182                let prev_val = population[sorted_indices[i - 1]].objectives[obj];
183                let next_val = population[sorted_indices[i + 1]].objectives[obj];
184                population[idx].crowding_distance += (next_val - prev_val) / obj_range;
185            }
186        }
187    }
188}
189
190/// Recompute crowding distance for every non-dominated front separately.
191///
192/// Crowding distance is only defined *within* a single front (Deb et al. 2002,
193/// Section III-B): the neighbours and the per-objective min/max range must be
194/// taken from members of the same rank. Groups `population` indices by `rank`
195/// (already assigned by [`fast_non_dominated_sort`]) and calls
196/// [`calculate_crowding_distance`] once per front.
197///
198/// Computing crowding over the whole mixed-rank population instead (the former
199/// behaviour, EV-13) interleaves individuals of different fronts, so a member's
200/// neighbours and the range come from the wrong front — corrupting the values
201/// read by binary-tournament parent selection and reported to callers.
202pub fn recompute_crowding_distance_per_front<G: EvolutionaryGenome>(
203    population: &mut [Nsga2Individual<G>],
204) {
205    use std::collections::BTreeMap;
206
207    let mut fronts: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
208    for (i, ind) in population.iter().enumerate() {
209        fronts.entry(ind.rank).or_default().push(i);
210    }
211
212    for front in fronts.values() {
213        calculate_crowding_distance(population, front);
214    }
215}
216
217/// Crowded comparison operator
218///
219/// Returns true if a is better than b (lower rank, or same rank with higher crowding distance)
220pub fn crowded_comparison<G: EvolutionaryGenome>(
221    a: &Nsga2Individual<G>,
222    b: &Nsga2Individual<G>,
223) -> bool {
224    a.rank < b.rank || (a.rank == b.rank && a.crowding_distance > b.crowding_distance)
225}
226
227/// NSGA-II algorithm
228pub struct Nsga2<G, F, C, M> {
229    /// Population size
230    pub population_size: usize,
231    /// Crossover probability
232    pub crossover_probability: f64,
233    /// Mutation probability
234    pub mutation_probability: f64,
235    /// Problem bounds
236    pub bounds: Option<MultiBounds>,
237    /// Marker for types
238    _phantom: PhantomData<(G, F, C, M)>,
239}
240
241impl<G, F, C, M> Nsga2<G, F, C, M>
242where
243    G: EvolutionaryGenome,
244    F: MultiObjectiveFitness<G>,
245    C: CrossoverOperator<G>,
246    M: MutationOperator<G>,
247{
248    /// Create a new NSGA-II algorithm
249    pub fn new(population_size: usize) -> Self {
250        Self {
251            population_size,
252            crossover_probability: 0.9,
253            mutation_probability: 1.0,
254            bounds: None,
255            _phantom: PhantomData,
256        }
257    }
258
259    /// Set crossover probability
260    pub fn with_crossover_probability(mut self, prob: f64) -> Self {
261        self.crossover_probability = prob;
262        self
263    }
264
265    /// Set mutation probability
266    pub fn with_mutation_probability(mut self, prob: f64) -> Self {
267        self.mutation_probability = prob;
268        self
269    }
270
271    /// Set bounds
272    pub fn with_bounds(mut self, bounds: MultiBounds) -> Self {
273        self.bounds = Some(bounds);
274        self
275    }
276
277    /// Initialize random population
278    pub fn initialize_population<R: Rng>(
279        &self,
280        fitness: &F,
281        bounds: &MultiBounds,
282        rng: &mut R,
283    ) -> Vec<Nsga2Individual<G>> {
284        (0..self.population_size)
285            .map(|_| {
286                let genome = G::generate(rng, bounds);
287                let objectives = fitness.evaluate(&genome);
288                Nsga2Individual::new(genome, objectives)
289            })
290            .collect()
291    }
292
293    /// Binary tournament selection with crowded comparison
294    ///
295    /// Draws two *distinct* competitors (sampling without replacement) so a
296    /// candidate never competes against itself, matching Deb's binary
297    /// tournament (EV-84). With a single individual the two competitors are
298    /// unavoidably the same.
299    pub fn tournament_select<'a, R: Rng>(
300        &self,
301        population: &'a [Nsga2Individual<G>],
302        rng: &mut R,
303    ) -> &'a Nsga2Individual<G> {
304        let len = population.len();
305        let i = rng.gen_range(0..len);
306        // Draw the second competitor from the remaining `len - 1` indices and
307        // shift past `i`, guaranteeing `j != i` without a rejection loop.
308        let j = if len > 1 {
309            let mut j = rng.gen_range(0..len - 1);
310            if j >= i {
311                j += 1;
312            }
313            j
314        } else {
315            i
316        };
317
318        if crowded_comparison(&population[i], &population[j]) {
319            &population[i]
320        } else {
321            &population[j]
322        }
323    }
324
325    /// Create offspring population
326    pub fn create_offspring<R: Rng>(
327        &self,
328        population: &[Nsga2Individual<G>],
329        fitness: &F,
330        crossover: &C,
331        mutation: &M,
332        rng: &mut R,
333    ) -> Vec<Nsga2Individual<G>> {
334        let mut offspring = Vec::with_capacity(self.population_size);
335
336        while offspring.len() < self.population_size {
337            // Select parents
338            let parent1 = self.tournament_select(population, rng);
339            let parent2 = self.tournament_select(population, rng);
340
341            // Crossover
342            let (mut child1, mut child2) = if rng.gen::<f64>() < self.crossover_probability {
343                match crossover.crossover(&parent1.genome, &parent2.genome, rng) {
344                    crate::error::OperatorResult::Success((c1, c2)) => (c1, c2),
345                    _ => (parent1.genome.clone(), parent2.genome.clone()),
346                }
347            } else {
348                (parent1.genome.clone(), parent2.genome.clone())
349            };
350
351            // Mutation
352            if rng.gen::<f64>() < self.mutation_probability {
353                mutation.mutate(&mut child1, rng);
354            }
355            if rng.gen::<f64>() < self.mutation_probability {
356                mutation.mutate(&mut child2, rng);
357            }
358
359            // Evaluate
360            let obj1 = fitness.evaluate(&child1);
361            let obj2 = fitness.evaluate(&child2);
362
363            offspring.push(Nsga2Individual::new(child1, obj1));
364            if offspring.len() < self.population_size {
365                offspring.push(Nsga2Individual::new(child2, obj2));
366            }
367        }
368
369        offspring
370    }
371
372    /// Run one generation of NSGA-II
373    pub fn step<R: Rng>(
374        &self,
375        population: &mut Vec<Nsga2Individual<G>>,
376        fitness: &F,
377        crossover: &C,
378        mutation: &M,
379        rng: &mut R,
380    ) {
381        // Create offspring
382        let offspring = self.create_offspring(population, fitness, crossover, mutation, rng);
383
384        // Combine parent and offspring populations
385        let mut combined: Vec<Nsga2Individual<G>> =
386            population.drain(..).chain(offspring.into_iter()).collect();
387
388        // Non-dominated sort
389        let fronts = fast_non_dominated_sort(&mut combined);
390
391        // Fill new population from fronts
392        let mut new_pop = Vec::with_capacity(self.population_size);
393
394        for front in fronts {
395            if new_pop.len() + front.len() <= self.population_size {
396                // Add entire front
397                for &i in &front {
398                    new_pop.push(combined[i].clone());
399                }
400            } else {
401                // Partial front - sort by crowding distance
402                calculate_crowding_distance(&mut combined, &front);
403
404                let mut sorted_front: Vec<usize> = front.to_vec();
405                sorted_front.sort_by(|&a, &b| {
406                    combined[b]
407                        .crowding_distance
408                        .partial_cmp(&combined[a].crowding_distance)
409                        .unwrap_or(std::cmp::Ordering::Equal)
410                });
411
412                let remaining = self.population_size - new_pop.len();
413                for &i in sorted_front.iter().take(remaining) {
414                    new_pop.push(combined[i].clone());
415                }
416                break;
417            }
418        }
419
420        // Update crowding distance per non-dominated front (EV-13)
421        recompute_crowding_distance_per_front(&mut new_pop);
422
423        *population = new_pop;
424    }
425
426    /// Run NSGA-II for a fixed number of generations
427    pub fn run<R: Rng>(
428        &self,
429        fitness: &F,
430        crossover: &C,
431        mutation: &M,
432        bounds: &MultiBounds,
433        max_generations: usize,
434        rng: &mut R,
435    ) -> EvoResult<Vec<Nsga2Individual<G>>> {
436        let mut population = self.initialize_population(fitness, bounds, rng);
437
438        // Initial non-dominated sort
439        fast_non_dominated_sort(&mut population);
440        recompute_crowding_distance_per_front(&mut population);
441
442        for _ in 0..max_generations {
443            self.step(&mut population, fitness, crossover, mutation, rng);
444        }
445
446        Ok(population)
447    }
448
449    /// Get the Pareto front (rank 0 individuals)
450    pub fn get_pareto_front(population: &[Nsga2Individual<G>]) -> Vec<&Nsga2Individual<G>> {
451        population.iter().filter(|ind| ind.rank == 0).collect()
452    }
453}
454
455/// Version with bounded operators
456impl<G, F, C, M> Nsga2<G, F, C, M>
457where
458    G: EvolutionaryGenome,
459    F: MultiObjectiveFitness<G>,
460    C: BoundedCrossoverOperator<G>,
461    M: BoundedMutationOperator<G>,
462{
463    /// Create offspring population with bounded operators
464    pub fn create_offspring_bounded<R: Rng>(
465        &self,
466        population: &[Nsga2Individual<G>],
467        fitness: &F,
468        crossover: &C,
469        mutation: &M,
470        bounds: &MultiBounds,
471        rng: &mut R,
472    ) -> Vec<Nsga2Individual<G>> {
473        let mut offspring = Vec::with_capacity(self.population_size);
474
475        while offspring.len() < self.population_size {
476            let parent1 = self.tournament_select(population, rng);
477            let parent2 = self.tournament_select(population, rng);
478
479            let (mut child1, mut child2) = if rng.gen::<f64>() < self.crossover_probability {
480                match crossover.crossover_bounded(&parent1.genome, &parent2.genome, bounds, rng) {
481                    crate::error::OperatorResult::Success((c1, c2)) => (c1, c2),
482                    _ => (parent1.genome.clone(), parent2.genome.clone()),
483                }
484            } else {
485                (parent1.genome.clone(), parent2.genome.clone())
486            };
487
488            if rng.gen::<f64>() < self.mutation_probability {
489                mutation.mutate_bounded(&mut child1, bounds, rng);
490            }
491            if rng.gen::<f64>() < self.mutation_probability {
492                mutation.mutate_bounded(&mut child2, bounds, rng);
493            }
494
495            let obj1 = fitness.evaluate(&child1);
496            let obj2 = fitness.evaluate(&child2);
497
498            offspring.push(Nsga2Individual::new(child1, obj1));
499            if offspring.len() < self.population_size {
500                offspring.push(Nsga2Individual::new(child2, obj2));
501            }
502        }
503
504        offspring
505    }
506
507    /// Run one generation with bounded operators
508    pub fn step_bounded<R: Rng>(
509        &self,
510        population: &mut Vec<Nsga2Individual<G>>,
511        fitness: &F,
512        crossover: &C,
513        mutation: &M,
514        bounds: &MultiBounds,
515        rng: &mut R,
516    ) {
517        let offspring =
518            self.create_offspring_bounded(population, fitness, crossover, mutation, bounds, rng);
519
520        let mut combined: Vec<Nsga2Individual<G>> =
521            population.drain(..).chain(offspring.into_iter()).collect();
522
523        let fronts = fast_non_dominated_sort(&mut combined);
524
525        let mut new_pop = Vec::with_capacity(self.population_size);
526
527        for front in fronts {
528            if new_pop.len() + front.len() <= self.population_size {
529                for &i in &front {
530                    new_pop.push(combined[i].clone());
531                }
532            } else {
533                calculate_crowding_distance(&mut combined, &front);
534
535                let mut sorted_front: Vec<usize> = front.to_vec();
536                sorted_front.sort_by(|&a, &b| {
537                    combined[b]
538                        .crowding_distance
539                        .partial_cmp(&combined[a].crowding_distance)
540                        .unwrap_or(std::cmp::Ordering::Equal)
541                });
542
543                let remaining = self.population_size - new_pop.len();
544                for &i in sorted_front.iter().take(remaining) {
545                    new_pop.push(combined[i].clone());
546                }
547                break;
548            }
549        }
550
551        // Update crowding distance per non-dominated front (EV-13)
552        recompute_crowding_distance_per_front(&mut new_pop);
553
554        *population = new_pop;
555    }
556
557    /// Run NSGA-II with bounded operators
558    pub fn run_bounded<R: Rng>(
559        &self,
560        fitness: &F,
561        crossover: &C,
562        mutation: &M,
563        bounds: &MultiBounds,
564        max_generations: usize,
565        rng: &mut R,
566    ) -> EvoResult<Vec<Nsga2Individual<G>>> {
567        let mut population = self.initialize_population(fitness, bounds, rng);
568
569        fast_non_dominated_sort(&mut population);
570        recompute_crowding_distance_per_front(&mut population);
571
572        for _ in 0..max_generations {
573            self.step_bounded(&mut population, fitness, crossover, mutation, bounds, rng);
574        }
575
576        Ok(population)
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::genome::real_vector::RealVector;
584    use crate::genome::traits::RealValuedGenome;
585    use crate::operators::crossover::SbxCrossover;
586    use crate::operators::mutation::PolynomialMutation;
587
588    // ZDT1 test problem
589    struct Zdt1;
590
591    impl MultiObjectiveFitness<RealVector> for Zdt1 {
592        fn num_objectives(&self) -> usize {
593            2
594        }
595
596        fn evaluate(&self, genome: &RealVector) -> Vec<f64> {
597            let x = genome.genes();
598            let n = x.len() as f64;
599
600            let f1 = x[0];
601
602            let g: f64 = 1.0 + 9.0 * x[1..].iter().sum::<f64>() / (n - 1.0);
603            let f2 = g * (1.0 - (f1 / g).sqrt());
604
605            vec![f1, f2]
606        }
607    }
608
609    #[test]
610    fn test_domination() {
611        let a = Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![1.0, 2.0]);
612        let b = Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![2.0, 3.0]);
613        let c = Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![1.5, 1.5]);
614
615        assert!(a.dominates(&b)); // a is better in both objectives
616        assert!(!b.dominates(&a));
617        assert!(!a.dominates(&c)); // c is better in second objective
618        assert!(!c.dominates(&a)); // a is better in first objective
619    }
620
621    #[test]
622    fn test_fast_non_dominated_sort() {
623        let mut population = vec![
624            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![1.0, 4.0]),
625            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![2.0, 3.0]),
626            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![3.0, 2.0]),
627            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![4.0, 1.0]),
628            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![3.0, 3.0]),
629        ];
630
631        let fronts = fast_non_dominated_sort(&mut population);
632
633        // First 4 should be in front 0 (none dominates each other)
634        assert_eq!(fronts[0].len(), 4);
635        // Last one should be in front 1 (dominated by [2,3] and [3,2])
636        assert_eq!(fronts[1].len(), 1);
637
638        for &i in &fronts[0] {
639            assert_eq!(population[i].rank, 0);
640        }
641        for &i in &fronts[1] {
642            assert_eq!(population[i].rank, 1);
643        }
644    }
645
646    #[test]
647    fn test_crowding_distance() {
648        let mut population = vec![
649            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![0.0, 10.0]),
650            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![5.0, 5.0]),
651            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![10.0, 0.0]),
652        ];
653
654        let front: Vec<usize> = (0..population.len()).collect();
655        calculate_crowding_distance(&mut population, &front);
656
657        // Boundary points should have infinite distance
658        assert!(population[0].crowding_distance.is_infinite());
659        assert!(population[2].crowding_distance.is_infinite());
660        // Middle point should have finite distance
661        assert!(population[1].crowding_distance.is_finite());
662        assert!(population[1].crowding_distance > 0.0);
663    }
664
665    #[test]
666    fn test_crowding_distance_per_front() {
667        // regression: EV-13
668        // Two fronts: rank-0 tradeoff front {A,B,C,D} and rank-1 singleton {E}.
669        // Deb (2002) per-front crowding: A,D = inf (front-0 boundaries),
670        // B = C = 4/3, and E = inf (sole member of its own front). The pre-fix
671        // whole-population computation instead gives E a finite value (~0.667)
672        // and shrinks the interior front-0 values.
673        let build = || {
674            vec![
675                Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![1.0, 4.0]), // A
676                Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![2.0, 3.0]), // B
677                Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![3.0, 2.0]), // C
678                Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![4.0, 1.0]), // D
679                Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![3.0, 3.0]), // E
680            ]
681        };
682
683        // Correct: per-front crowding.
684        let mut pop = build();
685        fast_non_dominated_sort(&mut pop);
686        assert_eq!(pop[0].rank, 0);
687        assert_eq!(pop[4].rank, 1, "E is dominated by B and C");
688        recompute_crowding_distance_per_front(&mut pop);
689        assert!(
690            pop[0].crowding_distance.is_infinite(),
691            "A is a front-0 boundary"
692        );
693        assert!(
694            pop[3].crowding_distance.is_infinite(),
695            "D is a front-0 boundary"
696        );
697        assert!(
698            (pop[1].crowding_distance - 4.0 / 3.0).abs() < 1e-9,
699            "B = 4/3"
700        );
701        assert!(
702            (pop[2].crowding_distance - 4.0 / 3.0).abs() < 1e-9,
703            "C = 4/3"
704        );
705        assert!(
706            pop[4].crowding_distance.is_infinite(),
707            "E is the sole member of its front and must be infinite"
708        );
709
710        // The pre-fix whole-population computation wrongly makes E finite.
711        let mut pop_all = build();
712        fast_non_dominated_sort(&mut pop_all);
713        let all: Vec<usize> = (0..pop_all.len()).collect();
714        calculate_crowding_distance(&mut pop_all, &all);
715        assert!(
716            pop_all[4].crowding_distance.is_finite(),
717            "whole-population crowding (pre-fix behaviour) makes E finite"
718        );
719    }
720
721    #[test]
722    fn test_tournament_select_draws_distinct_competitors() {
723        // regression: EV-84
724        // With a size-2 population where individual 0 strictly dominates
725        // individual 1, a distinct binary tournament ALWAYS returns the
726        // rank-0 individual. The pre-fix code drew i and j independently, so
727        // with probability 1/4 it drew i == j == 1 and returned the worse
728        // individual; over many trials that surfaces reliably.
729        use rand::SeedableRng;
730
731        let mut population = vec![
732            Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![0.0, 0.0]),
733            Nsga2Individual::<RealVector>::new(RealVector::new(vec![1.0]), vec![1.0, 1.0]),
734        ];
735        fast_non_dominated_sort(&mut population);
736        assert_eq!(population[0].rank, 0);
737        assert_eq!(population[1].rank, 1);
738
739        let nsga2: Nsga2<RealVector, Zdt1, SbxCrossover, PolynomialMutation> = Nsga2::new(2);
740        let mut rng = rand::rngs::StdRng::seed_from_u64(12345);
741        for _ in 0..2000 {
742            let selected = nsga2.tournament_select(&population, &mut rng);
743            assert_eq!(
744                selected.rank, 0,
745                "a distinct tournament must never return the dominated individual"
746            );
747        }
748    }
749
750    #[test]
751    fn test_closure_multiobjective_reports_true_count() {
752        // regression: EV-85 - closure fitness reports its true objective count.
753        let fitness = ClosureMultiObjective::new(3, |g: &RealVector| {
754            let x = g.genes()[0];
755            vec![x, x * x, x + 1.0]
756        });
757        assert_eq!(fitness.num_objectives(), 3);
758        assert_eq!(
759            fitness.evaluate(&RealVector::new(vec![2.0])),
760            vec![2.0, 4.0, 3.0]
761        );
762    }
763
764    #[test]
765    fn test_crowded_comparison() {
766        let mut a = Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![1.0, 1.0]);
767        a.rank = 0;
768        a.crowding_distance = 2.0;
769
770        let mut b = Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![2.0, 2.0]);
771        b.rank = 1;
772        b.crowding_distance = 3.0;
773
774        let mut c = Nsga2Individual::<RealVector>::new(RealVector::new(vec![0.0]), vec![1.5, 1.5]);
775        c.rank = 0;
776        c.crowding_distance = 1.0;
777
778        assert!(crowded_comparison(&a, &b)); // a has lower rank
779        assert!(crowded_comparison(&a, &c)); // same rank, a has higher crowding distance
780        assert!(!crowded_comparison(&c, &a)); // c has lower crowding distance
781    }
782
783    #[test]
784    fn test_nsga2_initialization() {
785        use crate::genome::bounds::Bounds;
786        let mut rng = rand::thread_rng();
787        let fitness = Zdt1;
788        let bounds = MultiBounds::new(vec![Bounds::new(0.0, 1.0); 10]);
789
790        let nsga2: Nsga2<RealVector, Zdt1, SbxCrossover, PolynomialMutation> = Nsga2::new(20);
791        let population = nsga2.initialize_population(&fitness, &bounds, &mut rng);
792
793        assert_eq!(population.len(), 20);
794        for ind in &population {
795            assert_eq!(ind.objectives.len(), 2);
796        }
797    }
798
799    #[test]
800    fn test_nsga2_run() {
801        use crate::genome::bounds::Bounds;
802        let mut rng = rand::thread_rng();
803        let fitness = Zdt1;
804        let bounds = MultiBounds::new(vec![Bounds::new(0.0, 1.0); 10]);
805        let crossover = SbxCrossover::new(15.0);
806        let mutation = PolynomialMutation::new(20.0);
807
808        let nsga2: Nsga2<RealVector, Zdt1, SbxCrossover, PolynomialMutation> = Nsga2::new(50);
809        let population = nsga2
810            .run_bounded(&fitness, &crossover, &mutation, &bounds, 10, &mut rng)
811            .unwrap();
812
813        assert_eq!(population.len(), 50);
814
815        // Check that we have a Pareto front
816        let pareto_front =
817            Nsga2::<RealVector, Zdt1, SbxCrossover, PolynomialMutation>::get_pareto_front(
818                &population,
819            );
820        assert!(!pareto_front.is_empty());
821    }
822
823    #[test]
824    fn test_nsga2_pareto_front_quality() {
825        use crate::genome::bounds::Bounds;
826        let mut rng = rand::thread_rng();
827        let fitness = Zdt1;
828        let bounds = MultiBounds::new(vec![Bounds::new(0.0, 1.0); 10]);
829        let crossover = SbxCrossover::new(15.0);
830        let mutation = PolynomialMutation::new(20.0);
831
832        let nsga2: Nsga2<RealVector, Zdt1, SbxCrossover, PolynomialMutation> = Nsga2::new(100);
833        let population = nsga2
834            .run_bounded(&fitness, &crossover, &mutation, &bounds, 50, &mut rng)
835            .unwrap();
836
837        let pareto_front =
838            Nsga2::<RealVector, Zdt1, SbxCrossover, PolynomialMutation>::get_pareto_front(
839                &population,
840            );
841
842        // Verify Pareto front properties
843        for ind in &pareto_front {
844            assert_eq!(ind.rank, 0);
845            // ZDT1 Pareto front has f2 = 1 - sqrt(f1)
846            // All objectives should be positive
847            assert!(ind.objectives[0] >= 0.0);
848            assert!(ind.objectives[1] >= 0.0);
849        }
850    }
851}