kizzasi_logic/
multiobjective.rs

1//! Multi-Objective Constraint Optimization
2//!
3//! This module provides algorithms for multi-objective optimization under constraints:
4//! - Pareto frontier computation
5//! - NSGA-II (Non-dominated Sorting Genetic Algorithm)
6//! - Weighted sum methods
7//! - ε-constraint method
8//! - Hypervolume indicator
9//!
10//! # Applications
11//!
12//! - Trade-off analysis between conflicting objectives
13//! - Multi-criteria decision making
14//! - Resource allocation under constraints
15//! - Design optimization with multiple goals
16
17use crate::error::{LogicError, LogicResult};
18use scirs2_core::ndarray::Array1;
19use std::cmp::Ordering;
20
21/// Multi-objective solution point
22#[derive(Debug, Clone)]
23pub struct MultiObjectiveSolution {
24    /// Decision variables
25    pub variables: Array1<f32>,
26    /// Objective values (one per objective)
27    pub objectives: Vec<f32>,
28    /// Constraint violations
29    pub violations: Vec<f32>,
30    /// Total constraint violation
31    pub total_violation: f32,
32    /// Pareto rank (0 = non-dominated front)
33    pub rank: usize,
34    /// Crowding distance (for diversity)
35    pub crowding_distance: f32,
36}
37
38impl MultiObjectiveSolution {
39    /// Check if this solution dominates another
40    ///
41    /// Solution A dominates B if:
42    /// - A is feasible and B is not, OR
43    /// - Both are feasible, A is no worse in all objectives, and better in at least one
44    pub fn dominates(&self, other: &Self, minimize: &[bool]) -> bool {
45        // Feasibility check first
46        let self_feasible = self.is_feasible();
47        let other_feasible = other.is_feasible();
48
49        if self_feasible && !other_feasible {
50            return true;
51        }
52        if !self_feasible && other_feasible {
53            return false;
54        }
55        if !self_feasible && !other_feasible {
56            // Both infeasible: compare constraint violations
57            return self.total_violation < other.total_violation;
58        }
59
60        // Both feasible: check Pareto dominance
61        let mut better_in_at_least_one = false;
62        let mut worse_in_any = false;
63
64        for (i, (&obj_a, &obj_b)) in self
65            .objectives
66            .iter()
67            .zip(other.objectives.iter())
68            .enumerate()
69        {
70            let cmp = if minimize[i] {
71                obj_a.partial_cmp(&obj_b)
72            } else {
73                obj_b.partial_cmp(&obj_a)
74            };
75
76            match cmp {
77                Some(Ordering::Less) => better_in_at_least_one = true,
78                Some(Ordering::Greater) => worse_in_any = true,
79                _ => {}
80            }
81        }
82
83        better_in_at_least_one && !worse_in_any
84    }
85
86    /// Check if solution is feasible
87    pub fn is_feasible(&self) -> bool {
88        self.total_violation < 1e-6
89    }
90
91    /// Compute Euclidean distance in objective space
92    pub fn objective_distance(&self, other: &Self) -> f32 {
93        self.objectives
94            .iter()
95            .zip(other.objectives.iter())
96            .map(|(a, b)| (a - b).powi(2))
97            .sum::<f32>()
98            .sqrt()
99    }
100}
101
102/// Multi-objective optimizer
103pub struct MultiObjectiveOptimizer {
104    /// Number of objectives
105    num_objectives: usize,
106    /// Minimize (true) or maximize (false) for each objective
107    minimize: Vec<bool>,
108    /// Population size
109    population_size: usize,
110    /// Maximum generations
111    max_generations: usize,
112    /// Mutation rate
113    #[allow(dead_code)]
114    mutation_rate: f32,
115}
116
117impl MultiObjectiveOptimizer {
118    /// Create a new multi-objective optimizer
119    pub fn new(num_objectives: usize, minimize: Vec<bool>) -> Self {
120        assert_eq!(
121            num_objectives,
122            minimize.len(),
123            "Minimize vector must match number of objectives"
124        );
125
126        Self {
127            num_objectives,
128            minimize,
129            population_size: 100,
130            max_generations: 100,
131            mutation_rate: 0.1,
132        }
133    }
134
135    /// Set population size
136    pub fn with_population_size(mut self, size: usize) -> Self {
137        self.population_size = size;
138        self
139    }
140
141    /// Set maximum generations
142    pub fn with_max_generations(mut self, gens: usize) -> Self {
143        self.max_generations = gens;
144        self
145    }
146
147    /// Compute non-dominated sorting (NSGA-II style)
148    pub fn non_dominated_sort(&self, population: &mut [MultiObjectiveSolution]) -> Vec<Vec<usize>> {
149        let n = population.len();
150        let mut domination_count = vec![0; n];
151        let mut dominated_solutions = vec![Vec::new(); n];
152        let mut fronts = vec![Vec::new()];
153
154        // Compute domination relationships
155        for i in 0..n {
156            for j in 0..n {
157                if i == j {
158                    continue;
159                }
160                if population[i].dominates(&population[j], &self.minimize) {
161                    dominated_solutions[i].push(j);
162                } else if population[j].dominates(&population[i], &self.minimize) {
163                    domination_count[i] += 1;
164                }
165            }
166
167            // Solutions with rank 0 are in first front
168            if domination_count[i] == 0 {
169                population[i].rank = 0;
170                fronts[0].push(i);
171            }
172        }
173
174        // Build subsequent fronts
175        let mut current_front = 0;
176        while current_front < fronts.len() && !fronts[current_front].is_empty() {
177            let mut next_front = Vec::new();
178
179            for &i in &fronts[current_front] {
180                for &j in &dominated_solutions[i] {
181                    domination_count[j] -= 1;
182                    if domination_count[j] == 0 {
183                        population[j].rank = current_front + 1;
184                        next_front.push(j);
185                    }
186                }
187            }
188
189            if !next_front.is_empty() {
190                fronts.push(next_front);
191            }
192            current_front += 1;
193        }
194
195        fronts
196    }
197
198    /// Compute crowding distance for diversity preservation
199    pub fn compute_crowding_distance(&self, population: &mut [MultiObjectiveSolution]) {
200        let n = population.len();
201
202        if n == 0 {
203            return;
204        }
205
206        // Initialize crowding distances
207        for solution in population.iter_mut() {
208            solution.crowding_distance = 0.0;
209        }
210
211        // For each objective
212        for obj_idx in 0..self.num_objectives {
213            // Sort by this objective
214            let mut indices: Vec<usize> = (0..n).collect();
215            indices.sort_by(|&a, &b| {
216                population[a].objectives[obj_idx]
217                    .partial_cmp(&population[b].objectives[obj_idx])
218                    .unwrap_or(Ordering::Equal)
219            });
220
221            // Boundary points get infinite distance
222            population[indices[0]].crowding_distance = f32::INFINITY;
223            population[indices[n - 1]].crowding_distance = f32::INFINITY;
224
225            // Compute range
226            let obj_min = population[indices[0]].objectives[obj_idx];
227            let obj_max = population[indices[n - 1]].objectives[obj_idx];
228            let range = obj_max - obj_min;
229
230            if range < 1e-10 {
231                continue;
232            }
233
234            // Compute crowding distance for interior points
235            for i in 1..(n - 1) {
236                let prev_obj = population[indices[i - 1]].objectives[obj_idx];
237                let next_obj = population[indices[i + 1]].objectives[obj_idx];
238                population[indices[i]].crowding_distance += (next_obj - prev_obj) / range;
239            }
240        }
241    }
242
243    /// Get Pareto frontier (non-dominated front)
244    pub fn get_pareto_frontier(
245        &self,
246        population: &[MultiObjectiveSolution],
247    ) -> Vec<MultiObjectiveSolution> {
248        let mut pop_copy = population.to_vec();
249        let fronts = self.non_dominated_sort(&mut pop_copy);
250
251        if fronts.is_empty() || fronts[0].is_empty() {
252            return Vec::new();
253        }
254
255        fronts[0].iter().map(|&i| pop_copy[i].clone()).collect()
256    }
257}
258
259/// Weighted sum method for multi-objective optimization
260pub struct WeightedSumMethod {
261    /// Weights for each objective (must sum to 1.0)
262    weights: Vec<f32>,
263}
264
265impl WeightedSumMethod {
266    /// Create weighted sum with specified weights
267    pub fn new(weights: Vec<f32>) -> LogicResult<Self> {
268        let sum: f32 = weights.iter().sum();
269        if (sum - 1.0).abs() > 1e-6 {
270            return Err(LogicError::InvalidInput(
271                "Weights must sum to 1.0".to_string(),
272            ));
273        }
274
275        for &w in &weights {
276            if w < 0.0 {
277                return Err(LogicError::InvalidInput(
278                    "Weights must be non-negative".to_string(),
279                ));
280            }
281        }
282
283        Ok(Self { weights })
284    }
285
286    /// Combine multiple objectives into single objective
287    pub fn combine_objectives(&self, objectives: &[f32]) -> LogicResult<f32> {
288        if objectives.len() != self.weights.len() {
289            return Err(LogicError::InvalidInput(
290                "Objective count mismatch".to_string(),
291            ));
292        }
293
294        let combined = objectives
295            .iter()
296            .zip(self.weights.iter())
297            .map(|(obj, weight)| obj * weight)
298            .sum();
299
300        Ok(combined)
301    }
302}
303
304/// Hypervolume indicator for Pareto front quality
305pub struct HypervolumeIndicator {
306    /// Reference point (worst acceptable point)
307    reference_point: Vec<f32>,
308}
309
310impl HypervolumeIndicator {
311    /// Create hypervolume indicator with reference point
312    pub fn new(reference_point: Vec<f32>) -> Self {
313        Self { reference_point }
314    }
315
316    /// Compute hypervolume of a Pareto front
317    ///
318    /// Simplified 2D implementation (for 2 objectives)
319    pub fn compute_2d(&self, front: &[MultiObjectiveSolution]) -> f32 {
320        if front.is_empty() || self.reference_point.len() != 2 {
321            return 0.0;
322        }
323
324        // Sort by first objective
325        let mut sorted_front = front.to_vec();
326        sorted_front.sort_by(|a, b| {
327            a.objectives[0]
328                .partial_cmp(&b.objectives[0])
329                .unwrap_or(Ordering::Equal)
330        });
331
332        let mut hypervolume = 0.0;
333        let mut prev_y = self.reference_point[1];
334
335        for solution in sorted_front.iter() {
336            let x = solution.objectives[0];
337            let y = solution.objectives[1];
338
339            if x < self.reference_point[0] && y < self.reference_point[1] {
340                let width = self.reference_point[0] - x;
341                let height = prev_y - y;
342                hypervolume += width * height;
343                prev_y = y;
344            }
345        }
346
347        hypervolume
348    }
349
350    /// Compute hypervolume contribution of a solution
351    pub fn contribution(
352        &self,
353        solution: &MultiObjectiveSolution,
354        front: &[MultiObjectiveSolution],
355    ) -> f32 {
356        // Compute hypervolume with and without this solution
357        let hv_with = self.compute_2d(front);
358
359        let front_without: Vec<MultiObjectiveSolution> = front
360            .iter()
361            .filter(|s| !std::ptr::eq(*s, solution))
362            .cloned()
363            .collect();
364
365        let hv_without = self.compute_2d(&front_without);
366
367        hv_with - hv_without
368    }
369}
370
371/// ε-constraint method for multi-objective optimization
372///
373/// Optimizes one objective while constraining others
374pub struct EpsilonConstraintMethod {
375    /// Index of primary objective to optimize
376    primary_objective: usize,
377    /// Bounds on other objectives
378    epsilon_bounds: Vec<f32>,
379}
380
381impl EpsilonConstraintMethod {
382    /// Create ε-constraint method
383    pub fn new(primary_objective: usize, epsilon_bounds: Vec<f32>) -> Self {
384        Self {
385            primary_objective,
386            epsilon_bounds,
387        }
388    }
389
390    /// Check if solution satisfies ε-constraints
391    pub fn satisfies_constraints(&self, solution: &MultiObjectiveSolution) -> bool {
392        for (i, &bound) in self.epsilon_bounds.iter().enumerate() {
393            if i == self.primary_objective {
394                continue;
395            }
396            if solution.objectives[i] > bound {
397                return false;
398            }
399        }
400        true
401    }
402
403    /// Get primary objective value
404    pub fn primary_value(&self, solution: &MultiObjectiveSolution) -> f32 {
405        solution.objectives[self.primary_objective]
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_dominance() {
415        let sol1 = MultiObjectiveSolution {
416            variables: Array1::from_vec(vec![1.0]),
417            objectives: vec![2.0, 3.0],
418            violations: vec![],
419            total_violation: 0.0,
420            rank: 0,
421            crowding_distance: 0.0,
422        };
423
424        let sol2 = MultiObjectiveSolution {
425            variables: Array1::from_vec(vec![2.0]),
426            objectives: vec![3.0, 4.0],
427            violations: vec![],
428            total_violation: 0.0,
429            rank: 0,
430            crowding_distance: 0.0,
431        };
432
433        let minimize = vec![true, true];
434        assert!(sol1.dominates(&sol2, &minimize));
435        assert!(!sol2.dominates(&sol1, &minimize));
436    }
437
438    #[test]
439    fn test_non_dominated_sorting() {
440        let optimizer = MultiObjectiveOptimizer::new(2, vec![true, true]);
441
442        let mut population = vec![
443            MultiObjectiveSolution {
444                variables: Array1::from_vec(vec![1.0]),
445                objectives: vec![1.0, 4.0],
446                violations: vec![],
447                total_violation: 0.0,
448                rank: 0,
449                crowding_distance: 0.0,
450            },
451            MultiObjectiveSolution {
452                variables: Array1::from_vec(vec![2.0]),
453                objectives: vec![2.0, 3.0],
454                violations: vec![],
455                total_violation: 0.0,
456                rank: 0,
457                crowding_distance: 0.0,
458            },
459            MultiObjectiveSolution {
460                variables: Array1::from_vec(vec![3.0]),
461                objectives: vec![3.0, 2.0],
462                violations: vec![],
463                total_violation: 0.0,
464                rank: 0,
465                crowding_distance: 0.0,
466            },
467            MultiObjectiveSolution {
468                variables: Array1::from_vec(vec![4.0]),
469                objectives: vec![4.0, 1.0],
470                violations: vec![],
471                total_violation: 0.0,
472                rank: 0,
473                crowding_distance: 0.0,
474            },
475            MultiObjectiveSolution {
476                variables: Array1::from_vec(vec![5.0]),
477                objectives: vec![2.5, 2.5],
478                violations: vec![],
479                total_violation: 0.0,
480                rank: 0,
481                crowding_distance: 0.0,
482            },
483        ];
484
485        let fronts = optimizer.non_dominated_sort(&mut population);
486
487        // All solutions should be on Pareto front (non-dominated)
488        assert!(!fronts.is_empty());
489        assert_eq!(fronts[0].len(), 5);
490    }
491
492    #[test]
493    fn test_crowding_distance() {
494        let optimizer = MultiObjectiveOptimizer::new(2, vec![true, true]);
495
496        let mut population = vec![
497            MultiObjectiveSolution {
498                variables: Array1::from_vec(vec![1.0]),
499                objectives: vec![1.0, 5.0],
500                violations: vec![],
501                total_violation: 0.0,
502                rank: 0,
503                crowding_distance: 0.0,
504            },
505            MultiObjectiveSolution {
506                variables: Array1::from_vec(vec![2.0]),
507                objectives: vec![3.0, 3.0],
508                violations: vec![],
509                total_violation: 0.0,
510                rank: 0,
511                crowding_distance: 0.0,
512            },
513            MultiObjectiveSolution {
514                variables: Array1::from_vec(vec![3.0]),
515                objectives: vec![5.0, 1.0],
516                violations: vec![],
517                total_violation: 0.0,
518                rank: 0,
519                crowding_distance: 0.0,
520            },
521        ];
522
523        optimizer.compute_crowding_distance(&mut population);
524
525        // Boundary points should have infinite crowding distance
526        assert_eq!(population[0].crowding_distance, f32::INFINITY);
527        assert_eq!(population[2].crowding_distance, f32::INFINITY);
528        // Interior point should have finite crowding distance
529        assert!(population[1].crowding_distance.is_finite());
530        assert!(population[1].crowding_distance > 0.0);
531    }
532
533    #[test]
534    fn test_weighted_sum() {
535        let method = WeightedSumMethod::new(vec![0.6, 0.4]).unwrap();
536        let objectives = vec![10.0, 20.0];
537        let combined = method.combine_objectives(&objectives).unwrap();
538
539        assert!((combined - 14.0).abs() < 1e-5); // 0.6*10 + 0.4*20 = 14
540    }
541
542    #[test]
543    fn test_weighted_sum_validation() {
544        // Weights don't sum to 1.0
545        let result = WeightedSumMethod::new(vec![0.5, 0.4]);
546        assert!(result.is_err());
547
548        // Negative weight
549        let result = WeightedSumMethod::new(vec![1.5, -0.5]);
550        assert!(result.is_err());
551    }
552
553    #[test]
554    fn test_hypervolume_2d() {
555        let indicator = HypervolumeIndicator::new(vec![10.0, 10.0]);
556
557        let front = vec![
558            MultiObjectiveSolution {
559                variables: Array1::from_vec(vec![1.0]),
560                objectives: vec![2.0, 8.0],
561                violations: vec![],
562                total_violation: 0.0,
563                rank: 0,
564                crowding_distance: 0.0,
565            },
566            MultiObjectiveSolution {
567                variables: Array1::from_vec(vec![2.0]),
568                objectives: vec![5.0, 5.0],
569                violations: vec![],
570                total_violation: 0.0,
571                rank: 0,
572                crowding_distance: 0.0,
573            },
574            MultiObjectiveSolution {
575                variables: Array1::from_vec(vec![3.0]),
576                objectives: vec![8.0, 2.0],
577                violations: vec![],
578                total_violation: 0.0,
579                rank: 0,
580                crowding_distance: 0.0,
581            },
582        ];
583
584        let hv = indicator.compute_2d(&front);
585        assert!(hv > 0.0);
586    }
587
588    #[test]
589    fn test_epsilon_constraint() {
590        let method = EpsilonConstraintMethod::new(0, vec![f32::MAX, 5.0]);
591
592        let sol1 = MultiObjectiveSolution {
593            variables: Array1::from_vec(vec![1.0]),
594            objectives: vec![2.0, 3.0],
595            violations: vec![],
596            total_violation: 0.0,
597            rank: 0,
598            crowding_distance: 0.0,
599        };
600
601        let sol2 = MultiObjectiveSolution {
602            variables: Array1::from_vec(vec![2.0]),
603            objectives: vec![1.0, 6.0],
604            violations: vec![],
605            total_violation: 0.0,
606            rank: 0,
607            crowding_distance: 0.0,
608        };
609
610        assert!(method.satisfies_constraints(&sol1));
611        assert!(!method.satisfies_constraints(&sol2));
612        assert_eq!(method.primary_value(&sol1), 2.0);
613    }
614
615    #[test]
616    fn test_pareto_frontier() {
617        let optimizer = MultiObjectiveOptimizer::new(2, vec![true, true]);
618
619        let population = vec![
620            MultiObjectiveSolution {
621                variables: Array1::from_vec(vec![1.0]),
622                objectives: vec![1.0, 5.0],
623                violations: vec![],
624                total_violation: 0.0,
625                rank: 0,
626                crowding_distance: 0.0,
627            },
628            MultiObjectiveSolution {
629                variables: Array1::from_vec(vec![2.0]),
630                objectives: vec![3.0, 3.0],
631                violations: vec![],
632                total_violation: 0.0,
633                rank: 0,
634                crowding_distance: 0.0,
635            },
636            MultiObjectiveSolution {
637                variables: Array1::from_vec(vec![3.0]),
638                objectives: vec![5.0, 1.0],
639                violations: vec![],
640                total_violation: 0.0,
641                rank: 0,
642                crowding_distance: 0.0,
643            },
644            MultiObjectiveSolution {
645                variables: Array1::from_vec(vec![4.0]),
646                objectives: vec![2.0, 6.0], // Dominated by sol1 (1.0<2.0 and 5.0<6.0)
647                violations: vec![],
648                total_violation: 0.0,
649                rank: 0,
650                crowding_distance: 0.0,
651            },
652        ];
653
654        let frontier = optimizer.get_pareto_frontier(&population);
655        assert_eq!(frontier.len(), 3); // First 3 are non-dominated
656    }
657}