quantrs2_anneal/
multi_objective.rs

1//! Multi-objective optimization framework for quantum annealing
2//!
3//! This module provides tools for solving multi-objective optimization problems
4//! where multiple conflicting objectives need to be optimized simultaneously,
5//! generating Pareto-optimal solutions.
6
7use scirs2_core::random::prelude::*;
8use scirs2_core::random::ChaCha8Rng;
9use scirs2_core::random::{Rng, SeedableRng};
10use std::collections::HashMap;
11use std::time::{Duration, Instant};
12use thiserror::Error;
13
14use crate::ising::{IsingError, IsingModel};
15use crate::simulator::{AnnealingParams, AnnealingSolution, QuantumAnnealingSimulator};
16
17/// Errors that can occur during multi-objective optimization
18#[derive(Error, Debug)]
19pub enum MultiObjectiveError {
20    /// Ising model error
21    #[error("Ising error: {0}")]
22    IsingError(#[from] IsingError),
23
24    /// Invalid objective function
25    #[error("Invalid objective: {0}")]
26    InvalidObjective(String),
27
28    /// Optimization failed
29    #[error("Optimization failed: {0}")]
30    OptimizationFailed(String),
31
32    /// Pareto analysis error
33    #[error("Pareto analysis error: {0}")]
34    ParetoAnalysisError(String),
35
36    /// Scalarization error
37    #[error("Scalarization error: {0}")]
38    ScalarizationError(String),
39}
40
41/// Result type for multi-objective operations
42pub type MultiObjectiveResult<T> = Result<T, MultiObjectiveError>;
43
44/// Objective function that evaluates multiple criteria
45pub type MultiObjectiveFunction = Box<dyn Fn(&[i8]) -> Vec<f64> + Send + Sync>;
46
47/// Single solution in multi-objective space
48#[derive(Debug, Clone)]
49pub struct MultiObjectiveSolution {
50    /// Variable assignment (spin configuration)
51    pub variables: Vec<i8>,
52
53    /// Objective values for each criterion
54    pub objectives: Vec<f64>,
55
56    /// Rank in Pareto front (0 = non-dominated)
57    pub pareto_rank: usize,
58
59    /// Crowding distance for diversity
60    pub crowding_distance: f64,
61
62    /// Solution metadata
63    pub metadata: HashMap<String, String>,
64}
65
66impl MultiObjectiveSolution {
67    /// Create a new multi-objective solution
68    #[must_use]
69    pub fn new(variables: Vec<i8>, objectives: Vec<f64>) -> Self {
70        Self {
71            variables,
72            objectives,
73            pareto_rank: 0,
74            crowding_distance: 0.0,
75            metadata: HashMap::new(),
76        }
77    }
78
79    /// Check if this solution dominates another
80    #[must_use]
81    pub fn dominates(&self, other: &Self) -> bool {
82        if self.objectives.len() != other.objectives.len() {
83            return false;
84        }
85
86        let mut at_least_one_better = false;
87        for (self_obj, other_obj) in self.objectives.iter().zip(other.objectives.iter()) {
88            if self_obj > other_obj {
89                return false; // Self is worse in this objective
90            }
91            if self_obj < other_obj {
92                at_least_one_better = true;
93            }
94        }
95
96        at_least_one_better
97    }
98
99    /// Check if this solution is non-dominated by another
100    #[must_use]
101    pub fn is_non_dominated_by(&self, other: &Self) -> bool {
102        !other.dominates(self)
103    }
104}
105
106/// Scalarization method for converting multi-objective to single-objective
107#[derive(Debug, Clone)]
108pub enum ScalarizationMethod {
109    /// Weighted sum: min `Σ(w_i` * `f_i`)
110    WeightedSum { weights: Vec<f64> },
111
112    /// Weighted Chebyshev: min `max_i(w_i` * |`f_i` - `z_i`*|)
113    WeightedChebyshev {
114        weights: Vec<f64>,
115        reference_point: Vec<f64>,
116    },
117
118    /// Augmented Chebyshev with penalty term
119    AugmentedChebyshev {
120        weights: Vec<f64>,
121        reference_point: Vec<f64>,
122        rho: f64,
123    },
124
125    /// Achievement scalarizing function
126    Achievement {
127        weights: Vec<f64>,
128        reference_point: Vec<f64>,
129    },
130
131    /// ε-constraint: optimize one objective subject to constraints on others
132    EpsilonConstraint {
133        primary_objective: usize,
134        constraints: Vec<f64>,
135    },
136}
137
138impl ScalarizationMethod {
139    /// Apply scalarization to convert multiple objectives to single value
140    pub fn scalarize(&self, objectives: &[f64]) -> MultiObjectiveResult<f64> {
141        match self {
142            Self::WeightedSum { weights } => {
143                if weights.len() != objectives.len() {
144                    return Err(MultiObjectiveError::ScalarizationError(
145                        "Weights and objectives dimension mismatch".to_string(),
146                    ));
147                }
148
149                let weighted_sum = weights
150                    .iter()
151                    .zip(objectives.iter())
152                    .map(|(w, obj)| w * obj)
153                    .sum();
154
155                Ok(weighted_sum)
156            }
157
158            Self::WeightedChebyshev {
159                weights,
160                reference_point,
161            } => {
162                if weights.len() != objectives.len() || reference_point.len() != objectives.len() {
163                    return Err(MultiObjectiveError::ScalarizationError(
164                        "Dimension mismatch in Chebyshev scalarization".to_string(),
165                    ));
166                }
167
168                let max_weighted_deviation = weights
169                    .iter()
170                    .zip(objectives.iter())
171                    .zip(reference_point.iter())
172                    .map(|((w, obj), ref_val)| w * (obj - ref_val).abs())
173                    .fold(0.0, f64::max);
174
175                Ok(max_weighted_deviation)
176            }
177
178            Self::AugmentedChebyshev {
179                weights,
180                reference_point,
181                rho,
182            } => {
183                let chebyshev_value =
184                    self.scalarize_chebyshev(objectives, weights, reference_point)?;
185
186                let augmentation_term = rho
187                    * weights
188                        .iter()
189                        .zip(objectives.iter())
190                        .zip(reference_point.iter())
191                        .map(|((w, obj), ref_val)| w * (obj - ref_val))
192                        .sum::<f64>();
193
194                Ok(chebyshev_value + augmentation_term)
195            }
196
197            Self::Achievement {
198                weights,
199                reference_point,
200            } => {
201                let achievement_value = weights
202                    .iter()
203                    .zip(objectives.iter())
204                    .zip(reference_point.iter())
205                    .map(|((w, obj), ref_val)| (obj - ref_val) / w.max(1e-8))
206                    .fold(f64::NEG_INFINITY, f64::max);
207
208                Ok(achievement_value)
209            }
210
211            Self::EpsilonConstraint {
212                primary_objective,
213                constraints,
214            } => {
215                if *primary_objective >= objectives.len() {
216                    return Err(MultiObjectiveError::ScalarizationError(
217                        "Primary objective index out of bounds".to_string(),
218                    ));
219                }
220
221                // Check constraint violations
222                let mut penalty = 0.0;
223                for (i, &constraint_bound) in constraints.iter().enumerate() {
224                    if i != *primary_objective && i < objectives.len() {
225                        if objectives[i] > constraint_bound {
226                            penalty += 1000.0 * (objectives[i] - constraint_bound);
227                        }
228                    }
229                }
230
231                Ok(objectives[*primary_objective] + penalty)
232            }
233        }
234    }
235
236    /// Helper method for Chebyshev scalarization
237    fn scalarize_chebyshev(
238        &self,
239        objectives: &[f64],
240        weights: &[f64],
241        reference_point: &[f64],
242    ) -> MultiObjectiveResult<f64> {
243        if weights.len() != objectives.len() || reference_point.len() != objectives.len() {
244            return Err(MultiObjectiveError::ScalarizationError(
245                "Dimension mismatch in Chebyshev scalarization".to_string(),
246            ));
247        }
248
249        let max_weighted_deviation = weights
250            .iter()
251            .zip(objectives.iter())
252            .zip(reference_point.iter())
253            .map(|((w, obj), ref_val)| w * (obj - ref_val).abs())
254            .fold(0.0, f64::max);
255
256        Ok(max_weighted_deviation)
257    }
258}
259
260/// Configuration for multi-objective optimization
261#[derive(Debug, Clone)]
262pub struct MultiObjectiveConfig {
263    /// Base annealing parameters
264    pub annealing_params: AnnealingParams,
265
266    /// Scalarization method
267    pub scalarization: ScalarizationMethod,
268
269    /// Number of Pareto front approximation runs
270    pub num_pareto_runs: usize,
271
272    /// Population size for diversity
273    pub population_size: usize,
274
275    /// Enable Pareto ranking and crowding distance
276    pub enable_pareto_analysis: bool,
277
278    /// Maximum number of non-dominated solutions to keep
279    pub max_pareto_solutions: usize,
280
281    /// Random seed
282    pub seed: Option<u64>,
283
284    /// Timeout for optimization
285    pub timeout: Option<Duration>,
286}
287
288impl Default for MultiObjectiveConfig {
289    fn default() -> Self {
290        Self {
291            annealing_params: AnnealingParams::default(),
292            scalarization: ScalarizationMethod::WeightedSum {
293                weights: vec![1.0, 1.0],
294            },
295            num_pareto_runs: 20,
296            population_size: 100,
297            enable_pareto_analysis: true,
298            max_pareto_solutions: 50,
299            seed: None,
300            timeout: Some(Duration::from_secs(600)),
301        }
302    }
303}
304
305/// Results from multi-objective optimization
306#[derive(Debug, Clone)]
307pub struct MultiObjectiveResults {
308    /// All solutions found
309    pub all_solutions: Vec<MultiObjectiveSolution>,
310
311    /// Pareto-optimal solutions (rank 0)
312    pub pareto_front: Vec<MultiObjectiveSolution>,
313
314    /// Statistics about the optimization
315    pub stats: MultiObjectiveStats,
316
317    /// Objective bounds for normalization
318    pub objective_bounds: Vec<(f64, f64)>, // (min, max) for each objective
319}
320
321/// Statistics for multi-objective optimization
322#[derive(Debug, Clone)]
323pub struct MultiObjectiveStats {
324    /// Total runtime
325    pub total_runtime: Duration,
326
327    /// Number of unique solutions found
328    pub unique_solutions: usize,
329
330    /// Size of final Pareto front
331    pub pareto_front_size: usize,
332
333    /// Average crowding distance
334    pub average_crowding_distance: f64,
335
336    /// Hypervolume indicator (if calculated)
337    pub hypervolume: Option<f64>,
338
339    /// Convergence metrics
340    pub convergence_metrics: HashMap<String, f64>,
341}
342
343/// Multi-objective optimizer using quantum annealing
344pub struct MultiObjectiveOptimizer {
345    /// Configuration
346    config: MultiObjectiveConfig,
347
348    /// Random number generator
349    rng: ChaCha8Rng,
350}
351
352impl MultiObjectiveOptimizer {
353    /// Create a new multi-objective optimizer
354    #[must_use]
355    pub fn new(config: MultiObjectiveConfig) -> Self {
356        let rng = match config.seed {
357            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
358            None => ChaCha8Rng::seed_from_u64(thread_rng().gen()),
359        };
360
361        Self { config, rng }
362    }
363
364    /// Solve a multi-objective optimization problem
365    pub fn solve(
366        &mut self,
367        model: &IsingModel,
368        objective_function: MultiObjectiveFunction,
369        num_objectives: usize,
370    ) -> MultiObjectiveResult<MultiObjectiveResults> {
371        let start_time = Instant::now();
372        let mut all_solutions = Vec::new();
373
374        // Generate multiple scalarizations for Pareto front approximation
375        let scalarizations = self.generate_scalarizations(num_objectives)?;
376
377        // Solve for each scalarization
378        for (run_idx, scalarization) in scalarizations.into_iter().enumerate() {
379            // Check timeout
380            if let Some(timeout) = self.config.timeout {
381                if start_time.elapsed() > timeout {
382                    break;
383                }
384            }
385
386            println!(
387                "Running scalarization {}/{}",
388                run_idx + 1,
389                self.config.num_pareto_runs
390            );
391
392            // Solve single-objective problem
393            let solution =
394                self.solve_scalarized_problem(model, &objective_function, &scalarization)?;
395
396            // Evaluate true objectives
397            let objectives = objective_function(&solution.best_spins);
398            let mo_solution = MultiObjectiveSolution::new(solution.best_spins, objectives);
399
400            all_solutions.push(mo_solution);
401        }
402
403        // Remove duplicate solutions
404        all_solutions = self.remove_duplicates(all_solutions);
405
406        // Perform Pareto analysis if enabled
407        if self.config.enable_pareto_analysis {
408            self.perform_pareto_analysis(&mut all_solutions)?;
409        }
410
411        // Extract Pareto front
412        let pareto_front: Vec<MultiObjectiveSolution> = all_solutions
413            .iter()
414            .filter(|sol| sol.pareto_rank == 0)
415            .cloned()
416            .collect();
417
418        // Calculate objective bounds
419        let objective_bounds = self.calculate_objective_bounds(&all_solutions, num_objectives);
420
421        // Calculate statistics
422        let stats = self.calculate_statistics(&all_solutions, &pareto_front, start_time.elapsed());
423
424        Ok(MultiObjectiveResults {
425            all_solutions,
426            pareto_front,
427            stats,
428            objective_bounds,
429        })
430    }
431
432    /// Generate different scalarizations for Pareto front approximation
433    fn generate_scalarizations(
434        &mut self,
435        num_objectives: usize,
436    ) -> MultiObjectiveResult<Vec<ScalarizationMethod>> {
437        let mut scalarizations = Vec::new();
438        let num_runs = self.config.num_pareto_runs;
439
440        match self.config.scalarization.clone() {
441            ScalarizationMethod::WeightedSum { .. } => {
442                // Generate uniformly distributed weight vectors
443                for i in 0..num_runs {
444                    let weights = self.generate_weight_vector(num_objectives, i, num_runs);
445                    scalarizations.push(ScalarizationMethod::WeightedSum { weights });
446                }
447            }
448
449            ScalarizationMethod::WeightedChebyshev {
450                reference_point, ..
451            } => {
452                // Generate different weight vectors with same reference point
453                for i in 0..num_runs {
454                    let weights = self.generate_weight_vector(num_objectives, i, num_runs);
455                    scalarizations.push(ScalarizationMethod::WeightedChebyshev {
456                        weights,
457                        reference_point: reference_point.clone(),
458                    });
459                }
460            }
461
462            ScalarizationMethod::EpsilonConstraint {
463                primary_objective, ..
464            } => {
465                // Generate different constraint bounds
466                for i in 0..num_runs {
467                    let constraints = self.generate_constraint_bounds(num_objectives, i);
468                    scalarizations.push(ScalarizationMethod::EpsilonConstraint {
469                        primary_objective,
470                        constraints,
471                    });
472                }
473            }
474
475            method => {
476                // For other methods, use the original configuration multiple times
477                for _ in 0..num_runs {
478                    scalarizations.push(method.clone());
479                }
480            }
481        }
482
483        Ok(scalarizations)
484    }
485
486    /// Generate weight vector for run i
487    fn generate_weight_vector(
488        &mut self,
489        num_objectives: usize,
490        run_index: usize,
491        num_runs: usize,
492    ) -> Vec<f64> {
493        if num_objectives == 2 {
494            // For 2 objectives, use uniform distribution
495            let alpha = if num_runs > 1 {
496                run_index as f64 / (num_runs - 1) as f64
497            } else {
498                0.5
499            };
500            vec![alpha, 1.0 - alpha]
501        } else {
502            // For more objectives, use random weights
503            let mut weights: Vec<f64> = (0..num_objectives)
504                .map(|_| self.rng.gen_range(0.1..1.0))
505                .collect();
506
507            // Normalize weights
508            let sum: f64 = weights.iter().sum();
509            for weight in &mut weights {
510                *weight /= sum;
511            }
512
513            weights
514        }
515    }
516
517    /// Generate constraint bounds for ε-constraint method
518    fn generate_constraint_bounds(&mut self, num_objectives: usize, _run_index: usize) -> Vec<f64> {
519        // Generate random constraint bounds
520        (0..num_objectives)
521            .map(|_| self.rng.gen_range(-10.0..10.0))
522            .collect()
523    }
524
525    /// Solve a scalarized single-objective problem
526    fn solve_scalarized_problem(
527        &self,
528        model: &IsingModel,
529        objective_function: &MultiObjectiveFunction,
530        scalarization: &ScalarizationMethod,
531    ) -> MultiObjectiveResult<AnnealingSolution> {
532        // Create a new Ising model that incorporates the scalarized objective
533        let mut scalarized_model = model.clone();
534
535        // For demonstration, we'll use the original model and evaluate objectives separately
536        // In practice, you might want to modify the model to encode the scalarized objective
537
538        let mut simulator = QuantumAnnealingSimulator::new(self.config.annealing_params.clone())
539            .map_err(|e| MultiObjectiveError::OptimizationFailed(e.to_string()))?;
540
541        let solution = simulator
542            .solve(&scalarized_model)
543            .map_err(|e| MultiObjectiveError::OptimizationFailed(e.to_string()))?;
544
545        Ok(solution)
546    }
547
548    /// Remove duplicate solutions
549    fn remove_duplicates(
550        &self,
551        mut solutions: Vec<MultiObjectiveSolution>,
552    ) -> Vec<MultiObjectiveSolution> {
553        solutions.sort_by(|a, b| {
554            a.variables
555                .partial_cmp(&b.variables)
556                .unwrap_or(std::cmp::Ordering::Equal)
557        });
558
559        solutions.dedup_by(|a, b| a.variables == b.variables);
560        solutions
561    }
562
563    /// Perform Pareto ranking and crowding distance calculation
564    fn perform_pareto_analysis(
565        &self,
566        solutions: &mut [MultiObjectiveSolution],
567    ) -> MultiObjectiveResult<()> {
568        // Fast non-dominated sorting
569        self.fast_non_dominated_sort(solutions)?;
570
571        // Calculate crowding distances
572        self.calculate_crowding_distances(solutions)?;
573
574        Ok(())
575    }
576
577    /// Fast non-dominated sorting algorithm
578    fn fast_non_dominated_sort(
579        &self,
580        solutions: &mut [MultiObjectiveSolution],
581    ) -> MultiObjectiveResult<()> {
582        let n = solutions.len();
583        let mut domination_count = vec![0; n];
584        let mut dominated_solutions = vec![Vec::new(); n];
585        let mut fronts = Vec::new();
586        let mut current_front = Vec::new();
587
588        // For each solution, find which solutions it dominates and how many dominate it
589        for i in 0..n {
590            for j in 0..n {
591                if i != j {
592                    if solutions[i].dominates(&solutions[j]) {
593                        dominated_solutions[i].push(j);
594                    } else if solutions[j].dominates(&solutions[i]) {
595                        domination_count[i] += 1;
596                    }
597                }
598            }
599
600            if domination_count[i] == 0 {
601                solutions[i].pareto_rank = 0;
602                current_front.push(i);
603            }
604        }
605
606        fronts.push(current_front.clone());
607
608        // Generate subsequent fronts
609        let mut front_index = 0;
610        while !fronts[front_index].is_empty() {
611            let mut next_front = Vec::new();
612
613            for &p in &fronts[front_index] {
614                for &q in &dominated_solutions[p] {
615                    domination_count[q] -= 1;
616                    if domination_count[q] == 0 {
617                        solutions[q].pareto_rank = front_index + 1;
618                        next_front.push(q);
619                    }
620                }
621            }
622
623            front_index += 1;
624            fronts.push(next_front);
625        }
626
627        Ok(())
628    }
629
630    /// Calculate crowding distances for diversity preservation
631    fn calculate_crowding_distances(
632        &self,
633        solutions: &mut [MultiObjectiveSolution],
634    ) -> MultiObjectiveResult<()> {
635        if solutions.is_empty() {
636            return Ok(());
637        }
638
639        let num_objectives = solutions[0].objectives.len();
640
641        // Initialize crowding distances
642        for solution in solutions.iter_mut() {
643            solution.crowding_distance = 0.0;
644        }
645
646        // Calculate crowding distance for each objective
647        for obj_idx in 0..num_objectives {
648            // Sort by objective value
649            let mut indices: Vec<usize> = (0..solutions.len()).collect();
650            indices.sort_by(|&a, &b| {
651                solutions[a].objectives[obj_idx]
652                    .partial_cmp(&solutions[b].objectives[obj_idx])
653                    .unwrap_or(std::cmp::Ordering::Equal)
654            });
655
656            // Set boundary solutions to infinite distance
657            if !indices.is_empty() {
658                solutions[indices[0]].crowding_distance = f64::INFINITY;
659                if indices.len() > 1 {
660                    solutions[indices[indices.len() - 1]].crowding_distance = f64::INFINITY;
661                }
662            }
663
664            // Calculate range
665            if indices.len() > 2 {
666                let min_obj = solutions[indices[0]].objectives[obj_idx];
667                let max_obj = solutions[indices[indices.len() - 1]].objectives[obj_idx];
668                let range = max_obj - min_obj;
669
670                if range > 0.0 {
671                    // Calculate crowding distance for intermediate solutions
672                    for i in 1..indices.len() - 1 {
673                        let distance = (solutions[indices[i + 1]].objectives[obj_idx]
674                            - solutions[indices[i - 1]].objectives[obj_idx])
675                            / range;
676                        solutions[indices[i]].crowding_distance += distance;
677                    }
678                }
679            }
680        }
681
682        Ok(())
683    }
684
685    /// Calculate objective bounds
686    fn calculate_objective_bounds(
687        &self,
688        solutions: &[MultiObjectiveSolution],
689        num_objectives: usize,
690    ) -> Vec<(f64, f64)> {
691        let mut bounds = vec![(f64::INFINITY, f64::NEG_INFINITY); num_objectives];
692
693        for solution in solutions {
694            for (obj_idx, &obj_value) in solution.objectives.iter().enumerate() {
695                if obj_idx < bounds.len() {
696                    bounds[obj_idx].0 = bounds[obj_idx].0.min(obj_value);
697                    bounds[obj_idx].1 = bounds[obj_idx].1.max(obj_value);
698                }
699            }
700        }
701
702        bounds
703    }
704
705    /// Calculate optimization statistics
706    fn calculate_statistics(
707        &self,
708        all_solutions: &[MultiObjectiveSolution],
709        pareto_front: &[MultiObjectiveSolution],
710        runtime: Duration,
711    ) -> MultiObjectiveStats {
712        let unique_solutions = all_solutions.len();
713        let pareto_front_size = pareto_front.len();
714
715        let average_crowding_distance = if pareto_front.is_empty() {
716            0.0
717        } else {
718            pareto_front
719                .iter()
720                .map(|sol| sol.crowding_distance)
721                .filter(|&d| d.is_finite())
722                .sum::<f64>()
723                / pareto_front.len() as f64
724        };
725
726        let mut convergence_metrics = HashMap::new();
727        convergence_metrics.insert(
728            "pareto_ratio".to_string(),
729            pareto_front_size as f64 / unique_solutions.max(1) as f64,
730        );
731
732        MultiObjectiveStats {
733            total_runtime: runtime,
734            unique_solutions,
735            pareto_front_size,
736            average_crowding_distance,
737            hypervolume: None, // Would need reference point to calculate
738            convergence_metrics,
739        }
740    }
741}
742
743/// Quality metrics for multi-objective optimization results
744pub struct QualityMetrics;
745
746impl QualityMetrics {
747    /// Calculate hypervolume indicator
748    pub fn hypervolume(
749        solutions: &[MultiObjectiveSolution],
750        reference_point: &[f64],
751    ) -> MultiObjectiveResult<f64> {
752        if solutions.is_empty() {
753            return Ok(0.0);
754        }
755
756        let num_objectives = solutions[0].objectives.len();
757        if reference_point.len() != num_objectives {
758            return Err(MultiObjectiveError::ParetoAnalysisError(
759                "Reference point dimension mismatch".to_string(),
760            ));
761        }
762
763        // Simple hypervolume calculation for 2D case
764        if num_objectives == 2 {
765            let mut sorted_solutions = solutions.to_vec();
766            sorted_solutions.sort_by(|a, b| {
767                a.objectives[0]
768                    .partial_cmp(&b.objectives[0])
769                    .unwrap_or(std::cmp::Ordering::Equal)
770            });
771
772            let mut volume = 0.0;
773            let mut prev_y = 0.0;
774
775            for solution in &sorted_solutions {
776                if solution.objectives[0] < reference_point[0]
777                    && solution.objectives[1] < reference_point[1]
778                {
779                    let width = reference_point[0] - solution.objectives[0];
780                    let height = solution.objectives[1] - prev_y;
781                    if height > 0.0 {
782                        volume += width * height;
783                    }
784                    prev_y = solution.objectives[1];
785                }
786            }
787
788            Ok(volume)
789        } else {
790            // For higher dimensions, would need more sophisticated algorithm
791            Ok(0.0)
792        }
793    }
794
795    /// Calculate spacing metric (diversity measure)
796    #[must_use]
797    pub fn spacing(solutions: &[MultiObjectiveSolution]) -> f64 {
798        if solutions.len() < 2 {
799            return 0.0;
800        }
801
802        let mut distances = Vec::new();
803
804        for (i, sol1) in solutions.iter().enumerate() {
805            let mut min_distance = f64::INFINITY;
806
807            for (j, sol2) in solutions.iter().enumerate() {
808                if i != j {
809                    let distance: f64 = sol1
810                        .objectives
811                        .iter()
812                        .zip(sol2.objectives.iter())
813                        .map(|(a, b)| (a - b).abs())
814                        .sum();
815
816                    min_distance = min_distance.min(distance);
817                }
818            }
819
820            distances.push(min_distance);
821        }
822
823        let mean_distance = distances.iter().sum::<f64>() / distances.len() as f64;
824        let variance = distances
825            .iter()
826            .map(|d| (d - mean_distance).powi(2))
827            .sum::<f64>()
828            / distances.len() as f64;
829
830        variance.sqrt()
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    #[test]
839    fn test_solution_dominance() {
840        let sol1 = MultiObjectiveSolution::new(vec![1, -1], vec![1.0, 2.0]);
841        let sol2 = MultiObjectiveSolution::new(vec![-1, 1], vec![2.0, 1.0]);
842        let sol3 = MultiObjectiveSolution::new(vec![1, 1], vec![0.5, 1.5]);
843
844        // sol3 dominates sol1 (both objectives are better: 0.5 < 1.0, 1.5 < 2.0)
845        assert!(sol3.dominates(&sol1));
846        // sol3 does not dominate sol2 (trade-off: 0.5 < 2.0 but 1.5 > 1.0)
847        assert!(!sol3.dominates(&sol2));
848
849        // sol1 and sol2 do not dominate each other (trade-off)
850        assert!(!sol1.dominates(&sol2));
851        assert!(!sol2.dominates(&sol1));
852    }
853
854    #[test]
855    fn test_weighted_sum_scalarization() {
856        let weights = vec![0.3, 0.7];
857        let scalarization = ScalarizationMethod::WeightedSum { weights };
858
859        let objectives = vec![2.0, 4.0];
860        let result = scalarization
861            .scalarize(&objectives)
862            .expect("Scalarization failed");
863
864        assert_eq!(result, 0.3 * 2.0 + 0.7 * 4.0);
865    }
866
867    #[test]
868    fn test_chebyshev_scalarization() {
869        let weights = vec![1.0, 1.0];
870        let reference_point = vec![0.0, 0.0];
871        let scalarization = ScalarizationMethod::WeightedChebyshev {
872            weights,
873            reference_point,
874        };
875
876        let objectives = vec![3.0, 1.0];
877        let result = scalarization
878            .scalarize(&objectives)
879            .expect("Chebyshev scalarization failed");
880
881        assert_eq!(result, 3.0); // max(1.0 * |3.0 - 0.0|, 1.0 * |1.0 - 0.0|)
882    }
883
884    #[test]
885    fn test_quality_metrics() {
886        let solutions = vec![
887            MultiObjectiveSolution::new(vec![1, -1], vec![1.0, 3.0]),
888            MultiObjectiveSolution::new(vec![-1, 1], vec![2.0, 1.0]),
889            MultiObjectiveSolution::new(vec![1, 1], vec![0.5, 2.0]),
890        ];
891
892        let spacing = QualityMetrics::spacing(&solutions);
893        assert!(spacing >= 0.0);
894
895        let reference_point = vec![4.0, 4.0];
896        let hypervolume = QualityMetrics::hypervolume(&solutions, &reference_point)
897            .expect("Hypervolume calculation failed");
898        assert!(hypervolume >= 0.0);
899    }
900}