Skip to main content

optirs_nas/multi_objective/
nsga2.rs

1//! NSGA-II: the complete, real multi-objective optimizer implementation (non-dominated sorting, crowding distance, tournament selection, crossover/mutation).
2
3use crate::error::{OptimError, Result};
4use crate::nas_engine::{
5    MultiObjectiveConfig, OptimizationDirection, OptimizerArchitecture, SearchResult,
6};
7use scirs2_core::numeric::Float;
8use scirs2_core::random::Random;
9use std::cmp::Ordering;
10use std::fmt::Debug;
11
12use super::core::{
13    CreationMethod, Individual, MultiObjectiveOptimizer, MultiObjectiveStatistics, ObjectiveBounds,
14    ParetoFront, ParetoSolution, SolutionMetadata,
15};
16use super::hypervolume::{
17    derive_reference_point, hypervolume_minimization, normalize_front_for_minimization,
18    normalize_reference_for_minimization,
19};
20use super::metrics;
21use super::operators;
22
23/// Dominance relation between two solutions
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(super) enum DominanceRelation {
26    Dominates,
27    DominatedBy,
28    NonDominated,
29}
30/// NSGA-II implementation
31pub struct NSGA2<T: Float + Debug + Send + Sync + 'static> {
32    /// Algorithm configuration
33    pub(super) config: MultiObjectiveConfig<T>,
34    /// Current population
35    pub(super) population: Vec<Individual<T>>,
36    /// Current Pareto front
37    pub(super) pareto_front: ParetoFront<T>,
38    /// Generation counter
39    pub(super) generation: usize,
40    /// Statistics
41    pub(super) statistics: MultiObjectiveStatistics<T>,
42    /// Population size
43    pub(super) population_size: usize,
44    /// Crossover probability
45    pub(super) crossover_prob: f64,
46    /// Mutation probability
47    pub(super) mutation_prob: f64,
48    /// Random number generator
49    pub(super) rng: Random<scirs2_core::random::rngs::StdRng>,
50    /// Reference point for the hypervolume indicator, in the **raw** objective
51    /// space the configured [`ObjectiveType`]s live in (mixed directions are
52    /// allowed; the indicator normalizes internally).
53    ///
54    /// `None` until the first front is measured, at which point one is derived
55    /// from that front and **latched**. Holding it fixed is what makes the
56    /// hypervolume comparable between generations — a reference that tracks the
57    /// current front moves the box being measured. Use
58    /// [`NSGA2::set_hypervolume_reference`] to supply your own instead.
59    pub(super) hypervolume_reference: Option<Vec<T>>,
60    /// Hypervolume recorded at the previous front update, used to compute the
61    /// `convergence` metric as a genuine indicator-change rate.
62    pub(super) previous_hypervolume: Option<T>,
63}
64/// Implementation of NSGA-II
65impl<
66        T: Float
67            + Debug
68            + Default
69            + Clone
70            + Send
71            + Sync
72            + std::fmt::Debug
73            + PartialOrd
74            + std::iter::Sum,
75    > NSGA2<T>
76{
77    /// Create a new NSGA-II optimizer seeded from OS entropy.
78    ///
79    /// The RNG used to be a hard-coded `Random::seed(42)`. That was invisible while
80    /// `generate_random_architecture` returned a fixed architecture, but now that
81    /// initialization genuinely samples (F18) a fixed seed would make **every**
82    /// NSGA-II instance explore the identical initial population. Use
83    /// [`NSGA2::with_seed`] when reproducibility is wanted.
84    pub fn new(population_size: usize, crossover_prob: f64, mutation_prob: f64) -> Self {
85        Self::with_seed(
86            population_size,
87            crossover_prob,
88            mutation_prob,
89            scirs2_core::random::random::<u64>(),
90        )
91    }
92
93    /// Create a fully reproducible NSGA-II optimizer.
94    pub fn with_seed(
95        population_size: usize,
96        crossover_prob: f64,
97        mutation_prob: f64,
98        seed: u64,
99    ) -> Self {
100        Self {
101            config: MultiObjectiveConfig::default(),
102            population: Vec::new(),
103            pareto_front: ParetoFront::new(),
104            generation: 0,
105            statistics: MultiObjectiveStatistics::default(),
106            population_size,
107            crossover_prob,
108            mutation_prob,
109            rng: Random::seed(seed),
110            hypervolume_reference: None,
111            previous_hypervolume: None,
112        }
113    }
114
115    /// Pin the hypervolume reference point (raw objective space, one entry per
116    /// configured objective). Supplying it explicitly is preferable to the
117    /// derived default whenever hypervolumes from different runs are compared.
118    pub fn set_hypervolume_reference(&mut self, reference: Vec<T>) -> Result<()> {
119        if reference.len() != self.config.objectives.len() {
120            return Err(OptimError::InvalidConfig(format!(
121                "hypervolume reference has {} entries but {} objectives are configured",
122                reference.len(),
123                self.config.objectives.len()
124            )));
125        }
126        self.hypervolume_reference = Some(reference);
127        Ok(())
128    }
129
130    /// The reference point currently used by the hypervolume indicator, if one
131    /// has been supplied or derived yet.
132    pub fn hypervolume_reference(&self) -> Option<&[T]> {
133        self.hypervolume_reference.as_deref()
134    }
135
136    /// Per-objective optimization directions, in configuration order.
137    pub(super) fn objective_directions(&self) -> Vec<OptimizationDirection> {
138        self.config
139            .objectives
140            .iter()
141            .map(|objective| objective.direction.clone())
142            .collect()
143    }
144
145    /// Objective vectors of the current Pareto front, mapped into the
146    /// pure-minimization convention every indicator in [`super::metrics`] and
147    /// [`super::hypervolume`] expects.
148    pub(super) fn front_in_minimization_space(&self) -> Vec<Vec<T>> {
149        let directions = self.objective_directions();
150        let raw: Vec<Vec<T>> = self
151            .pareto_front
152            .solutions
153            .iter()
154            .map(|solution| solution.objectives.clone())
155            .collect();
156        normalize_front_for_minimization(&raw, &directions)
157    }
158    /// Initialize the population with genuinely diverse sampled architectures
159    /// (F18).
160    ///
161    /// Individuals start with an **empty** objective vector, which is the honest
162    /// representation of "not evaluated yet": the previous `vec![T::zero(); n]`
163    /// made every unevaluated individual mutually non-dominated, so the reported
164    /// "Pareto front" was the entire population. See [`NSGA2::is_evaluated`].
165    pub(super) fn initialize_population(&mut self) -> Result<()> {
166        self.population.clear();
167        for i in 0..self.population_size {
168            let architecture = self.generate_random_architecture()?;
169            let individual = Individual {
170                id: format!("ind_{}", i),
171                architecture,
172                objectives: Vec::new(),
173                constraints: Vec::new(),
174                rank: 0,
175                crowding_distance: T::zero(),
176                fitness: T::zero(),
177            };
178            self.population.push(individual);
179        }
180        Ok(())
181    }
182    /// Sample a random architecture from the optimizer-component vocabulary and
183    /// the searchable hyperparameter ranges, using this optimizer's own RNG so a
184    /// seeded NSGA-II remains reproducible.
185    pub(super) fn generate_random_architecture(&mut self) -> Result<OptimizerArchitecture<T>> {
186        Ok(operators::sample_architecture(&mut self.rng, "arch"))
187    }
188    /// Whether `individual` carries a complete objective vector, i.e. whether it
189    /// has actually been evaluated against the configured objectives. Individuals
190    /// that have not are excluded from dominance and from the Pareto front rather
191    /// than being treated as all-zero (and therefore optimal).
192    pub(super) fn is_evaluated(&self, individual: &Individual<T>) -> bool {
193        !self.config.objectives.is_empty()
194            && individual.objectives.len() == self.config.objectives.len()
195    }
196    /// Perform non-dominated sorting
197    pub(super) fn non_dominated_sort(&mut self) -> Vec<Vec<usize>> {
198        let n = self.population.len();
199        let mut fronts = Vec::new();
200        let mut domination_count = vec![0; n];
201        let mut dominated_solutions = vec![Vec::new(); n];
202        let mut first_front = Vec::new();
203        for i in 0..n {
204            for j in 0..n {
205                if i != j {
206                    let dominance = self.dominance_relation(i, j);
207                    match dominance {
208                        DominanceRelation::Dominates => {
209                            dominated_solutions[i].push(j);
210                        }
211                        DominanceRelation::DominatedBy => {
212                            domination_count[i] += 1;
213                        }
214                        DominanceRelation::NonDominated => {}
215                    }
216                }
217            }
218            if domination_count[i] == 0 {
219                self.population[i].rank = 0;
220                first_front.push(i);
221            }
222        }
223        fronts.push(first_front.clone());
224        let mut current_front = first_front;
225        let mut rank = 0;
226        while !current_front.is_empty() {
227            let mut next_front = Vec::new();
228            for &i in &current_front {
229                for &j in &dominated_solutions[i] {
230                    domination_count[j] -= 1;
231                    if domination_count[j] == 0 {
232                        self.population[j].rank = rank + 1;
233                        next_front.push(j);
234                    }
235                }
236            }
237            rank += 1;
238            current_front = next_front.clone();
239            if !next_front.is_empty() {
240                fronts.push(next_front);
241            }
242        }
243        fronts
244    }
245    /// Determine dominance relation between two individuals.
246    ///
247    /// An individual that has not been evaluated yet (see [`NSGA2::is_evaluated`])
248    /// takes part in no dominance relation at all: it neither dominates nor is
249    /// dominated, so it cannot distort the ranks of the evaluated individuals and
250    /// is filtered out of the front by
251    /// [`NSGA2::update_pareto_front_from_population`].
252    pub(super) fn dominance_relation(&self, i: usize, j: usize) -> DominanceRelation {
253        let ind_i = &self.population[i];
254        let ind_j = &self.population[j];
255        if !self.is_evaluated(ind_i) || !self.is_evaluated(ind_j) {
256            return DominanceRelation::NonDominated;
257        }
258        let mut i_dominates = false;
259        let mut j_dominates = false;
260        for k in 0..ind_i.objectives.len().min(self.config.objectives.len()) {
261            let obj_config = &self.config.objectives[k];
262            let val_i = ind_i.objectives[k];
263            let val_j = ind_j.objectives[k];
264            match obj_config.direction {
265                OptimizationDirection::Minimize => {
266                    if val_i < val_j {
267                        i_dominates = true;
268                    } else if val_i > val_j {
269                        j_dominates = true;
270                    }
271                }
272                OptimizationDirection::Maximize => {
273                    if val_i > val_j {
274                        i_dominates = true;
275                    } else if val_i < val_j {
276                        j_dominates = true;
277                    }
278                }
279            }
280        }
281        if i_dominates && !j_dominates {
282            DominanceRelation::Dominates
283        } else if j_dominates && !i_dominates {
284            DominanceRelation::DominatedBy
285        } else {
286            DominanceRelation::NonDominated
287        }
288    }
289    /// Calculate crowding distance over the evaluated members of `front`.
290    ///
291    /// Unevaluated individuals carry no objective vector, so they are excluded
292    /// here as well — indexing their (empty) objectives would otherwise panic.
293    pub(super) fn calculate_crowding_distance(&mut self, front: &[usize]) {
294        for &idx in front {
295            self.population[idx].crowding_distance = T::zero();
296        }
297        let front: Vec<usize> = front
298            .iter()
299            .copied()
300            .filter(|&idx| self.is_evaluated(&self.population[idx]))
301            .collect();
302        let front = front.as_slice();
303        let front_size = front.len();
304        if front_size == 0 {
305            return;
306        }
307        if front_size <= 2 {
308            for &idx in front {
309                self.population[idx].crowding_distance = T::infinity();
310            }
311            return;
312        }
313        let num_objectives = self.config.objectives.len();
314        for obj_idx in 0..num_objectives {
315            let mut sorted_front = front.to_vec();
316            sorted_front.sort_by(|&a, &b| {
317                self.population[a].objectives[obj_idx]
318                    .partial_cmp(&self.population[b].objectives[obj_idx])
319                    .unwrap_or(Ordering::Equal)
320            });
321            self.population[sorted_front[0]].crowding_distance = T::infinity();
322            self.population[sorted_front[front_size - 1]].crowding_distance = T::infinity();
323            let obj_min = self.population[sorted_front[0]].objectives[obj_idx];
324            let obj_max = self.population[sorted_front[front_size - 1]].objectives[obj_idx];
325            let obj_range = obj_max - obj_min;
326            if obj_range > T::zero() {
327                for i in 1..front_size - 1 {
328                    let idx = sorted_front[i];
329                    let prev_obj = self.population[sorted_front[i - 1]].objectives[obj_idx];
330                    let next_obj = self.population[sorted_front[i + 1]].objectives[obj_idx];
331                    let distance = (next_obj - prev_obj) / obj_range;
332                    self.population[idx].crowding_distance =
333                        self.population[idx].crowding_distance + distance;
334                }
335            }
336        }
337    }
338    /// Environmental selection (survival selection)
339    pub(super) fn environmental_selection(
340        &mut self,
341        combined_population: Vec<Individual<T>>,
342    ) -> Vec<Individual<T>> {
343        self.population = combined_population;
344        let fronts = self.non_dominated_sort();
345        let mut new_population = Vec::new();
346        let mut front_idx = 0;
347        while front_idx < fronts.len() {
348            let front = &fronts[front_idx];
349            if new_population.len() + front.len() <= self.population_size {
350                self.calculate_crowding_distance(front);
351                for &idx in front {
352                    new_population.push(self.population[idx].clone());
353                }
354                front_idx += 1;
355            } else {
356                self.calculate_crowding_distance(front);
357                let mut front_individuals: Vec<_> = front
358                    .iter()
359                    .map(|&idx| (idx, self.population[idx].crowding_distance))
360                    .collect();
361                front_individuals.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
362                let remaining_slots = self.population_size - new_population.len();
363                for &(idx, _) in front_individuals.iter().take(remaining_slots) {
364                    new_population.push(self.population[idx].clone());
365                }
366                break;
367            }
368        }
369        new_population
370    }
371    /// Update Pareto front from current population.
372    ///
373    /// Only rank-0 individuals that have actually been evaluated are admitted —
374    /// see [`NSGA2::is_evaluated`]. Callers must have run
375    /// [`NSGA2::non_dominated_sort`] first, otherwise every rank is still the
376    /// initial `0` and the "front" degenerates to the whole population.
377    pub(super) fn update_pareto_front_from_population(&mut self) {
378        let mut pareto_solutions = Vec::new();
379        for individual in &self.population {
380            if individual.rank == 0 && self.is_evaluated(individual) {
381                let solution = ParetoSolution {
382                    architecture: individual.architecture.clone(),
383                    objectives: individual.objectives.clone(),
384                    constraint_violations: individual.constraints.clone(),
385                    rank: individual.rank,
386                    crowding_distance: individual.crowding_distance,
387                    metadata: SolutionMetadata {
388                        id: individual.id.clone(),
389                        generation: self.generation,
390                        evaluation_count: self.statistics.total_evaluations,
391                        parents: Vec::new(),
392                        creation_method: creation_method_from_id(
393                            &individual.architecture.architecture_id,
394                        ),
395                    },
396                };
397                pareto_solutions.push(solution);
398            }
399        }
400        self.pareto_front.solutions = pareto_solutions;
401        self.pareto_front.generation = self.generation;
402        self.pareto_front.last_updated = std::time::SystemTime::now();
403        self.update_objective_bounds();
404        self.calculate_front_metrics();
405    }
406    pub(super) fn update_objective_bounds(&mut self) {
407        if self.pareto_front.solutions.is_empty() {
408            return;
409        }
410        let num_objectives = self.pareto_front.solutions[0].objectives.len();
411        let mut min_values = vec![T::infinity(); num_objectives];
412        let mut max_values = vec![T::neg_infinity(); num_objectives];
413        for solution in &self.pareto_front.solutions {
414            for (i, &obj_val) in solution.objectives.iter().enumerate() {
415                if obj_val < min_values[i] {
416                    min_values[i] = obj_val;
417                }
418                if obj_val > max_values[i] {
419                    max_values[i] = obj_val;
420                }
421            }
422        }
423        self.pareto_front.objective_bounds = ObjectiveBounds {
424            min_values: min_values.clone(),
425            max_values: max_values.clone(),
426            ideal_point: min_values,
427            nadir_point: max_values,
428        };
429    }
430    /// Compute every [`FrontMetrics`] field from the current front. All of them
431    /// are real measurements: the hypervolume is the exact indicator against a
432    /// latched reference point (F17), `convergence` is the indicator's own
433    /// change rate, and the coverage block is computed from the front's geometry
434    /// instead of the previously hardcoded `0.5` / `0`.
435    pub(super) fn calculate_front_metrics(&mut self) {
436        let front = self.front_in_minimization_space();
437        let hypervolume = self.calculate_hypervolume();
438
439        let previous = self.previous_hypervolume;
440        self.previous_hypervolume = Some(hypervolume);
441
442        let directions = self.objective_directions();
443        let reference = self
444            .hypervolume_reference
445            .as_ref()
446            .map(|raw| normalize_reference_for_minimization(raw, &directions))
447            .unwrap_or_default();
448
449        // How far the non-dominated front is from covering the whole population
450        // the optimizer has in hand; zero when it dominates all of it.
451        let population_objectives: Vec<Vec<T>> = normalize_front_for_minimization(
452            &self
453                .population
454                .iter()
455                .map(|individual| individual.objectives.clone())
456                .collect::<Vec<_>>(),
457            &directions,
458        );
459
460        self.pareto_front.metrics = metrics::front_metrics_in_minimization_space(
461            &front,
462            &population_objectives,
463            &reference,
464            hypervolume,
465            previous,
466        );
467        let convergence = self.pareto_front.metrics.convergence;
468        let spread = self.pareto_front.metrics.spread;
469        self.statistics.pareto_front_size = self.pareto_front.solutions.len();
470        // `best_hypervolume` is the best value ever seen, not merely the latest.
471        if hypervolume > self.statistics.best_hypervolume {
472            self.statistics.best_hypervolume = hypervolume;
473        }
474        self.statistics.generation = self.generation;
475        self.statistics.convergence_history.push(convergence);
476        self.statistics.diversity_history.push(spread);
477    }
478    /// Exact hypervolume of the current Pareto front (F17).
479    ///
480    /// The front is mapped into the pure-minimization convention (so `Maximize`
481    /// objectives are handled correctly) and measured against the reference
482    /// point, which is derived from the first front seen and then held fixed.
483    /// This replaces the previous "bounding-box product times solution count"
484    /// heuristic, which was neither a hypervolume nor monotone.
485    pub(super) fn calculate_hypervolume(&mut self) -> T {
486        let front = self.front_in_minimization_space();
487        if front.is_empty() {
488            return T::zero();
489        }
490        let directions = self.objective_directions();
491
492        if self.hypervolume_reference.is_none() {
493            // Derive in minimization space, then store back in raw space so the
494            // public accessor speaks the caller's objective convention.
495            let Some(derived) = derive_reference_point(&front) else {
496                return T::zero();
497            };
498            self.hypervolume_reference =
499                Some(normalize_reference_for_minimization(&derived, &directions));
500        }
501        let Some(raw_reference) = self.hypervolume_reference.as_ref() else {
502            return T::zero();
503        };
504        let reference = normalize_reference_for_minimization(raw_reference, &directions);
505        hypervolume_minimization(&front, &reference)
506    }
507}
508impl<
509        T: Float
510            + Debug
511            + Default
512            + Clone
513            + Send
514            + Sync
515            + std::fmt::Debug
516            + PartialOrd
517            + std::iter::Sum,
518    > NSGA2<T>
519{
520    /// Map a configured [`ObjectiveType`] onto the [`EvaluationMetric`] used to
521    /// read its value from an [`EvaluationResults`]. Kept identical to the
522    /// mapping used by [`MultiObjectiveOptimizer::update_pareto_front`] so the
523    /// helpers below agree with the main optimization path.
524    pub(super) fn objective_vector_for_result(&self, result: &SearchResult<T>) -> Vec<T> {
525        super::core::objective_vector_for_result(&self.config.objectives, result)
526    }
527    /// Load `results` into the population (one individual per result), run the
528    /// complete NSGA-II non-dominated sort and crowding-distance assignment,
529    /// and return the indices (into `results`) of the best `k` solutions
530    /// ordered by ascending rank then descending crowding distance.
531    ///
532    /// This drives the engine-level multi-objective selection by reusing the
533    /// real NSGA-II machinery rather than re-deriving dominance externally.
534    pub(crate) fn select_by_rank_and_crowding(
535        &mut self,
536        results: &[SearchResult<T>],
537        k: usize,
538    ) -> Vec<usize> {
539        if results.is_empty() || k == 0 {
540            return Vec::new();
541        }
542        self.population = results
543            .iter()
544            .enumerate()
545            .map(|(i, result)| Individual {
546                architecture: result.architecture.clone(),
547                objectives: self.objective_vector_for_result(result),
548                constraints: Vec::new(),
549                rank: 0,
550                crowding_distance: T::zero(),
551                fitness: T::zero(),
552                id: format!("sel_{}", i),
553            })
554            .collect();
555        let fronts = self.non_dominated_sort();
556        for front in &fronts {
557            self.calculate_crowding_distance(front);
558        }
559        let mut order: Vec<usize> = (0..self.population.len()).collect();
560        order.sort_by(|&a, &b| {
561            let rank_a = self.population[a].rank;
562            let rank_b = self.population[b].rank;
563            rank_a.cmp(&rank_b).then_with(|| {
564                self.population[b]
565                    .crowding_distance
566                    .partial_cmp(&self.population[a].crowding_distance)
567                    .unwrap_or(Ordering::Equal)
568            })
569        });
570        order.truncate(k.min(self.population.len()));
571        order
572    }
573    /// Build a Pareto front from `results` using the complete NSGA-II pipeline:
574    /// load one individual per result, run the non-dominated sort to assign
575    /// dominance ranks, assign crowding distances within each front, and then
576    /// collect the rank-0 (non-dominated) solutions together with objective
577    /// bounds and front metrics.
578    ///
579    /// This is the faithful counterpart to the trait-level
580    /// [`MultiObjectiveOptimizer::update_pareto_front`], but it performs the
581    /// non-dominated sort that the index-mapped trait method omits, so the
582    /// returned front contains exactly the non-dominated solutions.
583    pub(crate) fn pareto_front_from_results(
584        &mut self,
585        results: &[SearchResult<T>],
586    ) -> ParetoFront<T> {
587        self.population = results
588            .iter()
589            .enumerate()
590            .map(|(i, result)| {
591                let id = if result.architecture.architecture_id.is_empty() {
592                    format!("ind_{}", i)
593                } else {
594                    result.architecture.architecture_id.clone()
595                };
596                Individual {
597                    architecture: result.architecture.clone(),
598                    objectives: self.objective_vector_for_result(result),
599                    constraints: Vec::new(),
600                    rank: 0,
601                    crowding_distance: T::zero(),
602                    fitness: T::zero(),
603                    id,
604                }
605            })
606            .collect();
607        self.generation += 1;
608        self.statistics.total_evaluations += results.len();
609        let fronts = self.non_dominated_sort();
610        for front in &fronts {
611            self.calculate_crowding_distance(front);
612        }
613        self.update_pareto_front_from_population();
614        self.pareto_front.clone()
615    }
616    /// Compute the mean pairwise Euclidean distance between the objective
617    /// vectors of `results`, providing a real diversity metric for the
618    /// population. Returns `0.0` for fewer than two solutions.
619    pub(crate) fn mean_objective_distance(&self, results: &[SearchResult<T>]) -> f64 {
620        let objective_vectors: Vec<Vec<T>> = results
621            .iter()
622            .map(|r| self.objective_vector_for_result(r))
623            .collect();
624        metrics::mean_pairwise_distance(&objective_vectors)
625    }
626    pub(super) fn tournament_selection(&mut self, tournamentsize: usize) -> Result<Individual<T>> {
627        if self.population.is_empty() {
628            return Err(OptimError::InvalidConfig("Empty population".to_string()));
629        }
630        let mut best_idx = self.rng.gen_range(0..self.population.len());
631        for _ in 1..tournamentsize {
632            let idx = self.rng.gen_range(0..self.population.len());
633            if self.population[idx].rank < self.population[best_idx].rank
634                || (self.population[idx].rank == self.population[best_idx].rank
635                    && self.population[idx].crowding_distance
636                        > self.population[best_idx].crowding_distance)
637            {
638                best_idx = idx;
639            }
640        }
641        Ok(self.population[best_idx].clone())
642    }
643    /// Uniform crossover over the component sequence **and** the numeric
644    /// hyperparameters (F18). The offspring's objectives are cleared, because an
645    /// offspring has not been evaluated — carrying the parent's objectives over
646    /// (as the previous `parent1.clone()` did) would have let an unevaluated
647    /// architecture claim its parent's front position.
648    pub(super) fn crossover(
649        &mut self,
650        parent1: &Individual<T>,
651        parent2: &Individual<T>,
652    ) -> Result<Individual<T>> {
653        let architecture = operators::crossover_architectures(
654            &mut self.rng,
655            &parent1.architecture,
656            &parent2.architecture,
657        );
658        Ok(Individual {
659            id: architecture.architecture_id.clone(),
660            architecture,
661            objectives: Vec::new(),
662            constraints: Vec::new(),
663            rank: 0,
664            crowding_distance: T::zero(),
665            fitness: T::zero(),
666        })
667    }
668    /// Mutate an individual's architecture — components as well as
669    /// hyperparameters (F18), with the numeric jitter clamped to each
670    /// hyperparameter's declared range instead of the previous unbounded
671    /// `+/-0.05` additive noise (which could drive a learning rate negative).
672    pub(super) fn mutate(&mut self, individual: &mut Individual<T>) -> Result<()> {
673        let changed = operators::mutate_architecture(
674            &mut self.rng,
675            &mut individual.architecture,
676            NSGA2_COMPONENT_MUTATION_RATE,
677            NSGA2_PARAMETER_MUTATION_RATE,
678        );
679        if changed {
680            // A mutated architecture is a different candidate: its recorded
681            // objectives no longer describe it.
682            individual.objectives.clear();
683            individual.id = individual.architecture.architecture_id.clone();
684        }
685        Ok(())
686    }
687}
688
689/// Per-component-position probability of a structural mutation.
690pub(super) const NSGA2_COMPONENT_MUTATION_RATE: f64 = 0.2;
691
692/// Per-parameter probability of a numeric mutation.
693pub(super) const NSGA2_PARAMETER_MUTATION_RATE: f64 = 0.3;
694
695/// Recover how an architecture was created from the prefix the variation
696/// operators stamp onto its identifier. Reporting the actual provenance is more
697/// useful than the previous unconditional
698/// [`CreationMethod::RandomGeneration`].
699fn creation_method_from_id(architecture_id: &str) -> CreationMethod {
700    if architecture_id.starts_with("offspring_") {
701        CreationMethod::Crossover
702    } else if architecture_id.starts_with("mutant_") {
703        CreationMethod::Mutation
704    } else if architecture_id.starts_with("arch_") {
705        CreationMethod::RandomGeneration
706    } else {
707        CreationMethod::Custom
708    }
709}
710impl<
711        T: Float
712            + Debug
713            + Default
714            + Clone
715            + Send
716            + Sync
717            + std::fmt::Debug
718            + PartialOrd
719            + std::iter::Sum,
720    > MultiObjectiveOptimizer<T> for NSGA2<T>
721{
722    fn initialize(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
723        self.config = config.clone();
724        self.initialize_population()?;
725        Ok(())
726    }
727    /// Absorb `results` into the population and rebuild the Pareto front (F18).
728    ///
729    /// Results are matched to population members by `architecture_id`, not by
730    /// position: the previous index-based mapping silently pasted the *i*-th
731    /// result's objectives onto the *i*-th individual even though evaluation order
732    /// need not match population order, so objectives and architectures could be
733    /// attributed to the wrong candidate. Results whose architecture is not in the
734    /// population are appended (they are genuinely new evaluated candidates).
735    ///
736    /// The full non-dominated sort and crowding-distance assignment now run here
737    /// too. Without them every rank stayed `0` and the returned "Pareto front"
738    /// was the entire population.
739    fn update_pareto_front(&mut self, results: &[SearchResult<T>]) -> Result<ParetoFront<T>> {
740        for result in results {
741            let objectives = self.objective_vector_for_result(result);
742            let architecture_id = result.architecture.architecture_id.as_str();
743            let existing = if architecture_id.is_empty() {
744                None
745            } else {
746                self.population
747                    .iter()
748                    .position(|ind| ind.architecture.architecture_id == architecture_id)
749            };
750            match existing {
751                Some(index) => {
752                    self.population[index].objectives = objectives;
753                    self.population[index].architecture = result.architecture.clone();
754                }
755                None => {
756                    // Prefer an unevaluated slot so the population does not grow
757                    // without bound when architecture ids are not carried through.
758                    let vacancy = self
759                        .population
760                        .iter()
761                        .position(|ind| !self.is_evaluated(ind));
762                    let id = if architecture_id.is_empty() {
763                        format!("ind_{}", self.population.len())
764                    } else {
765                        architecture_id.to_string()
766                    };
767                    match vacancy {
768                        Some(index) => {
769                            self.population[index].objectives = objectives;
770                            self.population[index].architecture = result.architecture.clone();
771                            self.population[index].id = id;
772                        }
773                        None => self.population.push(Individual {
774                            id,
775                            architecture: result.architecture.clone(),
776                            objectives,
777                            constraints: Vec::new(),
778                            rank: 0,
779                            crowding_distance: T::zero(),
780                            fitness: T::zero(),
781                        }),
782                    }
783                }
784            }
785        }
786        // NSGA-II's survivor selection, which was implemented but never called.
787        // Without it the population grew by one individual for every
788        // never-before-seen architecture, for the whole run: it stopped being a
789        // population of `population_size` at all, and both the memory it occupies
790        // and the O(n^2) non-dominated sort below scaled with the number of
791        // evaluations instead. `environmental_selection` truncates the combined
792        // parent+offspring pool back to `population_size` by rank, breaking ties on
793        // crowding distance — the (mu + lambda) step of the published algorithm.
794        //
795        // Only reachable once every slot holds an evaluated individual: the absorb
796        // loop above fills unevaluated vacancies first and only appends when there
797        // are none, so truncation never discards a candidate that has not been
798        // measured.
799        if self.population.len() > self.population_size {
800            let combined = std::mem::take(&mut self.population);
801            self.population = self.environmental_selection(combined);
802        }
803
804        self.generation += 1;
805        self.statistics.total_evaluations += results.len();
806        let fronts = self.non_dominated_sort();
807        for front in &fronts {
808            self.calculate_crowding_distance(front);
809        }
810        self.update_pareto_front_from_population();
811        Ok(self.pareto_front.clone())
812    }
813    fn get_pareto_front(&self) -> &ParetoFront<T> {
814        &self.pareto_front
815    }
816    fn select_candidates(
817        &mut self,
818        _population: &[OptimizerArchitecture<T>],
819        _objectives: &[T],
820    ) -> Result<Vec<OptimizerArchitecture<T>>> {
821        let mut new_population = Vec::new();
822        for _ in 0..self.population_size {
823            let parent1 = self.tournament_selection(2)?;
824            let parent2 = self.tournament_selection(2)?;
825            let mut offspring = if self.rng.gen_range(0.0..1.0) < self.crossover_prob {
826                self.crossover(&parent1, &parent2)?
827            } else {
828                parent1.clone()
829            };
830            if self.rng.gen_range(0.0..1.0) < self.mutation_prob {
831                self.mutate(&mut offspring)?;
832            }
833            new_population.push(offspring.architecture);
834        }
835        Ok(new_population)
836    }
837    fn name(&self) -> &str {
838        "NSGA-II"
839    }
840    fn get_statistics(&self) -> MultiObjectiveStatistics<T> {
841        self.statistics.clone()
842    }
843}