Skip to main content

scirs2_optimize/multiobjective/
pareto.rs

1//! Pareto front utilities for multi-objective optimization.
2//!
3//! This module provides core Pareto analysis tools:
4//! - Dominance checking (`dominates`)
5//! - Non-dominated sorting — NSGA-II style fronts (`non_dominated_sort`)
6//! - Crowding distance for diversity preservation (`crowding_distance`)
7//! - Exact 2-D hypervolume (`hypervolume_2d`)
8//! - WFG exact hypervolume for arbitrary dimensionality (`hypervolume_indicator`)
9//! - Additive epsilon-indicator (`epsilon_indicator`)
10//! - Generational Distance convergence metric (`generational_distance`)
11//! - Spread / uniformity metric (`spread_metric`)
12//!
13//! # Conventions
14//!
15//! All functions assume **minimisation** objectives: a lower value is better on
16//! every objective axis.  Inequalities for dominance, barrier feasibility, and
17//! hypervolume reference follow this convention.
18//!
19//! # References
20//!
21//! - Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. (2002). "A fast and
22//!   elitist multiobjective genetic algorithm: NSGA-II." IEEE TEC 6(2):182-197.
23//! - While, L., Bradstreet, L., & Barone, L. (2012). "A fast way of calculating
24//!   exact hypervolumes." IEEE TEC 16(1):86-95. (WFG algorithm)
25//! - Zitzler, E., Thiele, L., Laumanns, M., Fonseca, C. M., & da Fonseca, V.G.
26//!   (2003). "Performance assessment of multiobjective optimizers: an analysis
27//!   and review." IEEE TEC 7(2):117-132.
28
29// ─────────────────────────────────────────────────────────────────────────────
30// Dominance
31// ─────────────────────────────────────────────────────────────────────────────
32
33/// Returns `true` if objective vector `a` Pareto-dominates `b`.
34///
35/// `a` dominates `b` when:
36/// - `a[i] <= b[i]` for **all** objectives `i`, AND
37/// - `a[j] < b[j]` for **at least one** objective `j`.
38///
39/// Both slices must have the same length; if they have different lengths the
40/// function returns `false` (never dominates, defensively).
41///
42/// # Examples
43/// ```
44/// use scirs2_optimize::multiobjective::pareto::dominates;
45/// assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
46/// assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
47/// assert!(!dominates(&[1.0, 2.0], &[1.0, 2.0])); // equal: not dominating
48/// ```
49pub fn dominates(a: &[f64], b: &[f64]) -> bool {
50    if a.len() != b.len() {
51        return false;
52    }
53    let mut any_strictly_better = false;
54    for (ai, bi) in a.iter().zip(b.iter()) {
55        if ai > bi {
56            return false;
57        }
58        if ai < bi {
59            any_strictly_better = true;
60        }
61    }
62    any_strictly_better
63}
64
65// ─────────────────────────────────────────────────────────────────────────────
66// Non-dominated sorting (NSGA-II)
67// ─────────────────────────────────────────────────────────────────────────────
68
69/// Fast non-dominated sorting (NSGA-II style).
70///
71/// Partitions `population` into a sequence of *Pareto fronts*:
72/// - **Front 0** (`result[0]`) — the non-dominated set (Pareto-optimal).
73/// - **Front 1** (`result[1]`) — points dominated only by those in front 0.
74/// - And so on.
75///
76/// Each element of the returned `Vec` is a `Vec<usize>` of indices into
77/// `population`.
78///
79/// Implements the O(M · N²) algorithm from Deb et al. (2002), where M is the
80/// number of objectives and N is the population size.
81///
82/// # Arguments
83/// * `population` - Slice of objective vectors; each inner `Vec<f64>` must
84///   have the same length (number of objectives).
85///
86/// # Returns
87/// A `Vec<Vec<usize>>` of fronts; `result[0]` contains the indices of
88/// Pareto-optimal solutions.
89///
90/// # Examples
91/// ```
92/// use scirs2_optimize::multiobjective::pareto::non_dominated_sort;
93/// let pts = vec![vec![1.0,2.0], vec![2.0,1.0], vec![3.0,3.0]];
94/// let fronts = non_dominated_sort(&pts);
95/// assert_eq!(fronts[0].len(), 2);  // (1,2) and (2,1) are non-dominated
96/// assert_eq!(fronts[1].len(), 1);  // (3,3) is dominated
97/// ```
98pub fn non_dominated_sort(population: &[Vec<f64>]) -> Vec<Vec<usize>> {
99    let n = population.len();
100    if n == 0 {
101        return vec![];
102    }
103
104    let mut domination_count = vec![0usize; n]; // # solutions that dominate i
105    let mut dominated_set: Vec<Vec<usize>> = vec![vec![]; n]; // solutions that i dominates
106
107    for i in 0..n {
108        for j in (i + 1)..n {
109            if dominates(&population[i], &population[j]) {
110                dominated_set[i].push(j);
111                domination_count[j] += 1;
112            } else if dominates(&population[j], &population[i]) {
113                dominated_set[j].push(i);
114                domination_count[i] += 1;
115            }
116        }
117    }
118
119    let mut fronts: Vec<Vec<usize>> = Vec::new();
120    let mut current_front: Vec<usize> = (0..n).filter(|&i| domination_count[i] == 0).collect();
121
122    while !current_front.is_empty() {
123        let mut next_front: Vec<usize> = Vec::new();
124        for &i in &current_front {
125            for &j in &dominated_set[i] {
126                domination_count[j] -= 1;
127                if domination_count[j] == 0 {
128                    next_front.push(j);
129                }
130            }
131        }
132        fronts.push(current_front);
133        current_front = next_front;
134    }
135
136    fronts
137}
138
139// ─────────────────────────────────────────────────────────────────────────────
140// Crowding distance
141// ─────────────────────────────────────────────────────────────────────────────
142
143/// Compute the crowding distance for each solution in a single Pareto front.
144///
145/// The crowding distance is the average side length of the largest hypercuboid
146/// that encloses a solution without including any other front member.  It is
147/// used in NSGA-II to maintain diversity: solutions with larger crowding
148/// distances (less crowded neighbourhood) are preferred.
149///
150/// Solutions at the extremes of each objective axis are assigned
151/// `f64::INFINITY`.
152///
153/// # Arguments
154/// * `front` - Objective vectors of solutions **already in a single front**.
155///   Each inner `Vec<f64>` must have the same length.
156///
157/// # Returns
158/// A `Vec<f64>` with one crowding-distance value per solution (same order as
159/// `front`).
160///
161/// # Examples
162/// ```
163/// use scirs2_optimize::multiobjective::pareto::crowding_distance;
164/// let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
165/// let cd = crowding_distance(&front);
166/// assert_eq!(cd.len(), 3);
167/// // Boundary points get infinity
168/// assert!(cd[0].is_infinite() || cd[2].is_infinite());
169/// ```
170pub fn crowding_distance(front: &[Vec<f64>]) -> Vec<f64> {
171    let n = front.len();
172    if n == 0 {
173        return vec![];
174    }
175    if n == 1 {
176        return vec![f64::INFINITY];
177    }
178    if n == 2 {
179        return vec![f64::INFINITY; 2];
180    }
181
182    let n_obj = front[0].len();
183    let mut distances = vec![0.0_f64; n];
184
185    for m in 0..n_obj {
186        // Sort indices by objective m
187        let mut sorted_idx: Vec<usize> = (0..n).collect();
188        sorted_idx.sort_by(|&a, &b| {
189            front[a][m]
190                .partial_cmp(&front[b][m])
191                .unwrap_or(std::cmp::Ordering::Equal)
192        });
193
194        // Boundary solutions get infinite distance
195        distances[sorted_idx[0]] = f64::INFINITY;
196        distances[sorted_idx[n - 1]] = f64::INFINITY;
197
198        let obj_min = front[sorted_idx[0]][m];
199        let obj_max = front[sorted_idx[n - 1]][m];
200        let range = obj_max - obj_min;
201
202        if range < f64::EPSILON {
203            // All values identical on this objective — no contribution
204            continue;
205        }
206
207        for k in 1..(n - 1) {
208            let prev_val = front[sorted_idx[k - 1]][m];
209            let next_val = front[sorted_idx[k + 1]][m];
210            if distances[sorted_idx[k]].is_finite() {
211                distances[sorted_idx[k]] += (next_val - prev_val) / range;
212            }
213            // If already infinity (from another objective), leave as infinity
214        }
215    }
216
217    distances
218}
219
220// ─────────────────────────────────────────────────────────────────────────────
221// Hypervolume (2-D exact sweep)
222// ─────────────────────────────────────────────────────────────────────────────
223
224/// Exact 2-D hypervolume indicator.
225///
226/// Computes the area of the region in objective space that is dominated by at
227/// least one point in `front` and bounded above by `reference`.  All
228/// objectives are assumed to be **minimised**.
229///
230/// The algorithm sorts the (non-dominated) front points by the first objective
231/// and accumulates rectangles from right to left (O(N log N) sweep).
232///
233/// # Arguments
234/// * `front`     - Objective vectors; may contain dominated points (they are
235///   filtered before computing the area).  Each inner slice must have length 2.
236/// * `reference` - Reference point with exactly 2 components; must lie above
237///   all front points on both axes to yield a non-zero result.
238///
239/// # Returns
240/// The exact 2-D hypervolume.  Returns `0.0` if `front` is empty or all
241/// front points exceed the reference on at least one axis.
242///
243/// # Examples
244/// ```
245/// use scirs2_optimize::multiobjective::pareto::hypervolume_2d;
246/// // Front: (0,2) and (2,0), reference: (3,3) => area = 5
247/// let front = vec![vec![0.0_f64, 2.0], vec![2.0, 0.0]];
248/// let hv = hypervolume_2d(&front, &[3.0, 3.0]);
249/// assert!((hv - 5.0).abs() < 1e-10);
250/// ```
251pub fn hypervolume_2d(front: &[Vec<f64>], reference: &[f64]) -> f64 {
252    if front.is_empty() || reference.len() < 2 {
253        return 0.0;
254    }
255    let ref_x = reference[0];
256    let ref_y = reference[1];
257
258    // Keep only points strictly dominated by the reference on both axes
259    let mut pts: Vec<(f64, f64)> = front
260        .iter()
261        .filter(|p| p.len() >= 2 && p[0] < ref_x && p[1] < ref_y)
262        .map(|p| (p[0], p[1]))
263        .collect();
264
265    if pts.is_empty() {
266        return 0.0;
267    }
268
269    // Sort ascending by f1
270    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
271
272    // Build 2-D Pareto front: keep only strictly decreasing f2 subsequence
273    let mut pareto_2d: Vec<(f64, f64)> = Vec::new();
274    let mut min_f2 = f64::INFINITY;
275    for (f1, f2) in &pts {
276        if *f2 < min_f2 {
277            min_f2 = *f2;
278            pareto_2d.push((*f1, *f2));
279        }
280    }
281
282    // Right-to-left sweep: accumulate rectangles
283    let np = pareto_2d.len();
284    let mut volume = 0.0;
285    for i in (0..np).rev() {
286        let x_start = pareto_2d[i].0;
287        let x_end = if i + 1 < np {
288            pareto_2d[i + 1].0
289        } else {
290            ref_x
291        };
292        let y = pareto_2d[i].1;
293        volume += (x_end - x_start) * (ref_y - y);
294    }
295
296    volume
297}
298
299// ─────────────────────────────────────────────────────────────────────────────
300// Hypervolume indicator (WFG — arbitrary dimensionality)
301// ─────────────────────────────────────────────────────────────────────────────
302
303/// Compute the exact hypervolume indicator using the WFG algorithm.
304///
305/// Calculates the volume of objective space dominated by `front` and bounded
306/// by `reference`.  Works for any number of objectives ≥ 1.
307///
308/// For 2-D problems the call delegates to the exact O(N log N) sweep.
309/// For higher dimensions the recursive WFG slicing algorithm is used, which
310/// has worst-case complexity O(N^(M-1) log N) where M is the number of
311/// objectives.
312///
313/// # Arguments
314/// * `front`     - Objective vectors (each of equal length M).  May contain
315///   points that do not strictly dominate the reference; these are filtered.
316/// * `reference` - Reference point of length M; should dominate all front
317///   points to get a non-zero result.
318///
319/// # Returns
320/// The exact hypervolume value ≥ 0.
321///
322/// # Examples
323/// ```
324/// use scirs2_optimize::multiobjective::pareto::hypervolume_indicator;
325/// // 3-D: single point (1,1,1) with reference (2,2,2) => volume = 1
326/// let front = vec![vec![1.0_f64, 1.0, 1.0]];
327/// let hv = hypervolume_indicator(&front, &[2.0, 2.0, 2.0]);
328/// assert!((hv - 1.0).abs() < 1e-10);
329/// ```
330pub fn hypervolume_indicator(front: &[Vec<f64>], reference: &[f64]) -> f64 {
331    if front.is_empty() || reference.is_empty() {
332        return 0.0;
333    }
334    let n_obj = reference.len();
335
336    // Filter to keep only points strictly dominated by the reference
337    let mut pts: Vec<Vec<f64>> = front
338        .iter()
339        .filter(|p| p.len() == n_obj && p.iter().zip(reference.iter()).all(|(o, r)| o < r))
340        .cloned()
341        .collect();
342
343    if pts.is_empty() {
344        return 0.0;
345    }
346
347    wfg_hypervolume(&mut pts, reference)
348}
349
350/// Internal WFG recursive hypervolume computation.
351///
352/// All points in `pts` must strictly dominate `reference` on every objective
353/// (i.e., `pts[i][j] < reference[j]` for all i, j).
354fn wfg_hypervolume(pts: &mut Vec<Vec<f64>>, reference: &[f64]) -> f64 {
355    let n_dim = reference.len();
356
357    if pts.is_empty() {
358        return 0.0;
359    }
360
361    // Base case: 1-D
362    if n_dim == 1 {
363        let min_obj = pts.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
364        return reference[0] - min_obj;
365    }
366
367    // Base case: 2-D — use the exact sweep
368    if n_dim == 2 {
369        let owned: Vec<Vec<f64>> = pts.clone();
370        return hypervolume_2d(&owned, reference);
371    }
372
373    // General case: sort by last objective ascending
374    pts.sort_by(|a, b| {
375        a[n_dim - 1]
376            .partial_cmp(&b[n_dim - 1])
377            .unwrap_or(std::cmp::Ordering::Equal)
378    });
379
380    let n = pts.len();
381    let mut volume = 0.0;
382    let sub_ref: Vec<f64> = reference[..n_dim - 1].to_vec();
383
384    for i in 0..n {
385        let slice_start = pts[i][n_dim - 1];
386        let slice_end = if i + 1 < n {
387            pts[i + 1][n_dim - 1]
388        } else {
389            reference[n_dim - 1]
390        };
391
392        let slice_height = slice_end - slice_start;
393        if slice_height <= 0.0 {
394            continue;
395        }
396
397        // Project points 0..=i onto the first (n_dim-1) objectives
398        let mut sub_pts: Vec<Vec<f64>> =
399            pts[..=i].iter().map(|p| p[..n_dim - 1].to_vec()).collect();
400
401        // Remove dominated points in the sub-space for efficiency
402        sub_pts = filter_non_dominated_internal(&sub_pts);
403
404        let sub_hv = wfg_hypervolume(&mut sub_pts, &sub_ref);
405        volume += slice_height * sub_hv;
406    }
407
408    volume
409}
410
411/// Remove dominated points from a point set (keep non-dominated subset).
412fn filter_non_dominated_internal(pts: &[Vec<f64>]) -> Vec<Vec<f64>> {
413    if pts.is_empty() {
414        return vec![];
415    }
416    let n = pts.len();
417    let mut dominated = vec![false; n];
418
419    for i in 0..n {
420        if dominated[i] {
421            continue;
422        }
423        for j in 0..n {
424            if i == j || dominated[j] {
425                continue;
426            }
427            if dominates(&pts[i], &pts[j]) {
428                dominated[j] = true;
429            }
430        }
431    }
432
433    pts.iter()
434        .enumerate()
435        .filter(|(i, _)| !dominated[*i])
436        .map(|(_, p)| p.clone())
437        .collect()
438}
439
440// ─────────────────────────────────────────────────────────────────────────────
441// Epsilon indicator
442// ─────────────────────────────────────────────────────────────────────────────
443
444/// Additive epsilon-indicator (I_ε+).
445///
446/// Returns the smallest scalar ε such that for every point **p** in the
447/// reference (true) front there exists a point **q** in the approximate front
448/// satisfying `q[i] - ε ≤ p[i]` for all objectives i (i.e., q ε-dominates p).
449///
450/// Interpretation:
451/// - ε < 0: `approx` is, on average, *better* than `reference`.
452/// - ε = 0: `approx` weakly dominates every point in `reference`.
453/// - ε > 0: `approx` is ε away from covering `reference`.
454///
455/// Smaller values (closer to 0 or negative) indicate a better approximation.
456///
457/// Returns `f64::INFINITY` if either input is empty.
458///
459/// # Arguments
460/// * `approx`    - Approximate Pareto front being evaluated.
461/// * `reference` - True (or target) reference front.
462///
463/// # Examples
464/// ```
465/// use scirs2_optimize::multiobjective::pareto::epsilon_indicator;
466/// let reference = vec![vec![0.0_f64, 1.0], vec![1.0, 0.0]];
467/// let approx    = vec![vec![0.0, 1.0], vec![1.0, 0.0]]; // identical
468/// assert!(epsilon_indicator(&approx, &reference).abs() < 1e-10);
469/// ```
470pub fn epsilon_indicator(approx: &[Vec<f64>], reference: &[Vec<f64>]) -> f64 {
471    if approx.is_empty() || reference.is_empty() {
472        return f64::INFINITY;
473    }
474
475    // max over reference points of (min over approx points of max_i(q[i] - p[i]))
476    reference
477        .iter()
478        .map(|p| {
479            approx
480                .iter()
481                .map(|q| {
482                    // Minimum shift ε so that q[i] - ε <= p[i] for all i
483                    // => ε >= q[i] - p[i]  for all i => ε = max_i(q[i] - p[i])
484                    q.iter()
485                        .zip(p.iter())
486                        .map(|(qi, pi)| qi - pi)
487                        .fold(f64::NEG_INFINITY, f64::max)
488                })
489                .fold(f64::INFINITY, f64::min)
490        })
491        .fold(f64::NEG_INFINITY, f64::max)
492}
493
494// ─────────────────────────────────────────────────────────────────────────────
495// Generational Distance
496// ─────────────────────────────────────────────────────────────────────────────
497
498/// Generational Distance (GD).
499///
500/// For each solution in `approx`, finds the nearest point in `reference` and
501/// returns the average of these minimum distances.
502///
503/// A lower GD indicates that `approx` is closer to the reference front.
504/// GD = 0 when every point in `approx` lies on the reference front.
505///
506/// Note: GD measures **convergence** but not diversity.  It can be 0 even if
507/// `approx` covers only a small portion of the reference front.
508///
509/// Returns `f64::INFINITY` if either input is empty.
510///
511/// # Arguments
512/// * `approx`    - Approximate Pareto front being evaluated.
513/// * `reference` - True (or target) reference front.
514///
515/// # Examples
516/// ```
517/// use scirs2_optimize::multiobjective::pareto::generational_distance;
518/// let reference = vec![vec![0.0_f64, 1.0], vec![1.0, 0.0]];
519/// let approx    = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
520/// assert!(generational_distance(&approx, &reference) < 1e-10);
521/// ```
522pub fn generational_distance(approx: &[Vec<f64>], reference: &[Vec<f64>]) -> f64 {
523    if approx.is_empty() || reference.is_empty() {
524        return f64::INFINITY;
525    }
526
527    let sum: f64 = approx
528        .iter()
529        .map(|q| {
530            reference
531                .iter()
532                .map(|p| euclidean_distance(q, p))
533                .fold(f64::INFINITY, f64::min)
534        })
535        .sum();
536
537    sum / approx.len() as f64
538}
539
540// ─────────────────────────────────────────────────────────────────────────────
541// Spread metric
542// ─────────────────────────────────────────────────────────────────────────────
543
544/// Spread metric (Δ) — measures the uniformity of a Pareto front.
545///
546/// Computes a normalised coefficient of variation of nearest-neighbour
547/// distances over the front.  A value of 0 indicates a perfectly uniform
548/// distribution; larger values indicate gaps or clustering.
549///
550/// The metric is defined as:
551/// ```text
552/// Δ = σ(d_nn) / mean(d_nn)
553/// ```
554/// where `d_nn[i]` is the distance from solution `i` to its nearest
555/// neighbour in the front.
556///
557/// Returns `0.0` for fronts with fewer than 2 points.
558///
559/// # Arguments
560/// * `front` - Objective vectors of solutions in a single Pareto front.
561///
562/// # Examples
563/// ```
564/// use scirs2_optimize::multiobjective::pareto::spread_metric;
565/// // Uniformly spaced front → near-zero spread
566/// let front: Vec<Vec<f64>> = (0..5)
567///     .map(|i| { let f1 = i as f64 * 0.25; vec![f1, 1.0 - f1] })
568///     .collect();
569/// assert!(spread_metric(&front) < 0.05);
570/// ```
571pub fn spread_metric(front: &[Vec<f64>]) -> f64 {
572    let n = front.len();
573    if n < 2 {
574        return 0.0;
575    }
576
577    let nn_dists: Vec<f64> = (0..n)
578        .map(|i| {
579            (0..n)
580                .filter(|&j| j != i)
581                .map(|j| euclidean_distance(&front[i], &front[j]))
582                .fold(f64::INFINITY, f64::min)
583        })
584        .collect();
585
586    let mean = nn_dists.iter().sum::<f64>() / n as f64;
587    if mean < f64::EPSILON {
588        return 0.0;
589    }
590
591    let variance = nn_dists.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n as f64;
592    variance.sqrt() / mean
593}
594
595// ─────────────────────────────────────────────────────────────────────────────
596// Internal helpers
597// ─────────────────────────────────────────────────────────────────────────────
598
599#[inline]
600fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
601    a.iter()
602        .zip(b.iter())
603        .map(|(x, y)| (x - y).powi(2))
604        .sum::<f64>()
605        .sqrt()
606}
607
608// ─────────────────────────────────────────────────────────────────────────────
609// Tests
610// ─────────────────────────────────────────────────────────────────────────────
611
612// ─────────────────────────────────────────────────────────────────────────────
613// Additional Pareto utilities
614// ─────────────────────────────────────────────────────────────────────────────
615
616/// Compute Pareto rank (front index) for each individual in `population`.
617///
618/// Returns a vector of rank values, one per individual, where rank 0 means
619/// the individual is in the non-dominated (Pareto-optimal) set (front 0),
620/// rank 1 means it is dominated only by front-0 individuals, and so on.
621///
622/// This is equivalent to the non-dominated sorting in NSGA-II, but returns
623/// a flat rank array rather than grouped fronts.
624///
625/// # Examples
626/// ```
627/// use scirs2_optimize::multiobjective::pareto::pareto_rank;
628/// let pop = vec![vec![1.0,1.0], vec![2.0,2.0], vec![3.0,3.0]];
629/// let ranks = pareto_rank(&pop);
630/// assert_eq!(ranks[0], 0); // non-dominated
631/// assert_eq!(ranks[1], 1); // dominated by [1,1]
632/// assert_eq!(ranks[2], 2); // dominated by both
633/// ```
634pub fn pareto_rank(population: &[Vec<f64>]) -> Vec<usize> {
635    if population.is_empty() {
636        return vec![];
637    }
638
639    let fronts = non_dominated_sort(population);
640    let mut ranks = vec![0usize; population.len()];
641
642    for (front_idx, front) in fronts.iter().enumerate() {
643        for &i in front {
644            ranks[i] = front_idx;
645        }
646    }
647
648    ranks
649}
650
651/// Return the indices of individuals that form the Pareto front (rank 0).
652///
653/// Equivalent to `non_dominated_sort(population)[0]` but more ergonomic when
654/// only the first front is needed.  Returns an empty vector for empty input.
655///
656/// # Examples
657/// ```
658/// use scirs2_optimize::multiobjective::pareto::pareto_front;
659/// let pop = vec![
660///     vec![1.0, 2.0], // non-dominated
661///     vec![2.0, 1.0], // non-dominated
662///     vec![3.0, 3.0], // dominated
663/// ];
664/// let front = pareto_front(&pop);
665/// assert_eq!(front.len(), 2);
666/// assert!(!front.contains(&2));
667/// ```
668pub fn pareto_front(population: &[Vec<f64>]) -> Vec<usize> {
669    if population.is_empty() {
670        return vec![];
671    }
672
673    let fronts = non_dominated_sort(population);
674    fronts.into_iter().next().unwrap_or_default()
675}
676
677/// Compute the 2-D Pareto front of a set of points in O(n log n) time.
678///
679/// Returns the indices of the non-dominated points sorted by first objective
680/// ascending.  This is more efficient than the general `pareto_front` for the
681/// common 2-D case.
682///
683/// # Arguments
684/// * `points` — Slice of 2-D points `(f1, f2)`, all objectives minimised.
685///
686/// # Returns
687/// Indices into `points` of the Pareto-optimal points (front rank 0),
688/// ordered by `f1` ascending.  Returns an empty `Vec` if `points` is empty.
689///
690/// # Algorithm
691/// Sort by `f1` ascending; sweep through maintaining the minimum `f2` seen
692/// so far.  A point is non-dominated iff its `f2` is strictly less than all
693/// previous `f2` values (since `f1` is already non-decreasing).
694///
695/// # Examples
696/// ```
697/// use scirs2_optimize::multiobjective::pareto::pareto_front_2d;
698/// let points = &[(0.0_f64, 1.0_f64), (1.0, 0.0), (0.5, 0.5), (0.7, 0.8)];
699/// let front = pareto_front_2d(points);
700/// // (0.7, 0.8) is dominated by (0.5, 0.5); others are non-dominated
701/// assert_eq!(front.len(), 3);
702/// ```
703pub fn pareto_front_2d(points: &[(f64, f64)]) -> Vec<usize> {
704    if points.is_empty() {
705        return vec![];
706    }
707
708    // Create index list sorted by f1 ascending, then f2 ascending for ties
709    let mut sorted_idx: Vec<usize> = (0..points.len()).collect();
710    sorted_idx.sort_by(|&a, &b| {
711        points[a]
712            .0
713            .partial_cmp(&points[b].0)
714            .unwrap_or(std::cmp::Ordering::Equal)
715            .then_with(|| {
716                points[a]
717                    .1
718                    .partial_cmp(&points[b].1)
719                    .unwrap_or(std::cmp::Ordering::Equal)
720            })
721    });
722
723    // Sweep: keep track of minimum f2 seen so far
724    // A point is on the Pareto front iff its f2 is strictly less than all
725    // previously accepted front members' f2 values.
726    let mut front: Vec<usize> = Vec::new();
727    let mut min_f2 = f64::INFINITY;
728
729    for idx in sorted_idx {
730        let (_, f2) = points[idx];
731        if f2 < min_f2 {
732            front.push(idx);
733            min_f2 = f2;
734        }
735    }
736
737    front
738}
739
740/// Inverted Generational Distance (IGD) from `pareto.rs`.
741///
742/// Averages the distance from each point in `true_front` to its nearest
743/// neighbour in `approx_front`.  A value of 0 means `approx_front` covers
744/// every true-front point exactly.
745///
746/// # Arguments
747/// * `approx`     - Approximated Pareto front being evaluated.
748/// * `true_front` - Reference (true) Pareto front.
749///
750/// # Returns
751/// IGD ∈ [0, ∞).  Returns `f64::INFINITY` if either input is empty.
752///
753/// # Examples
754/// ```
755/// use scirs2_optimize::multiobjective::pareto::igd;
756/// let tf = vec![vec![0.0,1.0], vec![1.0,0.0]];
757/// let af = tf.clone();
758/// assert!(igd(&af, &tf) < 1e-10);
759/// ```
760pub fn igd(approx: &[Vec<f64>], true_front: &[Vec<f64>]) -> f64 {
761    if true_front.is_empty() || approx.is_empty() {
762        return f64::INFINITY;
763    }
764
765    let sum: f64 = true_front
766        .iter()
767        .map(|tp| {
768            approx
769                .iter()
770                .map(|ap| euclidean_distance(tp, ap))
771                .fold(f64::INFINITY, f64::min)
772        })
773        .sum();
774
775    sum / true_front.len() as f64
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    // ── dominates ─────────────────────────────────────────────────────────────
783
784    #[test]
785    fn test_dominates_basic_2d() {
786        assert!(dominates(&[1.0, 1.0], &[2.0, 2.0]));
787        assert!(dominates(&[1.0, 2.0], &[2.0, 2.0]));
788        assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
789        assert!(!dominates(&[2.0, 2.0], &[2.0, 2.0])); // equal
790    }
791
792    #[test]
793    fn test_dominates_three_objectives() {
794        assert!(dominates(&[1.0, 1.0, 1.0], &[2.0, 2.0, 2.0]));
795        assert!(!dominates(&[1.0, 2.0, 1.0], &[1.0, 1.0, 2.0]));
796    }
797
798    #[test]
799    fn test_dominates_different_lengths() {
800        // Different lengths → false (defensive)
801        assert!(!dominates(&[1.0, 2.0], &[3.0]));
802    }
803
804    // ── non_dominated_sort ────────────────────────────────────────────────────
805
806    #[test]
807    fn test_non_dominated_sort_trivial() {
808        let pts = vec![vec![1.0, 2.0], vec![2.0, 1.0], vec![3.0, 3.0]];
809        let fronts = non_dominated_sort(&pts);
810        assert_eq!(fronts.len(), 2);
811        assert_eq!(fronts[0].len(), 2);
812        assert_eq!(fronts[1].len(), 1);
813        assert!(fronts[1].contains(&2));
814    }
815
816    #[test]
817    fn test_non_dominated_sort_all_optimal() {
818        let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
819        let fronts = non_dominated_sort(&pts);
820        assert_eq!(fronts.len(), 1);
821        assert_eq!(fronts[0].len(), 3);
822    }
823
824    #[test]
825    fn test_non_dominated_sort_chain() {
826        let pts = vec![vec![1.0, 1.0], vec![2.0, 2.0], vec![3.0, 3.0]];
827        let fronts = non_dominated_sort(&pts);
828        assert_eq!(fronts.len(), 3);
829        assert_eq!(fronts[0], vec![0]);
830        assert_eq!(fronts[1], vec![1]);
831        assert_eq!(fronts[2], vec![2]);
832    }
833
834    #[test]
835    fn test_non_dominated_sort_empty() {
836        assert!(non_dominated_sort(&[]).is_empty());
837    }
838
839    // ── crowding_distance ────────────────────────────────────────────────────
840
841    #[test]
842    fn test_crowding_distance_three_points() {
843        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
844        let cd = crowding_distance(&front);
845        assert_eq!(cd.len(), 3);
846        // Boundary points (0,1) and (1,0) should have infinite distance
847        // The middle point (0.5, 0.5) should have finite distance
848        let inf_count = cd.iter().filter(|d| d.is_infinite()).count();
849        assert_eq!(
850            inf_count, 2,
851            "Expected 2 boundary points with inf cd, got {:?}",
852            cd
853        );
854        assert!(cd[1].is_finite(), "Middle point should have finite cd");
855    }
856
857    #[test]
858    fn test_crowding_distance_two_points() {
859        let front = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
860        let cd = crowding_distance(&front);
861        assert!(cd.iter().all(|d| d.is_infinite()));
862    }
863
864    #[test]
865    fn test_crowding_distance_single_point() {
866        let cd = crowding_distance(&[vec![0.5, 0.5]]);
867        assert_eq!(cd, vec![f64::INFINITY]);
868    }
869
870    #[test]
871    fn test_crowding_distance_empty() {
872        let cd = crowding_distance(&[]);
873        assert!(cd.is_empty());
874    }
875
876    #[test]
877    fn test_crowding_distance_identical_objectives() {
878        // All objectives identical on one axis — should not panic
879        let front = vec![vec![0.0, 1.0], vec![0.0, 0.5], vec![0.0, 0.0]];
880        let cd = crowding_distance(&front);
881        assert_eq!(cd.len(), 3);
882    }
883
884    // ── hypervolume_2d ───────────────────────────────────────────────────────
885
886    #[test]
887    fn test_hypervolume_2d_empty() {
888        assert_eq!(hypervolume_2d(&[], &[2.0, 2.0]), 0.0);
889    }
890
891    #[test]
892    fn test_hypervolume_2d_single_point() {
893        let front = vec![vec![1.0, 1.0]];
894        let hv = hypervolume_2d(&front, &[2.0, 2.0]);
895        assert!((hv - 1.0).abs() < 1e-10, "Expected 1.0, got {hv}");
896    }
897
898    #[test]
899    fn test_hypervolume_2d_two_points() {
900        // (0,2) and (2,0), reference (3,3) => area = 5
901        let front = vec![vec![0.0, 2.0], vec![2.0, 0.0]];
902        let hv = hypervolume_2d(&front, &[3.0, 3.0]);
903        assert!((hv - 5.0).abs() < 1e-10, "Expected 5.0, got {hv}");
904    }
905
906    #[test]
907    fn test_hypervolume_2d_outside_reference() {
908        let front = vec![vec![5.0, 5.0]];
909        let hv = hypervolume_2d(&front, &[2.0, 2.0]);
910        assert_eq!(hv, 0.0);
911    }
912
913    #[test]
914    fn test_hypervolume_2d_with_dominated_point() {
915        // (1,1) dominates (2,2); effectively only (1,1) contributes
916        let front = vec![vec![1.0, 1.0], vec![2.0, 2.0]];
917        let hv = hypervolume_2d(&front, &[3.0, 3.0]);
918        // area = 2 * 2 = 4
919        assert!((hv - 4.0).abs() < 1e-10, "Expected 4.0, got {hv}");
920    }
921
922    // ── hypervolume_indicator (WFG) ──────────────────────────────────────────
923
924    #[test]
925    fn test_hypervolume_indicator_2d() {
926        let front = vec![vec![0.0, 2.0], vec![2.0, 0.0]];
927        let hv = hypervolume_indicator(&front, &[3.0, 3.0]);
928        assert!((hv - 5.0).abs() < 1e-10, "Expected 5.0, got {hv}");
929    }
930
931    #[test]
932    fn test_hypervolume_indicator_3d_unit_cube() {
933        // Single point (1,1,1), reference (2,2,2) => volume = 1
934        let front = vec![vec![1.0, 1.0, 1.0]];
935        let hv = hypervolume_indicator(&front, &[2.0, 2.0, 2.0]);
936        assert!((hv - 1.0).abs() < 1e-10, "Expected 1.0, got {hv}");
937    }
938
939    #[test]
940    fn test_hypervolume_indicator_empty() {
941        assert_eq!(hypervolume_indicator(&[], &[2.0, 2.0]), 0.0);
942    }
943
944    #[test]
945    fn test_hypervolume_indicator_outside_reference() {
946        let front = vec![vec![5.0, 5.0, 5.0]];
947        let hv = hypervolume_indicator(&front, &[2.0, 2.0, 2.0]);
948        assert_eq!(hv, 0.0);
949    }
950
951    // ── epsilon_indicator ────────────────────────────────────────────────────
952
953    #[test]
954    fn test_epsilon_indicator_identical() {
955        let reference = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
956        let approx = reference.clone();
957        let eps = epsilon_indicator(&approx, &reference);
958        assert!(eps.abs() < 1e-10, "eps={eps}");
959    }
960
961    #[test]
962    fn test_epsilon_indicator_worse() {
963        // approx uniformly worse by 1.0
964        let reference = vec![vec![0.0, 0.0]];
965        let approx = vec![vec![1.0, 1.0]];
966        let eps = epsilon_indicator(&approx, &reference);
967        assert!((eps - 1.0).abs() < 1e-10, "Expected eps=1.0, got {eps}");
968    }
969
970    #[test]
971    fn test_epsilon_indicator_better() {
972        // approx better on first objective
973        let reference = vec![vec![1.0, 1.0]];
974        let approx = vec![vec![0.5, 1.0]];
975        let eps = epsilon_indicator(&approx, &reference);
976        // max_i(q[i] - p[i]) = max(-0.5, 0.0) = 0.0
977        assert!(eps <= 0.0, "approx is better, eps should be <=0, got {eps}");
978    }
979
980    #[test]
981    fn test_epsilon_indicator_empty() {
982        assert_eq!(epsilon_indicator(&[], &[vec![1.0, 1.0]]), f64::INFINITY);
983        assert_eq!(epsilon_indicator(&[vec![1.0, 1.0]], &[]), f64::INFINITY);
984    }
985
986    // ── generational_distance ────────────────────────────────────────────────
987
988    #[test]
989    fn test_generational_distance_identical() {
990        let reference = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
991        let approx = reference.clone();
992        let gd = generational_distance(&approx, &reference);
993        assert!(gd < 1e-10, "gd={gd}");
994    }
995
996    #[test]
997    fn test_generational_distance_offset() {
998        let reference = vec![vec![0.0, 0.0]];
999        let approx = vec![vec![0.1, 0.1]];
1000        let gd = generational_distance(&approx, &reference);
1001        let expected = (0.1f64.powi(2) + 0.1f64.powi(2)).sqrt();
1002        assert!((gd - expected).abs() < 1e-10);
1003    }
1004
1005    #[test]
1006    fn test_generational_distance_empty() {
1007        assert_eq!(generational_distance(&[], &[vec![1.0, 1.0]]), f64::INFINITY);
1008        assert_eq!(generational_distance(&[vec![1.0, 1.0]], &[]), f64::INFINITY);
1009    }
1010
1011    // ── spread_metric ────────────────────────────────────────────────────────
1012
1013    #[test]
1014    fn test_spread_metric_uniform() {
1015        let front: Vec<Vec<f64>> = (0..5)
1016            .map(|i| {
1017                let f1 = i as f64 * 0.25;
1018                vec![f1, 1.0 - f1]
1019            })
1020            .collect();
1021        let sp = spread_metric(&front);
1022        assert!(
1023            sp < 0.05,
1024            "Uniform front should have near-zero spread: {sp}"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_spread_metric_single_point() {
1030        assert_eq!(spread_metric(&[vec![0.5, 0.5]]), 0.0);
1031    }
1032
1033    #[test]
1034    fn test_spread_metric_two_points() {
1035        let front = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
1036        let sp = spread_metric(&front);
1037        // Two points — both are each other's nn, variance = 0
1038        assert!(sp < 1e-10, "Two-point front spread should be 0, got {sp}");
1039    }
1040
1041    #[test]
1042    fn test_spread_metric_clustered() {
1043        // Two clusters: [0,1] and [10,0] with a large gap
1044        let front = vec![
1045            vec![0.0, 1.0],
1046            vec![0.1, 0.9],
1047            vec![9.9, 0.1],
1048            vec![10.0, 0.0],
1049        ];
1050        let sp_clustered = spread_metric(&front);
1051        // Uniform front should have lower spread
1052        let uniform_front: Vec<Vec<f64>> = (0..4)
1053            .map(|i| vec![i as f64 * 10.0 / 3.0, 10.0 - i as f64 * 10.0 / 3.0])
1054            .collect();
1055        let sp_uniform = spread_metric(&uniform_front);
1056        assert!(
1057            sp_clustered > sp_uniform,
1058            "Clustered ({sp_clustered}) should have higher spread than uniform ({sp_uniform})"
1059        );
1060    }
1061
1062    // ── Integration: non_dominated_sort + crowding_distance ──────────────────
1063
1064    #[test]
1065    fn test_nsga2_style_ranking() {
1066        // Simulate NSGA-II front assignment + crowding
1067        let population = vec![
1068            vec![1.0, 3.0],
1069            vec![2.0, 2.0],
1070            vec![3.0, 1.0], // front 0: all non-dominated
1071            vec![2.0, 3.0],
1072            vec![3.0, 2.0], // front 1: dominated by (2,2) or (1,3) or (3,1)
1073            vec![4.0, 4.0], // front 2
1074        ];
1075
1076        let fronts = non_dominated_sort(&population);
1077        assert!(fronts.len() >= 2, "Expected multiple fronts");
1078
1079        // Compute crowding distance for the first front
1080        let front0_pts: Vec<Vec<f64>> = fronts[0].iter().map(|&i| population[i].clone()).collect();
1081        let cd = crowding_distance(&front0_pts);
1082        assert_eq!(cd.len(), front0_pts.len());
1083    }
1084
1085    // ── pareto_rank ──────────────────────────────────────────────────────────
1086
1087    #[test]
1088    fn test_pareto_rank_chain() {
1089        // Strictly ordered: 0 dominates 1 dominates 2
1090        let pop = vec![vec![1.0, 1.0], vec![2.0, 2.0], vec![3.0, 3.0]];
1091        let ranks = pareto_rank(&pop);
1092        assert_eq!(ranks, vec![0, 1, 2]);
1093    }
1094
1095    #[test]
1096    fn test_pareto_rank_all_equal_rank() {
1097        // Non-dominated front: all rank 0
1098        let pop = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
1099        let ranks = pareto_rank(&pop);
1100        assert!(
1101            ranks.iter().all(|&r| r == 0),
1102            "all should be rank 0: {ranks:?}"
1103        );
1104    }
1105
1106    #[test]
1107    fn test_pareto_rank_empty() {
1108        let ranks = pareto_rank(&[]);
1109        assert!(ranks.is_empty());
1110    }
1111
1112    #[test]
1113    fn test_pareto_rank_mixed() {
1114        let pop = vec![
1115            vec![1.0, 2.0], // rank 0: non-dominated
1116            vec![2.0, 1.0], // rank 0: non-dominated
1117            vec![2.0, 2.0], // rank 1: dominated by [1,2] or [2,1]
1118            vec![3.0, 3.0], // rank 2: dominated by all others
1119        ];
1120        let ranks = pareto_rank(&pop);
1121        assert_eq!(ranks[0], 0);
1122        assert_eq!(ranks[1], 0);
1123        assert!(ranks[2] >= 1, "rank[2] should be >=1, got {}", ranks[2]);
1124        assert!(ranks[3] >= ranks[2], "rank[3] should be >= rank[2]");
1125    }
1126
1127    // ── pareto_front ─────────────────────────────────────────────────────────
1128
1129    #[test]
1130    fn test_pareto_front_basic() {
1131        let pop = vec![vec![1.0, 2.0], vec![2.0, 1.0], vec![3.0, 3.0]];
1132        let front = pareto_front(&pop);
1133        assert_eq!(front.len(), 2);
1134        assert!(
1135            !front.contains(&2),
1136            "dominated point should not be in front"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_pareto_front_empty() {
1142        let front = pareto_front(&[]);
1143        assert!(front.is_empty());
1144    }
1145
1146    #[test]
1147    fn test_pareto_front_all_non_dominated() {
1148        let pop = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1149        let front = pareto_front(&pop);
1150        assert_eq!(front.len(), 3);
1151    }
1152
1153    // ── igd (pareto module) ──────────────────────────────────────────────────
1154
1155    #[test]
1156    fn test_igd_pareto_perfect() {
1157        let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1158        let af = tf.clone();
1159        let val = igd(&af, &tf);
1160        assert!(val < 1e-10, "IGD of identical fronts: {val}");
1161    }
1162
1163    #[test]
1164    fn test_igd_pareto_empty() {
1165        assert_eq!(igd(&[], &[vec![1.0]]), f64::INFINITY);
1166        assert_eq!(igd(&[vec![1.0]], &[]), f64::INFINITY);
1167    }
1168
1169    #[test]
1170    fn test_igd_pareto_offset() {
1171        let tf = vec![vec![0.0, 0.0]];
1172        let af = vec![vec![0.3, 0.4]]; // distance = 0.5
1173        let val = igd(&af, &tf);
1174        let expected = (0.3f64.powi(2) + 0.4f64.powi(2)).sqrt();
1175        assert!(
1176            (val - expected).abs() < 1e-10,
1177            "Expected {expected}, got {val}"
1178        );
1179    }
1180}