Skip to main content

sklears_multioutput/optimization/
evolutionary_multi_objective.rs

1//! Evolutionary Multi-Objective Optimization Algorithms
2//!
3//! This module implements advanced evolutionary algorithms for multi-objective optimization
4//! problems, particularly suited for multi-output learning scenarios where multiple
5//! conflicting objectives need to be optimized simultaneously.
6
7// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
8use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
9use scirs2_core::random::Rng;
10use sklears_core::{
11    error::{Result as SklResult, SklearsError},
12    types::Float,
13};
14use std::cmp::Ordering;
15
16/// Individual in the evolutionary algorithm population
17#[derive(Debug, Clone)]
18pub struct Individual {
19    /// Decision variables (parameters to optimize)
20    pub variables: Array1<Float>,
21    /// Objective function values for this individual
22    pub objectives: Array1<Float>,
23    /// Non-dominated rank (lower is better)
24    pub rank: usize,
25    /// Crowding distance (higher is better for diversity)
26    pub crowding_distance: Float,
27}
28
29impl Individual {
30    /// Create a new individual with given variables
31    pub fn new(variables: Array1<Float>) -> Self {
32        Self {
33            variables,
34            objectives: Array1::zeros(0), // Will be evaluated later
35            rank: 0,
36            crowding_distance: 0.0,
37        }
38    }
39
40    /// Check if this individual dominates another
41    pub fn dominates(&self, other: &Individual) -> bool {
42        let mut at_least_one_better = false;
43
44        for i in 0..self.objectives.len() {
45            if self.objectives[i] > other.objectives[i] {
46                return false; // This individual is worse in at least one objective
47            }
48            if self.objectives[i] < other.objectives[i] {
49                at_least_one_better = true;
50            }
51        }
52
53        at_least_one_better
54    }
55}
56
57/// NSGA-II (Non-dominated Sorting Genetic Algorithm II) implementation
58///
59/// A state-of-the-art evolutionary algorithm for multi-objective optimization
60/// that maintains a diverse set of Pareto-optimal solutions.
61///
62/// # Examples
63///
64/// ```text
65/// use sklears_multioutput::optimization::evolutionary_multi_objective::NSGAII;
66/// use scirs2_core::ndarray::array;
67///
68/// let objectives = |x: &ArrayView1<f64>| {
69///     let obj1 = x[0].powi(2) + x[1].powi(2);
70///     let obj2 = (x[0] - 1.0).powi(2) + (x[1] - 1.0).powi(2);
71///     array![obj1, obj2]
72/// };
73///
74/// let nsga2 = NSGAII::new()
75///     .population_size(100)
76///     .n_generations(50)
77///     .crossover_probability(0.9)
78///     .mutation_probability(0.1)
79///     .variable_bounds(vec![(-2.0, 2.0), (-2.0, 2.0)])
80///     .random_state(42);
81///
82/// let result = nsga2.optimize(objectives, 2).unwrap();
83/// assert!(result.pareto_front().len() > 0);
84/// ```
85#[derive(Debug, Clone)]
86pub struct NSGAII {
87    /// Size of the population
88    population_size: usize,
89    /// Number of generations to evolve
90    n_generations: usize,
91    /// Probability of crossover
92    crossover_probability: Float,
93    /// Probability of mutation
94    mutation_probability: Float,
95    /// Bounds for each decision variable (min, max)
96    variable_bounds: Vec<(Float, Float)>,
97    /// Random state for reproducibility
98    random_state: Option<u64>,
99    /// Crossover distribution index
100    crossover_eta: Float,
101    /// Mutation distribution index
102    mutation_eta: Float,
103}
104
105impl Default for NSGAII {
106    fn default() -> Self {
107        Self {
108            population_size: 100,
109            n_generations: 100,
110            crossover_probability: 0.9,
111            mutation_probability: 0.1,
112            variable_bounds: Vec::new(),
113            random_state: None,
114            crossover_eta: 20.0,
115            mutation_eta: 20.0,
116        }
117    }
118}
119
120impl NSGAII {
121    /// Create a new NSGA-II optimizer with default parameters
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Set the population size
127    pub fn population_size(mut self, size: usize) -> Self {
128        self.population_size = size;
129        self
130    }
131
132    /// Set the number of generations
133    pub fn n_generations(mut self, generations: usize) -> Self {
134        self.n_generations = generations;
135        self
136    }
137
138    /// Set the crossover probability
139    pub fn crossover_probability(mut self, prob: Float) -> Self {
140        self.crossover_probability = prob;
141        self
142    }
143
144    /// Set the mutation probability
145    pub fn mutation_probability(mut self, prob: Float) -> Self {
146        self.mutation_probability = prob;
147        self
148    }
149
150    /// Set bounds for decision variables
151    pub fn variable_bounds(mut self, bounds: Vec<(Float, Float)>) -> Self {
152        self.variable_bounds = bounds;
153        self
154    }
155
156    /// Set random state for reproducibility
157    pub fn random_state(mut self, seed: u64) -> Self {
158        self.random_state = Some(seed);
159        self
160    }
161
162    /// Set crossover distribution index (higher values = more uniform crossover)
163    pub fn crossover_eta(mut self, eta: Float) -> Self {
164        self.crossover_eta = eta;
165        self
166    }
167
168    /// Set mutation distribution index (higher values = smaller mutations)
169    pub fn mutation_eta(mut self, eta: Float) -> Self {
170        self.mutation_eta = eta;
171        self
172    }
173
174    /// Optimize a multi-objective function using NSGA-II
175    pub fn optimize<F>(&self, objective_fn: F, n_objectives: usize) -> SklResult<OptimizationResult>
176    where
177        F: Fn(&ArrayView1<Float>) -> Array1<Float>,
178    {
179        if self.variable_bounds.is_empty() {
180            return Err(SklearsError::InvalidInput(
181                "Variable bounds must be specified".to_string(),
182            ));
183        }
184
185        let n_variables = self.variable_bounds.len();
186        let mut rng = if let Some(seed) = self.random_state {
187            scirs2_core::random::seeded_rng(seed)
188        } else {
189            scirs2_core::random::seeded_rng(42)
190        };
191
192        // Initialize population
193        let mut population = self.initialize_population(&mut rng, n_variables)?;
194
195        // Evaluate initial population
196        for individual in &mut population {
197            individual.objectives = objective_fn(&individual.variables.view());
198        }
199
200        let mut generation_stats = Vec::new();
201
202        // Evolution loop
203        for _generation in 0..self.n_generations {
204            // Create offspring through crossover and mutation
205            let offspring = self.create_offspring(&population, &mut rng, n_variables)?;
206
207            // Combine parent and offspring populations
208            let mut combined_population = population;
209            combined_population.extend(offspring);
210
211            // Evaluate new individuals
212            for individual in &mut combined_population {
213                if individual.objectives.len() != n_objectives {
214                    individual.objectives = objective_fn(&individual.variables.view());
215                }
216            }
217
218            // Perform non-dominated sorting
219            let fronts = self.non_dominated_sort(&combined_population);
220
221            // Select next generation using NSGA-II selection
222            population = self.environmental_selection(combined_population, fronts)?;
223
224            // Record generation statistics
225            let stats = self.calculate_generation_stats(&population);
226            generation_stats.push(stats);
227
228            // Optional: Early stopping based on convergence criteria could be added here
229        }
230
231        // Extract final Pareto front
232        let fronts = self.non_dominated_sort(&population);
233        let pareto_front = if fronts.is_empty() {
234            Vec::new()
235        } else {
236            fronts[0].clone()
237        };
238
239        Ok(OptimizationResult {
240            pareto_front,
241            final_population: population,
242            generation_stats,
243            n_generations: self.n_generations,
244        })
245    }
246
247    /// Initialize a random population
248    fn initialize_population<R: Rng>(
249        &self,
250        rng: &mut scirs2_core::random::CoreRandom<R>,
251        n_variables: usize,
252    ) -> SklResult<Vec<Individual>> {
253        let mut population = Vec::with_capacity(self.population_size);
254
255        for _ in 0..self.population_size {
256            let mut variables = Array1::zeros(n_variables);
257
258            for j in 0..n_variables {
259                let (min_val, max_val) = self.variable_bounds[j];
260                variables[j] = rng.gen_range(min_val..max_val + 1.0);
261            }
262
263            population.push(Individual::new(variables));
264        }
265
266        Ok(population)
267    }
268
269    /// Create offspring through crossover and mutation
270    fn create_offspring<R: Rng>(
271        &self,
272        population: &[Individual],
273        rng: &mut scirs2_core::random::CoreRandom<R>,
274        _n_variables: usize,
275    ) -> SklResult<Vec<Individual>> {
276        let mut offspring = Vec::with_capacity(self.population_size);
277
278        for _ in 0..self.population_size {
279            // Tournament selection for parents
280            let parent1 = self.tournament_selection(population, rng);
281            let parent2 = self.tournament_selection(population, rng);
282
283            // Crossover
284            let mut child = if rng.random::<Float>() < self.crossover_probability {
285                self.sbx_crossover(parent1, parent2, rng)?
286            } else {
287                parent1.clone()
288            };
289
290            // Mutation
291            if rng.random::<Float>() < self.mutation_probability {
292                self.polynomial_mutation(&mut child, rng);
293            }
294
295            offspring.push(child);
296        }
297
298        Ok(offspring)
299    }
300
301    /// Tournament selection
302    fn tournament_selection<'a, R: Rng>(
303        &self,
304        population: &'a [Individual],
305        rng: &mut scirs2_core::random::CoreRandom<R>,
306    ) -> &'a Individual {
307        let tournament_size = 2;
308        let mut best = &population[rng.gen_range(0..population.len())];
309
310        for _ in 1..tournament_size {
311            let candidate = &population[rng.gen_range(0..population.len())];
312            if self.compare_individuals(candidate, best) == Ordering::Less {
313                best = candidate;
314            }
315        }
316
317        best
318    }
319
320    /// Compare two individuals using NSGA-II criteria
321    fn compare_individuals(&self, a: &Individual, b: &Individual) -> Ordering {
322        // First, compare by rank (lower is better)
323        match a.rank.cmp(&b.rank) {
324            Ordering::Equal => {
325                // If ranks are equal, compare by crowding distance (higher is better)
326                b.crowding_distance
327                    .partial_cmp(&a.crowding_distance)
328                    .unwrap_or(Ordering::Equal)
329            }
330            other => other,
331        }
332    }
333
334    /// Simulated Binary Crossover (SBX)
335    fn sbx_crossover<R: Rng>(
336        &self,
337        parent1: &Individual,
338        parent2: &Individual,
339        rng: &mut scirs2_core::random::CoreRandom<R>,
340    ) -> SklResult<Individual> {
341        let n_variables = parent1.variables.len();
342        let mut child_variables = Array1::zeros(n_variables);
343
344        for i in 0..n_variables {
345            let p1 = parent1.variables[i];
346            let p2 = parent2.variables[i];
347            let (min_val, max_val) = self.variable_bounds[i];
348
349            if (p1 - p2).abs() > 1e-14 {
350                let u = rng.random::<Float>();
351                let beta = if u <= 0.5 {
352                    (2.0 * u).powf(1.0 / (self.crossover_eta + 1.0))
353                } else {
354                    (1.0 / (2.0 * (1.0 - u))).powf(1.0 / (self.crossover_eta + 1.0))
355                };
356
357                let c1 = 0.5 * (p1 + p2 - beta * (p1 - p2).abs());
358                child_variables[i] = c1.clamp(min_val, max_val);
359            } else {
360                child_variables[i] = p1;
361            }
362        }
363
364        Ok(Individual::new(child_variables))
365    }
366
367    /// Polynomial mutation
368    fn polynomial_mutation<R: Rng>(
369        &self,
370        individual: &mut Individual,
371        rng: &mut scirs2_core::random::CoreRandom<R>,
372    ) {
373        for i in 0..individual.variables.len() {
374            if rng.random::<Float>() < (1.0 / individual.variables.len() as Float) {
375                let (min_val, max_val) = self.variable_bounds[i];
376                let x = individual.variables[i];
377                let u = rng.random::<Float>();
378
379                let delta = if u <= 0.5 {
380                    let bl = (x - min_val) / (max_val - min_val);
381                    let b = 2.0 * u + (1.0 - 2.0 * u) * (1.0 - bl).powf(self.mutation_eta + 1.0);
382                    b.powf(1.0 / (self.mutation_eta + 1.0)) - 1.0
383                } else {
384                    let bu = (max_val - x) / (max_val - min_val);
385                    let b = 2.0 * (1.0 - u)
386                        + 2.0 * (u - 0.5) * (1.0 - bu).powf(self.mutation_eta + 1.0);
387                    1.0 - b.powf(1.0 / (self.mutation_eta + 1.0))
388                };
389
390                individual.variables[i] = (x + delta * (max_val - min_val)).clamp(min_val, max_val);
391            }
392        }
393    }
394
395    /// Perform non-dominated sorting
396    fn non_dominated_sort(&self, population: &[Individual]) -> Vec<Vec<Individual>> {
397        let mut fronts = Vec::new();
398        let mut domination_count = vec![0; population.len()];
399        let mut dominated_solutions = vec![Vec::new(); population.len()];
400
401        // Calculate domination relationships
402        for i in 0..population.len() {
403            for j in 0..population.len() {
404                if population[i].dominates(&population[j]) {
405                    dominated_solutions[i].push(j);
406                } else if population[j].dominates(&population[i]) {
407                    domination_count[i] += 1;
408                }
409            }
410        }
411
412        // Find first front
413        let mut current_front = Vec::new();
414        for i in 0..population.len() {
415            if domination_count[i] == 0 {
416                current_front.push(population[i].clone());
417            }
418        }
419
420        while !current_front.is_empty() {
421            fronts.push(current_front.clone());
422            let mut next_front = Vec::new();
423
424            for individual in &current_front {
425                // Find the index of this individual in the original population
426                if let Some(ind_idx) = population.iter().position(|p| {
427                    p.variables
428                        .iter()
429                        .zip(individual.variables.iter())
430                        .all(|(a, b)| (a - b).abs() < 1e-10)
431                }) {
432                    for &dominated_idx in &dominated_solutions[ind_idx] {
433                        domination_count[dominated_idx] -= 1;
434                        if domination_count[dominated_idx] == 0 {
435                            next_front.push(population[dominated_idx].clone());
436                        }
437                    }
438                }
439            }
440
441            current_front = next_front;
442        }
443
444        fronts
445    }
446
447    /// Environmental selection using NSGA-II
448    fn environmental_selection(
449        &self,
450        _population: Vec<Individual>,
451        fronts: Vec<Vec<Individual>>,
452    ) -> SklResult<Vec<Individual>> {
453        let mut selected = Vec::new();
454
455        for front in &fronts {
456            if selected.len() + front.len() <= self.population_size {
457                // Add entire front
458                selected.extend(front.clone());
459            } else {
460                // Partially add front based on crowding distance
461                let remaining = self.population_size - selected.len();
462                let mut front_with_distance = front.clone();
463
464                // Calculate crowding distances for this front
465                self.calculate_crowding_distance(&mut front_with_distance);
466
467                // Sort by crowding distance (descending)
468                front_with_distance.sort_by(|a, b| {
469                    b.crowding_distance
470                        .partial_cmp(&a.crowding_distance)
471                        .unwrap_or(Ordering::Equal)
472                });
473
474                selected.extend(front_with_distance.into_iter().take(remaining));
475                break;
476            }
477        }
478
479        // Assign ranks to selected individuals
480        for (rank, front) in fronts.iter().enumerate() {
481            for individual in &mut selected {
482                if front.iter().any(|f| {
483                    f.variables
484                        .iter()
485                        .zip(individual.variables.iter())
486                        .all(|(a, b)| (a - b).abs() < 1e-10)
487                }) {
488                    individual.rank = rank;
489                }
490            }
491        }
492
493        Ok(selected)
494    }
495
496    /// Calculate crowding distance for a front
497    fn calculate_crowding_distance(&self, front: &mut [Individual]) {
498        if front.len() <= 2 {
499            // Set infinite crowding distance for boundary solutions
500            for individual in front {
501                individual.crowding_distance = Float::INFINITY;
502            }
503            return;
504        }
505
506        let n_objectives = front[0].objectives.len();
507
508        // Initialize crowding distances
509        for individual in front.iter_mut() {
510            individual.crowding_distance = 0.0;
511        }
512
513        // Calculate for each objective
514        for obj_idx in 0..n_objectives {
515            // Sort by objective value
516            front.sort_by(|a, b| {
517                a.objectives[obj_idx]
518                    .partial_cmp(&b.objectives[obj_idx])
519                    .unwrap_or(Ordering::Equal)
520            });
521
522            // Set boundary points to infinity
523            front[0].crowding_distance = Float::INFINITY;
524            front[front.len() - 1].crowding_distance = Float::INFINITY;
525
526            // Calculate crowding distance for intermediate points
527            let max_obj = front[front.len() - 1].objectives[obj_idx];
528            let min_obj = front[0].objectives[obj_idx];
529
530            if max_obj - min_obj > 0.0 {
531                for i in 1..front.len() - 1 {
532                    if front[i].crowding_distance != Float::INFINITY {
533                        front[i].crowding_distance += (front[i + 1].objectives[obj_idx]
534                            - front[i - 1].objectives[obj_idx])
535                            / (max_obj - min_obj);
536                    }
537                }
538            }
539        }
540    }
541
542    /// Calculate generation statistics
543    fn calculate_generation_stats(&self, population: &[Individual]) -> GenerationStats {
544        let mut hypervolume = 0.0;
545        let spacing = 0.0;
546        let pareto_front_size = population.iter().filter(|ind| ind.rank == 0).count();
547
548        // Simple hypervolume calculation (could be improved)
549        let pareto_individuals: Vec<_> = population.iter().filter(|ind| ind.rank == 0).collect();
550        if !pareto_individuals.is_empty() {
551            let n_objectives = pareto_individuals[0].objectives.len();
552            let reference_point = Array1::from_elem(n_objectives, 10.0); // Simple reference point
553
554            for individual in &pareto_individuals {
555                let mut volume = 1.0;
556                for obj_idx in 0..n_objectives {
557                    volume *= (reference_point[obj_idx] - individual.objectives[obj_idx]).max(0.0);
558                }
559                hypervolume += volume;
560            }
561        }
562
563        GenerationStats {
564            hypervolume,
565            spacing,
566            pareto_front_size,
567        }
568    }
569}
570
571/// Results from evolutionary multi-objective optimization
572#[derive(Debug, Clone)]
573pub struct OptimizationResult {
574    /// Final Pareto front (non-dominated solutions)
575    pub pareto_front: Vec<Individual>,
576    /// Final population
577    pub final_population: Vec<Individual>,
578    /// Statistics for each generation
579    pub generation_stats: Vec<GenerationStats>,
580    /// Number of generations run
581    pub n_generations: usize,
582}
583
584impl OptimizationResult {
585    /// Get the Pareto front solutions
586    pub fn pareto_front(&self) -> &[Individual] {
587        &self.pareto_front
588    }
589
590    /// Get the final population
591    pub fn final_population(&self) -> &[Individual] {
592        &self.final_population
593    }
594
595    /// Get statistics for each generation
596    pub fn generation_stats(&self) -> &[GenerationStats] {
597        &self.generation_stats
598    }
599
600    /// Extract objective values from the Pareto front
601    pub fn pareto_objectives(&self) -> Array2<Float> {
602        if self.pareto_front.is_empty() {
603            return Array2::zeros((0, 0));
604        }
605
606        let n_objectives = self.pareto_front[0].objectives.len();
607        let mut objectives = Array2::zeros((self.pareto_front.len(), n_objectives));
608
609        for (i, individual) in self.pareto_front.iter().enumerate() {
610            for (j, &obj_val) in individual.objectives.iter().enumerate() {
611                objectives[[i, j]] = obj_val;
612            }
613        }
614
615        objectives
616    }
617
618    /// Extract decision variables from the Pareto front
619    pub fn pareto_variables(&self) -> Array2<Float> {
620        if self.pareto_front.is_empty() {
621            return Array2::zeros((0, 0));
622        }
623
624        let n_variables = self.pareto_front[0].variables.len();
625        let mut variables = Array2::zeros((self.pareto_front.len(), n_variables));
626
627        for (i, individual) in self.pareto_front.iter().enumerate() {
628            for (j, &var_val) in individual.variables.iter().enumerate() {
629                variables[[i, j]] = var_val;
630            }
631        }
632
633        variables
634    }
635}
636
637/// Statistics for a generation of evolution
638#[derive(Debug, Clone)]
639pub struct GenerationStats {
640    /// Hypervolume indicator
641    pub hypervolume: Float,
642    /// Spacing metric
643    pub spacing: Float,
644    /// Size of Pareto front
645    pub pareto_front_size: usize,
646}
647
648#[allow(non_snake_case)]
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use approx::assert_abs_diff_eq;
653    // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
654    use scirs2_core::ndarray::array;
655
656    #[test]
657    fn test_nsga2_creation() {
658        let nsga2 = NSGAII::new()
659            .population_size(50)
660            .n_generations(25)
661            .crossover_probability(0.8)
662            .mutation_probability(0.2)
663            .variable_bounds(vec![(-1.0, 1.0), (-1.0, 1.0)])
664            .random_state(42);
665
666        assert_eq!(nsga2.population_size, 50);
667        assert_eq!(nsga2.n_generations, 25);
668        assert_abs_diff_eq!(nsga2.crossover_probability, 0.8);
669        assert_abs_diff_eq!(nsga2.mutation_probability, 0.2);
670        assert_eq!(nsga2.variable_bounds.len(), 2);
671        assert_eq!(nsga2.random_state, Some(42));
672    }
673
674    #[test]
675    fn test_nsga2_optimization() {
676        // Simple bi-objective optimization problem (ZDT1-like)
677        let objective_fn = |x: &ArrayView1<Float>| {
678            let f1 = x[0];
679            let g = 1.0 + 9.0 * x.iter().skip(1).sum::<Float>() / (x.len() - 1) as Float;
680            let f2 = g * (1.0 - (f1 / g).sqrt());
681            array![f1, f2]
682        };
683
684        let nsga2 = NSGAII::new()
685            .population_size(20)
686            .n_generations(10)
687            .variable_bounds(vec![(0.0, 1.0), (0.0, 1.0)])
688            .random_state(42);
689
690        let result = nsga2
691            .optimize(objective_fn, 2)
692            .expect("operation should succeed");
693
694        // Check that we got a Pareto front
695        assert!(!result.pareto_front().is_empty());
696        assert!(result.pareto_front().len() <= 20); // Should not exceed population size
697
698        // Check that objectives are properly calculated
699        for individual in result.pareto_front() {
700            assert_eq!(individual.objectives.len(), 2);
701            assert!(individual.objectives[0] >= 0.0 && individual.objectives[0] <= 1.0);
702            assert!(individual.objectives[1] >= 0.0);
703        }
704
705        // Check generation stats
706        assert_eq!(result.generation_stats().len(), 10);
707        assert!(result.generation_stats()[0].pareto_front_size > 0);
708    }
709
710    #[test]
711    fn test_individual_dominance() {
712        let ind1 = Individual {
713            variables: array![1.0, 2.0],
714            objectives: array![1.0, 2.0], // Better in both objectives
715            rank: 0,
716            crowding_distance: 0.0,
717        };
718
719        let ind2 = Individual {
720            variables: array![2.0, 3.0],
721            objectives: array![2.0, 3.0], // Worse in both objectives
722            rank: 0,
723            crowding_distance: 0.0,
724        };
725
726        assert!(ind1.dominates(&ind2));
727        assert!(!ind2.dominates(&ind1));
728    }
729
730    #[test]
731    fn test_non_dominated_sort() {
732        let nsga2 = NSGAII::new();
733
734        let population = vec![
735            Individual {
736                variables: array![1.0],
737                objectives: array![1.0, 3.0], // Front 0
738                rank: 0,
739                crowding_distance: 0.0,
740            },
741            Individual {
742                variables: array![2.0],
743                objectives: array![2.0, 2.0], // Front 0
744                rank: 0,
745                crowding_distance: 0.0,
746            },
747            Individual {
748                variables: array![3.0],
749                objectives: array![3.0, 1.0], // Front 0
750                rank: 0,
751                crowding_distance: 0.0,
752            },
753            Individual {
754                variables: array![4.0],
755                objectives: array![2.0, 3.0], // Front 1 (dominated by ind1)
756                rank: 0,
757                crowding_distance: 0.0,
758            },
759        ];
760
761        let fronts = nsga2.non_dominated_sort(&population);
762
763        assert!(!fronts.is_empty());
764        assert_eq!(fronts[0].len(), 3); // First three form Pareto front
765        if fronts.len() > 1 {
766            assert_eq!(fronts[1].len(), 1); // Last one is dominated
767        }
768    }
769
770    #[test]
771    fn test_optimization_result_accessors() {
772        let individuals = vec![
773            Individual {
774                variables: array![1.0, 2.0],
775                objectives: array![1.0, 2.0],
776                rank: 0,
777                crowding_distance: 0.0,
778            },
779            Individual {
780                variables: array![2.0, 1.0],
781                objectives: array![2.0, 1.0],
782                rank: 0,
783                crowding_distance: 0.0,
784            },
785        ];
786
787        let result = OptimizationResult {
788            pareto_front: individuals.clone(),
789            final_population: individuals,
790            generation_stats: vec![GenerationStats {
791                hypervolume: 1.0,
792                spacing: 0.5,
793                pareto_front_size: 2,
794            }],
795            n_generations: 10,
796        };
797
798        let objectives = result.pareto_objectives();
799        assert_eq!(objectives.shape(), &[2, 2]);
800        assert_abs_diff_eq!(objectives[[0, 0]], 1.0);
801        assert_abs_diff_eq!(objectives[[0, 1]], 2.0);
802
803        let variables = result.pareto_variables();
804        assert_eq!(variables.shape(), &[2, 2]);
805        assert_abs_diff_eq!(variables[[0, 0]], 1.0);
806        assert_abs_diff_eq!(variables[[0, 1]], 2.0);
807    }
808}