Skip to main content

scirs2_optimize/multi_objective/
pareto.rs

1//! Pareto utilities for multi-objective optimization
2//!
3//! This module provides core utilities for Pareto-based multi-objective optimization:
4//! - Dominance checking
5//! - Non-dominated sorting (fast non-dominated sort, Deb et al.)
6//! - Crowding distance calculation
7//! - Pareto front extraction
8//! - Hypervolume indicator (exact 2D, WFG algorithm for 3D+)
9//!
10//! # References
11//!
12//! - Deb et al., "A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II",
13//!   IEEE TEC 2002
14//! - While et al., "A Fast Way of Calculating Exact Hypervolumes", IEEE TEC 2012 (WFG)
15
16use super::solutions::MultiObjectiveSolution;
17use std::cmp::Ordering;
18
19/// Check if solution `a` dominates solution `b` (minimization).
20///
21/// `a` dominates `b` if a_i <= b_i for all objectives, and a_i < b_i for at least one.
22pub fn dominates(a: &[f64], b: &[f64]) -> bool {
23    debug_assert_eq!(a.len(), b.len(), "Objective vectors must have equal length");
24
25    let mut at_least_one_strict = false;
26    for (ai, bi) in a.iter().zip(b.iter()) {
27        if ai > bi {
28            return false;
29        }
30        if ai < bi {
31            at_least_one_strict = true;
32        }
33    }
34    at_least_one_strict
35}
36
37/// Check if solution `a` dominates solution `b` using `MultiObjectiveSolution`.
38///
39/// Considers constraint violations: a feasible solution dominates an infeasible one.
40pub fn solution_dominates(a: &MultiObjectiveSolution, b: &MultiObjectiveSolution) -> bool {
41    a.dominates(b)
42}
43
44/// Perform fast non-dominated sorting (Deb et al. 2002).
45///
46/// Partitions a population into Pareto fronts F0, F1, F2, ...
47/// where F0 is the non-dominated set, F1 is non-dominated in the remaining set, etc.
48///
49/// Returns a vector of fronts, where each front is a vector of indices into the input slice.
50///
51/// Time complexity: O(M * N^2) where M = number of objectives, N = population size.
52pub fn fast_non_dominated_sort(objectives: &[Vec<f64>]) -> Vec<Vec<usize>> {
53    let n = objectives.len();
54    if n == 0 {
55        return vec![];
56    }
57
58    let mut domination_counts = vec![0usize; n];
59    let mut dominated_sets: Vec<Vec<usize>> = vec![Vec::new(); n];
60
61    // Compute domination relationships: O(M*N^2)
62    for i in 0..n {
63        for j in (i + 1)..n {
64            if dominates(&objectives[i], &objectives[j]) {
65                dominated_sets[i].push(j);
66                domination_counts[j] += 1;
67            } else if dominates(&objectives[j], &objectives[i]) {
68                dominated_sets[j].push(i);
69                domination_counts[i] += 1;
70            }
71        }
72    }
73
74    // Build fronts
75    let mut fronts: Vec<Vec<usize>> = Vec::new();
76    let mut current_front: Vec<usize> = Vec::new();
77
78    // First front: all solutions with domination count == 0
79    for i in 0..n {
80        if domination_counts[i] == 0 {
81            current_front.push(i);
82        }
83    }
84
85    while !current_front.is_empty() {
86        let mut next_front = Vec::new();
87        for &i in &current_front {
88            for &j in &dominated_sets[i] {
89                domination_counts[j] -= 1;
90                if domination_counts[j] == 0 {
91                    next_front.push(j);
92                }
93            }
94        }
95        fronts.push(current_front);
96        current_front = next_front;
97    }
98
99    fronts
100}
101
102/// Perform fast non-dominated sorting on `MultiObjectiveSolution` slice.
103///
104/// Updates `rank` field on each solution and returns the fronts as index vectors.
105pub fn non_dominated_sort_solutions(solutions: &mut [MultiObjectiveSolution]) -> Vec<Vec<usize>> {
106    let objectives: Vec<Vec<f64>> = solutions.iter().map(|s| s.objectives.to_vec()).collect();
107
108    let fronts = fast_non_dominated_sort(&objectives);
109
110    // Assign ranks
111    for (rank, front) in fronts.iter().enumerate() {
112        for &idx in front {
113            solutions[idx].rank = rank;
114        }
115    }
116
117    fronts
118}
119
120/// Calculate crowding distances for a set of solutions within the same front.
121///
122/// Boundary solutions (min/max for any objective) receive `f64::INFINITY`.
123/// Interior solutions receive the sum of normalized distances to their neighbors
124/// when sorted by each objective.
125///
126/// Returns a vector of crowding distances, one per solution in `front_indices`.
127pub fn crowding_distance(
128    solutions: &[MultiObjectiveSolution],
129    front_indices: &[usize],
130) -> Vec<f64> {
131    let n = front_indices.len();
132    if n == 0 {
133        return vec![];
134    }
135    if n <= 2 {
136        return vec![f64::INFINITY; n];
137    }
138
139    let n_objectives = solutions[front_indices[0]].objectives.len();
140    let mut distances = vec![0.0_f64; n];
141
142    // For each objective, sort front members and add normalized neighbor distances
143    for obj in 0..n_objectives {
144        // Create (local_index, objective_value) pairs
145        let mut sorted: Vec<(usize, f64)> = (0..n)
146            .map(|local_idx| {
147                let global_idx = front_indices[local_idx];
148                (local_idx, solutions[global_idx].objectives[obj])
149            })
150            .collect();
151
152        sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
153
154        let obj_min = sorted[0].1;
155        let obj_max = sorted[n - 1].1;
156        let obj_range = obj_max - obj_min;
157
158        // Boundary solutions get infinity
159        distances[sorted[0].0] = f64::INFINITY;
160        distances[sorted[n - 1].0] = f64::INFINITY;
161
162        if obj_range > 0.0 {
163            for i in 1..n - 1 {
164                let local_idx = sorted[i].0;
165                if distances[local_idx].is_finite() {
166                    let neighbor_dist = (sorted[i + 1].1 - sorted[i - 1].1) / obj_range;
167                    distances[local_idx] += neighbor_dist;
168                }
169            }
170        }
171    }
172
173    distances
174}
175
176/// Calculate crowding distances and assign them to solution structs in place.
177pub fn assign_crowding_distances(
178    solutions: &mut [MultiObjectiveSolution],
179    front_indices: &[usize],
180) {
181    let distances = crowding_distance(solutions, front_indices);
182    for (local_idx, &global_idx) in front_indices.iter().enumerate() {
183        solutions[global_idx].crowding_distance = distances[local_idx];
184    }
185}
186
187/// Extract the Pareto front (non-dominated set) from a collection of solutions.
188///
189/// Returns cloned solutions that belong to the first non-dominated front.
190pub fn pareto_front(solutions: &[MultiObjectiveSolution]) -> Vec<MultiObjectiveSolution> {
191    if solutions.is_empty() {
192        return vec![];
193    }
194
195    let mut front: Vec<MultiObjectiveSolution> = Vec::new();
196
197    for candidate in solutions {
198        let mut is_dominated = false;
199
200        // Check if candidate is dominated by any existing front member
201        for existing in &front {
202            if existing.dominates(candidate) {
203                is_dominated = true;
204                break;
205            }
206        }
207
208        if !is_dominated {
209            // Remove front members dominated by candidate
210            front.retain(|existing| !candidate.dominates(existing));
211            front.push(candidate.clone());
212        }
213    }
214
215    front
216}
217
218/// Calculate the hypervolume indicator for a set of solutions.
219///
220/// For 2D problems, uses an exact O(N log N) sweep-line algorithm.
221/// For 3D+ problems, uses the WFG (Walking Fish Group) exact algorithm.
222///
223/// All solutions must have objective values strictly dominated by `reference_point`
224/// to contribute to the hypervolume.
225pub fn hypervolume(solutions: &[MultiObjectiveSolution], reference_point: &[f64]) -> f64 {
226    if solutions.is_empty() {
227        return 0.0;
228    }
229
230    let n_objectives = reference_point.len();
231
232    // Filter solutions that are dominated by the reference point
233    let valid_solutions: Vec<&MultiObjectiveSolution> = solutions
234        .iter()
235        .filter(|sol| {
236            sol.objectives
237                .iter()
238                .zip(reference_point.iter())
239                .all(|(&obj, &rp)| obj < rp)
240        })
241        .collect();
242
243    if valid_solutions.is_empty() {
244        return 0.0;
245    }
246
247    match n_objectives {
248        1 => hypervolume_1d(&valid_solutions, reference_point),
249        2 => hypervolume_2d(&valid_solutions, reference_point),
250        _ => hypervolume_wfg(&valid_solutions, reference_point),
251    }
252}
253
254/// 1D hypervolume: simple length calculation
255fn hypervolume_1d(solutions: &[&MultiObjectiveSolution], reference_point: &[f64]) -> f64 {
256    let min_obj = solutions
257        .iter()
258        .map(|s| s.objectives[0])
259        .fold(f64::INFINITY, f64::min);
260
261    reference_point[0] - min_obj
262}
263
264/// 2D hypervolume: exact sweep-line algorithm, O(N log N)
265///
266/// Standard staircase computation: sort by first objective ascending,
267/// filter to non-dominated staircase (y must be decreasing), then sum rectangles.
268fn hypervolume_2d(solutions: &[&MultiObjectiveSolution], reference_point: &[f64]) -> f64 {
269    let mut points: Vec<(f64, f64)> = solutions
270        .iter()
271        .map(|s| (s.objectives[0], s.objectives[1]))
272        .collect();
273
274    // Sort by first objective ascending
275    points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
276
277    // Filter to non-dominated set: sorted by x ascending, keep only points
278    // where y is strictly less than all previous kept points' y.
279    // In 2D, a point (x2, y2) with x2 > x1 is dominated by (x1, y1) iff y2 >= y1.
280    // So we keep points where y < min_y_so_far.
281    // Actually for non-dominated set in x-sorted order, y must be DECREASING.
282    let mut staircase: Vec<(f64, f64)> = Vec::new();
283    let mut min_y_so_far = f64::INFINITY;
284    for &(x, y) in &points {
285        if y < min_y_so_far {
286            staircase.push((x, y));
287            min_y_so_far = y;
288        }
289    }
290
291    // Wait -- this is wrong too. (1,3) has y=3, min_y starts at inf, so 3<inf -> keep.
292    // Then (3,1) has y=1 < 3 -> keep. staircase = [(1,3), (3,1)]. That's correct.
293    // Let me re-check: the staircase should have DECREASING y when read left to right.
294    // Since we process in x-ascending order and only keep points with y < min_y_so_far,
295    // the y values are strictly decreasing. Good.
296
297    if staircase.is_empty() {
298        return 0.0;
299    }
300
301    // Compute hypervolume as sum of rectangles
302    // Each point (x_i, y_i) contributes rectangle of width (x_{i+1} - x_i) and
303    // height (ref_y - y_i)
304    let mut volume = 0.0;
305    for i in 0..staircase.len() {
306        let (x, y) = staircase[i];
307        let x_next = if i + 1 < staircase.len() {
308            staircase[i + 1].0
309        } else {
310            reference_point[0]
311        };
312        volume += (x_next - x) * (reference_point[1] - y);
313    }
314
315    volume
316}
317
318/// WFG hypervolume algorithm for 3+ dimensions.
319///
320/// Based on the Hypervolume by Slicing Objectives (HSO) approach.
321/// For moderate dimension counts (3-5), this is exact and efficient.
322fn hypervolume_wfg(solutions: &[&MultiObjectiveSolution], reference_point: &[f64]) -> f64 {
323    let n_objectives = reference_point.len();
324
325    // Convert to raw objective vectors for recursive processing
326    let points: Vec<Vec<f64>> = solutions.iter().map(|s| s.objectives.to_vec()).collect();
327
328    wfg_hv_recursive(&points, reference_point, n_objectives)
329}
330
331/// Recursive WFG hypervolume computation (Hypervolume by Slicing Objectives).
332///
333/// Implements the HSO/WFG exact algorithm (While et al., IEEE TEC 2012):
334///   1. Sort points ASCENDING by the last objective coordinate.
335///   2. Process each point in order: add it to a "contributing" set (maintaining
336///      non-dominated projections), then account for the slab between the current
337///      point's last-coordinate and the next point's (or the reference point's).
338///   3. The contribution of each slab equals the projected (dim-1)-hypervolume of
339///      the contributing set times the slab height.
340///
341/// Base cases: dim == 1 (length) and dim == 2 (O(N log N) staircase sweep).
342fn wfg_hv_recursive(points: &[Vec<f64>], reference_point: &[f64], dim: usize) -> f64 {
343    if points.is_empty() {
344        return 0.0;
345    }
346
347    if dim == 1 {
348        // 1D base case: hypervolume = ref - min(obj)
349        let min_val = points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
350        return reference_point[0] - min_val;
351    }
352
353    if dim == 2 {
354        // 2D base case: exact staircase sweep, O(N log N).
355        // Sort ascending by x; keep only the non-dominated front (y strictly
356        // decreasing left-to-right).  Then sum up axis-aligned rectangles.
357        let mut pts: Vec<(f64, f64)> = points.iter().map(|p| (p[0], p[1])).collect();
358        pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
359
360        // Build non-dominated staircase: retain only points where y < all previous y.
361        let mut staircase: Vec<(f64, f64)> = Vec::new();
362        let mut min_y = f64::INFINITY;
363        for &(x, y) in &pts {
364            if y < min_y {
365                staircase.push((x, y));
366                min_y = y;
367            }
368        }
369
370        let mut vol = 0.0;
371        for i in 0..staircase.len() {
372            let x_next = if i + 1 < staircase.len() {
373                staircase[i + 1].0
374            } else {
375                reference_point[0]
376            };
377            vol += (x_next - staircase[i].0) * (reference_point[1] - staircase[i].1);
378        }
379        return vol;
380    }
381
382    // General case (dim >= 3): slice by the last dimension.
383    //
384    // Algorithm:
385    //   - Sort ASCENDING by last coordinate.
386    //   - Maintain a "contributing" set of points already processed (their
387    //     projected (dim-1)-dimensional footprint covers what we've accounted for
388    //     below the current z-level).
389    //   - For each new point p at z-level z_i:
390    //       a) Add p to contributing (removing any projections that p now dominates,
391    //          and skipping if p's projection is already dominated).
392    //       b) Compute the slab height to the NEXT z-level (z_{i+1} or ref[last_dim]).
393    //       c) Accumulate: volume += projected_hv(contributing) * slab_height.
394    //
395    // This ensures the very first slab (from z_0 up to z_1 or ref) gets counted
396    // after z_0 is in contributing, and the final slab (from z_{n-1} up to ref)
397    // is captured in step (c) of the last iteration.
398    let last_dim = dim - 1;
399    let mut sorted_points = points.to_vec();
400    sorted_points.sort_by(|a, b| {
401        a[last_dim]
402            .partial_cmp(&b[last_dim])
403            .unwrap_or(Ordering::Equal)
404    });
405
406    let mut volume = 0.0;
407    // Maintains the non-dominated set of points already processed, as their
408    // full dim-vectors (so the projection is p[..last_dim]).
409    let mut contributing: Vec<Vec<f64>> = Vec::new();
410
411    for (idx, point) in sorted_points.iter().enumerate() {
412        // Step a: update contributing with this point's projection.
413        let new_proj: Vec<f64> = point[..last_dim].to_vec();
414        let mut dominated_by_existing = false;
415        for existing in &contributing {
416            let ex_proj: Vec<f64> = existing[..last_dim].to_vec();
417            if dominates(&ex_proj, &new_proj) {
418                dominated_by_existing = true;
419                break;
420            }
421        }
422        if !dominated_by_existing {
423            contributing.retain(|existing| {
424                let ex_proj: Vec<f64> = existing[..last_dim].to_vec();
425                !dominates(&new_proj, &ex_proj)
426            });
427            contributing.push(point.clone());
428        }
429
430        // Step b: slab height from this point's z up to the next z-level (or ref).
431        let z_current = point[last_dim];
432        let z_next = if idx + 1 < sorted_points.len() {
433            sorted_points[idx + 1][last_dim]
434        } else {
435            reference_point[last_dim]
436        };
437        let slab_height = z_next - z_current;
438
439        // Step c: accumulate if the slab has positive height.
440        if slab_height > 0.0 {
441            let projected: Vec<Vec<f64>> = contributing
442                .iter()
443                .map(|p| p[..last_dim].to_vec())
444                .collect();
445            let ref_projected = &reference_point[..last_dim];
446            let slice_hv = wfg_hv_recursive(&projected, ref_projected, last_dim);
447            volume += slice_hv * slab_height;
448        }
449    }
450
451    volume
452}
453
454/// Calculate hypervolume from raw objective vectors.
455///
456/// Convenience function that wraps solutions in `MultiObjectiveSolution`.
457pub fn hypervolume_from_objectives(objectives: &[Vec<f64>], reference_point: &[f64]) -> f64 {
458    use scirs2_core::ndarray::Array1;
459
460    let solutions: Vec<MultiObjectiveSolution> = objectives
461        .iter()
462        .map(|objs| {
463            MultiObjectiveSolution::new(
464                Array1::zeros(1), // dummy variables
465                Array1::from_vec(objs.clone()),
466            )
467        })
468        .collect();
469
470    hypervolume(&solutions, reference_point)
471}
472
473/// Compare two solutions using crowded comparison operator (NSGA-II).
474///
475/// Returns `Ordering::Less` if `a` is preferred over `b`.
476/// Preference: lower rank first, then higher crowding distance.
477pub fn crowded_comparison(a: &MultiObjectiveSolution, b: &MultiObjectiveSolution) -> Ordering {
478    match a.rank.cmp(&b.rank) {
479        Ordering::Less => Ordering::Less,       // a has better (lower) rank
480        Ordering::Greater => Ordering::Greater, // b has better rank
481        Ordering::Equal => {
482            // Same rank: prefer higher crowding distance
483            b.crowding_distance
484                .partial_cmp(&a.crowding_distance)
485                .unwrap_or(Ordering::Equal)
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use scirs2_core::ndarray::array;
494
495    // ======= Dominance tests =======
496
497    #[test]
498    fn test_dominates_strict() {
499        // a strictly better in both objectives
500        assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
501    }
502
503    #[test]
504    fn test_dominates_weak_one_equal() {
505        // a equal in one, better in another
506        assert!(dominates(&[1.0, 2.0], &[1.0, 3.0]));
507    }
508
509    #[test]
510    fn test_dominates_equal_returns_false() {
511        // identical solutions: no dominance
512        assert!(!dominates(&[1.0, 2.0], &[1.0, 2.0]));
513    }
514
515    #[test]
516    fn test_dominates_trade_off_returns_false() {
517        // neither dominates the other (trade-off)
518        assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
519        assert!(!dominates(&[2.0, 2.0], &[1.0, 3.0]));
520    }
521
522    #[test]
523    fn test_dominates_three_objectives() {
524        assert!(dominates(&[1.0, 2.0, 3.0], &[2.0, 3.0, 4.0]));
525        assert!(!dominates(&[1.0, 2.0, 5.0], &[2.0, 3.0, 4.0]));
526    }
527
528    #[test]
529    fn test_solution_dominates_with_constraints() {
530        let feasible =
531            MultiObjectiveSolution::new_with_constraints(array![1.0], array![5.0, 5.0], 0.0);
532        let infeasible =
533            MultiObjectiveSolution::new_with_constraints(array![1.0], array![1.0, 1.0], 1.0);
534        // Feasible dominates infeasible even with worse objectives
535        assert!(solution_dominates(&feasible, &infeasible));
536        assert!(!solution_dominates(&infeasible, &feasible));
537    }
538
539    // ======= Non-dominated sorting tests =======
540
541    #[test]
542    fn test_fast_non_dominated_sort_single_front() {
543        let objectives = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
544        let fronts = fast_non_dominated_sort(&objectives);
545        assert_eq!(fronts.len(), 1);
546        assert_eq!(fronts[0].len(), 3);
547    }
548
549    #[test]
550    fn test_fast_non_dominated_sort_two_fronts() {
551        let objectives = vec![
552            vec![1.0, 3.0], // Front 0
553            vec![3.0, 1.0], // Front 0
554            vec![2.0, 2.0], // Front 0
555            vec![3.0, 3.0], // Front 1 (dominated by [1,3], [2,2])
556        ];
557        let fronts = fast_non_dominated_sort(&objectives);
558        assert_eq!(fronts.len(), 2);
559        assert_eq!(fronts[0].len(), 3);
560        assert_eq!(fronts[1].len(), 1);
561        assert!(fronts[1].contains(&3));
562    }
563
564    #[test]
565    fn test_fast_non_dominated_sort_three_fronts() {
566        let objectives = vec![
567            vec![1.0, 4.0], // Front 0
568            vec![4.0, 1.0], // Front 0
569            vec![2.0, 3.0], // Front 1 (dominated by [1,4] on obj0 but not obj1... wait)
570            vec![3.0, 3.0], // Front 1 (dominated by [2,3] and [1,4])
571            vec![4.0, 4.0], // Front 2 (dominated by front 1 members)
572        ];
573        // Front 0: (1,4), (4,1), (2,3) -- all non-dominated
574        // Front 1: (3,3) -- dominated by (2,3)
575        // Front 2: (4,4) -- dominated by (3,3)
576        let fronts = fast_non_dominated_sort(&objectives);
577        assert_eq!(fronts.len(), 3);
578        assert_eq!(fronts[0].len(), 3); // (1,4), (4,1), (2,3)
579        assert_eq!(fronts[1].len(), 1); // (3,3)
580        assert_eq!(fronts[2].len(), 1); // (4,4)
581    }
582
583    #[test]
584    fn test_fast_non_dominated_sort_empty() {
585        let objectives: Vec<Vec<f64>> = vec![];
586        let fronts = fast_non_dominated_sort(&objectives);
587        assert!(fronts.is_empty());
588    }
589
590    #[test]
591    fn test_non_dominated_sort_solutions_assigns_ranks() {
592        let mut solutions = vec![
593            MultiObjectiveSolution::new(array![1.0], array![1.0, 3.0]),
594            MultiObjectiveSolution::new(array![2.0], array![3.0, 1.0]),
595            MultiObjectiveSolution::new(array![3.0], array![4.0, 4.0]),
596        ];
597        let fronts = non_dominated_sort_solutions(&mut solutions);
598        assert_eq!(fronts.len(), 2);
599        assert_eq!(solutions[0].rank, 0);
600        assert_eq!(solutions[1].rank, 0);
601        assert_eq!(solutions[2].rank, 1);
602    }
603
604    // ======= Crowding distance tests =======
605
606    #[test]
607    fn test_crowding_distance_boundary_infinite() {
608        let solutions = vec![
609            MultiObjectiveSolution::new(array![1.0], array![1.0, 5.0]),
610            MultiObjectiveSolution::new(array![2.0], array![3.0, 3.0]),
611            MultiObjectiveSolution::new(array![3.0], array![5.0, 1.0]),
612        ];
613        let front = vec![0, 1, 2];
614        let distances = crowding_distance(&solutions, &front);
615
616        // Boundary solutions get infinity
617        assert_eq!(distances[0], f64::INFINITY);
618        assert_eq!(distances[2], f64::INFINITY);
619        // Interior solution gets finite positive distance
620        assert!(distances[1].is_finite());
621        assert!(distances[1] > 0.0);
622    }
623
624    #[test]
625    fn test_crowding_distance_two_solutions() {
626        let solutions = vec![
627            MultiObjectiveSolution::new(array![1.0], array![1.0, 3.0]),
628            MultiObjectiveSolution::new(array![2.0], array![3.0, 1.0]),
629        ];
630        let front = vec![0, 1];
631        let distances = crowding_distance(&solutions, &front);
632        assert_eq!(distances[0], f64::INFINITY);
633        assert_eq!(distances[1], f64::INFINITY);
634    }
635
636    #[test]
637    fn test_crowding_distance_uniform_spacing() {
638        // Uniformly spaced solutions should have equal crowding distances for interior
639        let solutions = vec![
640            MultiObjectiveSolution::new(array![0.0], array![0.0, 4.0]),
641            MultiObjectiveSolution::new(array![1.0], array![1.0, 3.0]),
642            MultiObjectiveSolution::new(array![2.0], array![2.0, 2.0]),
643            MultiObjectiveSolution::new(array![3.0], array![3.0, 1.0]),
644            MultiObjectiveSolution::new(array![4.0], array![4.0, 0.0]),
645        ];
646        let front = vec![0, 1, 2, 3, 4];
647        let distances = crowding_distance(&solutions, &front);
648
649        // Interior solutions should have approximately equal distances
650        assert!(distances[0].is_infinite());
651        assert!(distances[4].is_infinite());
652        let d1 = distances[1];
653        let d2 = distances[2];
654        let d3 = distances[3];
655        assert!((d1 - d2).abs() < 1e-10);
656        assert!((d2 - d3).abs() < 1e-10);
657    }
658
659    #[test]
660    fn test_crowding_distance_empty() {
661        let solutions: Vec<MultiObjectiveSolution> = vec![];
662        let front: Vec<usize> = vec![];
663        let distances = crowding_distance(&solutions, &front);
664        assert!(distances.is_empty());
665    }
666
667    #[test]
668    fn test_assign_crowding_distances() {
669        let mut solutions = vec![
670            MultiObjectiveSolution::new(array![1.0], array![1.0, 5.0]),
671            MultiObjectiveSolution::new(array![2.0], array![3.0, 3.0]),
672            MultiObjectiveSolution::new(array![3.0], array![5.0, 1.0]),
673        ];
674        let front = vec![0, 1, 2];
675        assign_crowding_distances(&mut solutions, &front);
676
677        assert_eq!(solutions[0].crowding_distance, f64::INFINITY);
678        assert_eq!(solutions[2].crowding_distance, f64::INFINITY);
679        assert!(solutions[1].crowding_distance.is_finite());
680    }
681
682    // ======= Pareto front extraction tests =======
683
684    #[test]
685    fn test_pareto_front_extraction() {
686        let solutions = vec![
687            MultiObjectiveSolution::new(array![1.0], array![1.0, 3.0]),
688            MultiObjectiveSolution::new(array![2.0], array![2.0, 2.0]),
689            MultiObjectiveSolution::new(array![3.0], array![3.0, 1.0]),
690            MultiObjectiveSolution::new(array![4.0], array![2.5, 2.5]), // dominated
691        ];
692        let front = pareto_front(&solutions);
693        assert_eq!(front.len(), 3);
694    }
695
696    #[test]
697    fn test_pareto_front_all_non_dominated() {
698        let solutions = vec![
699            MultiObjectiveSolution::new(array![1.0], array![1.0, 5.0]),
700            MultiObjectiveSolution::new(array![2.0], array![3.0, 3.0]),
701            MultiObjectiveSolution::new(array![3.0], array![5.0, 1.0]),
702        ];
703        let front = pareto_front(&solutions);
704        assert_eq!(front.len(), 3);
705    }
706
707    #[test]
708    fn test_pareto_front_single_dominant() {
709        let solutions = vec![
710            MultiObjectiveSolution::new(array![1.0], array![1.0, 1.0]),
711            MultiObjectiveSolution::new(array![2.0], array![2.0, 2.0]),
712            MultiObjectiveSolution::new(array![3.0], array![3.0, 3.0]),
713        ];
714        let front = pareto_front(&solutions);
715        assert_eq!(front.len(), 1);
716        assert_eq!(front[0].objectives[0], 1.0);
717    }
718
719    #[test]
720    fn test_pareto_front_empty() {
721        let solutions: Vec<MultiObjectiveSolution> = vec![];
722        let front = pareto_front(&solutions);
723        assert!(front.is_empty());
724    }
725
726    #[test]
727    fn test_pareto_front_three_objectives() {
728        let solutions = vec![
729            MultiObjectiveSolution::new(array![1.0], array![1.0, 5.0, 5.0]),
730            MultiObjectiveSolution::new(array![2.0], array![5.0, 1.0, 5.0]),
731            MultiObjectiveSolution::new(array![3.0], array![5.0, 5.0, 1.0]),
732            MultiObjectiveSolution::new(array![4.0], array![3.0, 3.0, 3.0]), // dominated by none of above
733            MultiObjectiveSolution::new(array![5.0], array![6.0, 6.0, 6.0]), // dominated by all
734        ];
735        let front = pareto_front(&solutions);
736        assert_eq!(front.len(), 4); // First 4 are non-dominated
737    }
738
739    // ======= Hypervolume tests =======
740
741    #[test]
742    fn test_hypervolume_2d_simple() {
743        // Single point (1,1) with reference (2,2) => hypervolume = 1*1 = 1
744        let solutions = vec![MultiObjectiveSolution::new(array![0.0], array![1.0, 1.0])];
745        let hv = hypervolume(&solutions, &[2.0, 2.0]);
746        assert!((hv - 1.0).abs() < 1e-10, "Expected 1.0, got {}", hv);
747    }
748
749    #[test]
750    fn test_hypervolume_2d_two_points() {
751        // Two points forming a staircase:
752        // (1,3) and (3,1) with reference (4,4)
753        // HV = (3-1)*(4-3) + (4-3)*(4-1) = 2*1 + 1*3 = 5
754        let solutions = vec![
755            MultiObjectiveSolution::new(array![0.0], array![1.0, 3.0]),
756            MultiObjectiveSolution::new(array![0.0], array![3.0, 1.0]),
757        ];
758        let hv = hypervolume(&solutions, &[4.0, 4.0]);
759        assert!((hv - 5.0).abs() < 1e-10, "Expected 5.0, got {}", hv);
760    }
761
762    #[test]
763    fn test_hypervolume_2d_three_points() {
764        // (1,3), (2,2), (3,1) with reference (4,4)
765        // HV = (2-1)*(4-3) + (3-2)*(4-2) + (4-3)*(4-1) = 1 + 2 + 3 = 6
766        let solutions = vec![
767            MultiObjectiveSolution::new(array![0.0], array![1.0, 3.0]),
768            MultiObjectiveSolution::new(array![0.0], array![2.0, 2.0]),
769            MultiObjectiveSolution::new(array![0.0], array![3.0, 1.0]),
770        ];
771        let hv = hypervolume(&solutions, &[4.0, 4.0]);
772        assert!((hv - 6.0).abs() < 1e-10, "Expected 6.0, got {}", hv);
773    }
774
775    #[test]
776    fn test_hypervolume_empty() {
777        let solutions: Vec<MultiObjectiveSolution> = vec![];
778        let hv = hypervolume(&solutions, &[4.0, 4.0]);
779        assert_eq!(hv, 0.0);
780    }
781
782    #[test]
783    fn test_hypervolume_point_at_reference_boundary() {
784        // Point at reference boundary should not contribute
785        let solutions = vec![MultiObjectiveSolution::new(array![0.0], array![4.0, 4.0])];
786        let hv = hypervolume(&solutions, &[4.0, 4.0]);
787        assert_eq!(hv, 0.0);
788    }
789
790    #[test]
791    fn test_hypervolume_1d() {
792        let solutions = vec![
793            MultiObjectiveSolution::new(array![0.0], array![2.0]),
794            MultiObjectiveSolution::new(array![0.0], array![3.0]),
795        ];
796        let hv = hypervolume(&solutions, &[5.0]);
797        // 1D HV = ref - min = 5 - 2 = 3
798        assert!((hv - 3.0).abs() < 1e-10);
799    }
800
801    #[test]
802    fn test_hypervolume_3d() {
803        // Single point (1,1,1) with reference (2,2,2) => HV = 1*1*1 = 1
804        let solutions = vec![MultiObjectiveSolution::new(
805            array![0.0],
806            array![1.0, 1.0, 1.0],
807        )];
808        let hv = hypervolume(&solutions, &[2.0, 2.0, 2.0]);
809        assert!((hv - 1.0).abs() < 1e-10, "Expected 1.0, got {}", hv);
810    }
811
812    #[test]
813    fn test_hypervolume_from_objectives_convenience() {
814        let objectives = vec![vec![1.0, 3.0], vec![3.0, 1.0]];
815        let hv = hypervolume_from_objectives(&objectives, &[4.0, 4.0]);
816        assert!((hv - 5.0).abs() < 1e-10);
817    }
818
819    // ======= Crowded comparison tests =======
820
821    #[test]
822    fn test_crowded_comparison_different_ranks() {
823        let mut a = MultiObjectiveSolution::new(array![1.0], array![1.0, 1.0]);
824        a.rank = 0;
825        a.crowding_distance = 1.0;
826
827        let mut b = MultiObjectiveSolution::new(array![2.0], array![2.0, 2.0]);
828        b.rank = 1;
829        b.crowding_distance = 5.0;
830
831        // a should be preferred (lower rank)
832        assert_eq!(crowded_comparison(&a, &b), Ordering::Less);
833    }
834
835    #[test]
836    fn test_crowded_comparison_same_rank_different_distance() {
837        let mut a = MultiObjectiveSolution::new(array![1.0], array![1.0, 1.0]);
838        a.rank = 0;
839        a.crowding_distance = 5.0;
840
841        let mut b = MultiObjectiveSolution::new(array![2.0], array![2.0, 2.0]);
842        b.rank = 0;
843        b.crowding_distance = 1.0;
844
845        // a should be preferred (higher crowding distance)
846        assert_eq!(crowded_comparison(&a, &b), Ordering::Less);
847    }
848
849    #[test]
850    fn test_crowded_comparison_equal() {
851        let mut a = MultiObjectiveSolution::new(array![1.0], array![1.0, 1.0]);
852        a.rank = 0;
853        a.crowding_distance = 3.0;
854
855        let mut b = MultiObjectiveSolution::new(array![2.0], array![2.0, 2.0]);
856        b.rank = 0;
857        b.crowding_distance = 3.0;
858
859        assert_eq!(crowded_comparison(&a, &b), Ordering::Equal);
860    }
861}