Skip to main content

scirs2_optimize/multiobjective/
indicators.rs

1//! Performance indicators for multi-objective optimization
2//!
3//! Provides metrics to evaluate the quality of Pareto fronts:
4//! - Hypervolume (exact 2D sweep, Monte Carlo for higher dimensions)
5//! - Inverted Generational Distance (IGD)
6//! - Generational Distance (GD)
7//! - Additive epsilon indicator
8//! - Spread / diversity indicator
9//! - Domination utilities and non-dominated sorting
10//!
11//! # References
12//!
13//! - Zitzler, Thiele (1998). Multiobjective Optimization Using Evolutionary Algorithms —
14//!   A Comparative Case Study.
15//! - Deb et al. (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II.
16//! - Van Veldhuizen & Lamont (1998). Evolutionary Computation and Convergence to a Pareto Front.
17
18use crate::error::OptimizeError;
19use scirs2_core::random::rngs::StdRng;
20use scirs2_core::random::{Rng, SeedableRng};
21use scirs2_core::RngExt;
22
23// ─────────────────────────────────────────────────────────────────────────────
24// Domination utilities
25// ─────────────────────────────────────────────────────────────────────────────
26
27/// Returns `true` if vector `a` Pareto-dominates vector `b`.
28///
29/// `a` dominates `b` when:
30///  - `a[i] <= b[i]` for every objective `i`, AND
31///  - `a[j] < b[j]` for at least one objective `j`.
32///
33/// # Examples
34/// ```
35/// use scirs2_optimize::multiobjective::indicators::dominates;
36/// assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
37/// assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
38/// ```
39pub fn dominates(a: &[f64], b: &[f64]) -> bool {
40    debug_assert_eq!(a.len(), b.len(), "objective vectors must have equal length");
41    let mut any_strictly_better = false;
42    for (ai, bi) in a.iter().zip(b.iter()) {
43        if ai > bi {
44            return false;
45        }
46        if ai < bi {
47            any_strictly_better = true;
48        }
49    }
50    any_strictly_better
51}
52
53/// Fast non-dominated sorting.
54///
55/// Returns a list of fronts, where each front is a `Vec<usize>` of indices
56/// into `points`. Front 0 contains the non-dominated set; front 1 contains
57/// points dominated only by front 0, and so on.
58///
59/// This implements the O(MN²) algorithm from Deb et al. (2002).
60///
61/// # Arguments
62/// * `points` - Slice of objective vectors.  Each inner `Vec<f64>` must have
63///   the same length (number of objectives).
64///
65/// # Returns
66/// Vector of fronts; `result[0]` = Pareto-optimal indices, etc.
67///
68/// # Examples
69/// ```
70/// use scirs2_optimize::multiobjective::indicators::non_dominated_sort;
71/// let pts = vec![vec![1.0,2.0], vec![2.0,1.0], vec![3.0,3.0]];
72/// let fronts = non_dominated_sort(&pts);
73/// assert_eq!(fronts[0].len(), 2); // (1,2) and (2,1) are non-dominated
74/// assert_eq!(fronts[1].len(), 1); // (3,3) dominated
75/// ```
76pub fn non_dominated_sort(points: &[Vec<f64>]) -> Vec<Vec<usize>> {
77    let n = points.len();
78    if n == 0 {
79        return vec![];
80    }
81
82    // For each point i: list of points it dominates, and count of points that dominate it
83    let mut dominated_by_count = vec![0usize; n];
84    let mut dominates_list: Vec<Vec<usize>> = vec![vec![]; n];
85
86    for i in 0..n {
87        for j in 0..n {
88            if i == j {
89                continue;
90            }
91            if dominates(&points[i], &points[j]) {
92                dominates_list[i].push(j);
93            } else if dominates(&points[j], &points[i]) {
94                dominated_by_count[i] += 1;
95            }
96        }
97    }
98
99    let mut fronts: Vec<Vec<usize>> = Vec::new();
100    let mut current_front: Vec<usize> = (0..n).filter(|&i| dominated_by_count[i] == 0).collect();
101
102    while !current_front.is_empty() {
103        let mut next_front: Vec<usize> = Vec::new();
104        for &i in &current_front {
105            for &j in &dominates_list[i] {
106                dominated_by_count[j] -= 1;
107                if dominated_by_count[j] == 0 {
108                    next_front.push(j);
109                }
110            }
111        }
112        fronts.push(current_front);
113        current_front = next_front;
114    }
115
116    fronts
117}
118
119// ─────────────────────────────────────────────────────────────────────────────
120// Hypervolume
121// ─────────────────────────────────────────────────────────────────────────────
122
123/// Exact 2-D hypervolume indicator.
124///
125/// Computes the area of the objective space dominated by `pareto_front` and
126/// bounded by `reference_point`.  All objective values are assumed to be
127/// minimised.
128///
129/// # Arguments
130/// * `pareto_front`     - Non-dominated objective vectors (2-D each).
131/// * `reference_point`  - Reference point with 2 components; must be greater
132///   than all front points on both axes to yield a non-zero result.
133///
134/// # Panics
135/// Panics in debug mode if any point does not have exactly 2 objectives.
136///
137/// # Examples
138/// ```
139/// use scirs2_optimize::multiobjective::indicators::hypervolume_2d;
140/// // Triangle: (0,2), (2,0), reference (3,3) => area = 5
141/// let front = vec![vec![0.0,2.0], vec![2.0,0.0]];
142/// let hv = hypervolume_2d(&front, &[3.0, 3.0]);
143/// assert!((hv - 5.0).abs() < 1e-10);
144/// ```
145pub fn hypervolume_2d(pareto_front: &[Vec<f64>], reference_point: &[f64]) -> f64 {
146    if pareto_front.is_empty() {
147        return 0.0;
148    }
149    debug_assert_eq!(reference_point.len(), 2);
150
151    // Filter points that are dominated by the reference point on both axes
152    let mut pts: Vec<(f64, f64)> = pareto_front
153        .iter()
154        .filter(|p| p[0] < reference_point[0] && p[1] < reference_point[1])
155        .map(|p| (p[0], p[1]))
156        .collect();
157
158    if pts.is_empty() {
159        return 0.0;
160    }
161
162    // Build 2-D Pareto front: sort by f1 ascending, keep only strictly
163    // decreasing f2 subsequence (i.e., remove dominated points in 2D).
164    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
165
166    let mut pareto_2d: Vec<(f64, f64)> = Vec::new();
167    let mut min_f2 = f64::INFINITY;
168    for (f1, f2) in &pts {
169        if *f2 < min_f2 {
170            min_f2 = *f2;
171            pareto_2d.push((*f1, *f2));
172        }
173    }
174
175    // Right-to-left sweep: accumulate rectangles
176    let ref_x = reference_point[0];
177    let ref_y = reference_point[1];
178    let np = pareto_2d.len();
179    let mut volume = 0.0;
180    for i in (0..np).rev() {
181        let x_start = pareto_2d[i].0;
182        let x_end = if i + 1 < np {
183            pareto_2d[i + 1].0
184        } else {
185            ref_x
186        };
187        let y = pareto_2d[i].1;
188        volume += (x_end - x_start) * (ref_y - y);
189    }
190
191    volume
192}
193
194/// Monte-Carlo hypervolume approximation for arbitrary number of objectives.
195///
196/// Estimates the hypervolume by sampling random points uniformly in the
197/// bounding box `[ideal_point, reference_point]` and checking whether each
198/// sample is dominated by at least one point in `pareto_front`.
199///
200/// For 2-D problems prefer [`hypervolume_2d`] which gives an exact result.
201///
202/// # Arguments
203/// * `pareto_front`    - Objective vectors (each of equal length).
204/// * `reference_point` - Upper bound; must have the same length as each point.
205/// * `n_samples`       - Number of Monte-Carlo samples (higher → more accurate).
206/// * `seed`            - RNG seed for reproducibility.
207pub fn hypervolume_mc(
208    pareto_front: &[Vec<f64>],
209    reference_point: &[f64],
210    n_samples: usize,
211    seed: u64,
212) -> f64 {
213    if pareto_front.is_empty() || n_samples == 0 {
214        return 0.0;
215    }
216
217    let n_obj = reference_point.len();
218    let mut rng = StdRng::seed_from_u64(seed);
219
220    // Compute ideal (lower) point as component-wise minimum of the front
221    let mut ideal = vec![f64::INFINITY; n_obj];
222    for pt in pareto_front {
223        for (i, &v) in pt.iter().enumerate() {
224            if v < ideal[i] {
225                ideal[i] = v;
226            }
227        }
228    }
229
230    // Compute total volume of the sampling bounding box
231    let box_volume: f64 = (0..n_obj)
232        .map(|i| (reference_point[i] - ideal[i]).max(0.0))
233        .product();
234
235    if box_volume == 0.0 {
236        return 0.0;
237    }
238
239    let mut dominated_count = 0usize;
240
241    for _ in 0..n_samples {
242        // Sample a random point within the bounding box
243        let sample: Vec<f64> = (0..n_obj)
244            .map(|i| ideal[i] + rng.random::<f64>() * (reference_point[i] - ideal[i]))
245            .collect();
246
247        // Check if the sample is dominated by any front point
248        'outer: for front_pt in pareto_front {
249            let mut pt_dominates_sample = true;
250            for j in 0..n_obj {
251                if front_pt[j] >= sample[j] {
252                    pt_dominates_sample = false;
253                    break;
254                }
255            }
256            if pt_dominates_sample {
257                dominated_count += 1;
258                break 'outer;
259            }
260        }
261    }
262
263    box_volume * (dominated_count as f64 / n_samples as f64)
264}
265
266// ─────────────────────────────────────────────────────────────────────────────
267// Convergence / diversity indicators
268// ─────────────────────────────────────────────────────────────────────────────
269
270/// Inverted Generational Distance (IGD).
271///
272/// For each point in `true_front`, finds the nearest point in `approx_front`
273/// and averages the distances.  A value of 0 means `approx_front` covers every
274/// point in `true_front` exactly.
275///
276/// # Arguments
277/// * `true_front`   - Reference (true) Pareto front.
278/// * `approx_front` - Approximated Pareto front being evaluated.
279///
280/// # Returns
281/// IGD ∈ [0, ∞).  Returns `f64::INFINITY` if either input is empty.
282///
283/// # Examples
284/// ```
285/// use scirs2_optimize::multiobjective::indicators::igd;
286/// let true_front  = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
287/// let approx      = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
288/// assert!(igd(&true_front, &approx) < 1e-10);
289/// ```
290pub fn igd(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
291    if true_front.is_empty() || approx_front.is_empty() {
292        return f64::INFINITY;
293    }
294
295    let sum: f64 = true_front
296        .iter()
297        .map(|tp| {
298            approx_front
299                .iter()
300                .map(|ap| euclidean_distance(tp, ap))
301                .fold(f64::INFINITY, f64::min)
302        })
303        .sum();
304
305    sum / true_front.len() as f64
306}
307
308/// Generational Distance (GD).
309///
310/// For each point in `approx_front`, finds the nearest point in `true_front`
311/// and averages the distances.
312///
313/// # Returns
314/// GD ∈ [0, ∞).  Returns `f64::INFINITY` if either input is empty.
315pub fn generational_distance(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
316    if true_front.is_empty() || approx_front.is_empty() {
317        return f64::INFINITY;
318    }
319
320    let sum: f64 = approx_front
321        .iter()
322        .map(|ap| {
323            true_front
324                .iter()
325                .map(|tp| euclidean_distance(ap, tp))
326                .fold(f64::INFINITY, f64::min)
327        })
328        .sum();
329
330    sum / approx_front.len() as f64
331}
332
333/// Additive epsilon indicator (I_ε+).
334///
335/// Returns the smallest scalar ε such that for every point p ∈ `true_front`
336/// there exists a point q ∈ `approx_front` with `q[i] - ε ≤ p[i]` for all i
337/// (i.e., q ε-dominates p).
338///
339/// Smaller values indicate that `approx_front` better covers `true_front`.
340/// A value ≤ 0 means `approx_front` weakly dominates all of `true_front`.
341///
342/// # Examples
343/// ```
344/// use scirs2_optimize::multiobjective::indicators::additive_epsilon_indicator;
345/// // Perfect coverage => eps = 0
346/// let tf = vec![vec![0.0,1.0], vec![1.0,0.0]];
347/// let af = vec![vec![0.0,1.0], vec![1.0,0.0]];
348/// assert!(additive_epsilon_indicator(&tf, &af).abs() < 1e-10);
349/// ```
350pub fn additive_epsilon_indicator(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
351    if true_front.is_empty() || approx_front.is_empty() {
352        return f64::INFINITY;
353    }
354
355    // For each true_front point, find the minimum epsilon needed from any approx point
356    true_front
357        .iter()
358        .map(|tp| {
359            approx_front
360                .iter()
361                .map(|ap| {
362                    // Minimum shift so that ap[i] - eps <= tp[i] for all i
363                    // => eps >= ap[i] - tp[i] for all i
364                    ap.iter()
365                        .zip(tp.iter())
366                        .map(|(a, t)| a - t)
367                        .fold(f64::NEG_INFINITY, f64::max)
368                })
369                .fold(f64::INFINITY, f64::min)
370        })
371        .fold(f64::NEG_INFINITY, f64::max)
372}
373
374/// Spread / diversity indicator (Δ, Delta).
375///
376/// Measures the extent and uniformity of the Pareto front approximation.
377/// Based on Deb et al. (2002): computes the average nearest-neighbour
378/// distance for internal points and compares against extreme-point distances.
379///
380/// Returns a value ≥ 0; smaller means better distributed front.
381/// Returns 0 for fewer than 2 points.
382///
383/// # Notes
384/// This implementation uses the modified spread indicator where the value
385/// is computed as the standard deviation of nearest-neighbour distances,
386/// normalised by the mean distance.
387pub fn spread(pareto_front: &[Vec<f64>]) -> f64 {
388    let n = pareto_front.len();
389    if n < 2 {
390        return 0.0;
391    }
392
393    // Nearest-neighbour distances
394    let nn_dists: Vec<f64> = (0..n)
395        .map(|i| {
396            (0..n)
397                .filter(|&j| j != i)
398                .map(|j| euclidean_distance(&pareto_front[i], &pareto_front[j]))
399                .fold(f64::INFINITY, f64::min)
400        })
401        .collect();
402
403    let mean = nn_dists.iter().sum::<f64>() / n as f64;
404    if mean < f64::EPSILON {
405        return 0.0;
406    }
407
408    // Coefficient of variation as diversity measure
409    let variance = nn_dists.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n as f64;
410    variance.sqrt() / mean
411}
412
413// ─────────────────────────────────────────────────────────────────────────────
414// Internal helpers
415// ─────────────────────────────────────────────────────────────────────────────
416
417fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
418    a.iter()
419        .zip(b.iter())
420        .map(|(x, y)| (x - y).powi(2))
421        .sum::<f64>()
422        .sqrt()
423}
424
425// ─────────────────────────────────────────────────────────────────────────────
426// Tests
427// ─────────────────────────────────────────────────────────────────────────────
428
429// ─────────────────────────────────────────────────────────────────────────────
430// Enhanced quality indicators
431// ─────────────────────────────────────────────────────────────────────────────
432
433/// Spacing metric (SP).
434///
435/// Measures the uniformity of spacing between solutions in the Pareto front.
436/// Based on Schott (1995): computes the standard deviation of nearest-neighbour
437/// distances.  A value of 0 indicates perfectly uniform spacing.
438///
439/// # Returns
440/// SP ∈ [0, ∞).  Returns 0 for fewer than 2 points.
441///
442/// # Examples
443/// ```
444/// use scirs2_optimize::multiobjective::indicators::spacing_metric;
445/// // Uniformly spaced front → near-zero spacing
446/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
447/// let sp = spacing_metric(&front);
448/// assert!(sp < 0.01);
449/// ```
450pub fn spacing_metric(front: &[Vec<f64>]) -> f64 {
451    let n = front.len();
452    if n < 2 {
453        return 0.0;
454    }
455
456    let dists: Vec<f64> = (0..n)
457        .map(|i| {
458            (0..n)
459                .filter(|&j| j != i)
460                .map(|j| euclidean_distance(&front[i], &front[j]))
461                .fold(f64::INFINITY, f64::min)
462        })
463        .collect();
464
465    let mean = dists.iter().sum::<f64>() / n as f64;
466    let variance = dists.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n as f64;
467    variance.sqrt()
468}
469
470/// Delta metric (Δ) — Deb's modified spread measure.
471///
472/// Combines spread (extent) of the front with uniformity of spacing.
473/// Formally: Δ = (d_f + d_l + Σ|d_i - d̄|) / (d_f + d_l + (n-1) * d̄)
474///
475/// where d_f, d_l are distances of the extreme approximation points to the
476/// corresponding true-front extremes (or 0 if `true_extremes` is empty),
477/// and d_i are nearest-neighbour distances between consecutive solutions.
478///
479/// # Arguments
480/// * `front`         - Approximated Pareto front solutions.
481/// * `true_extremes` - Extreme points of the true Pareto front (may be empty).
482///
483/// # Returns
484/// Δ ∈ [0, ∞).  Lower values indicate better spread and uniformity.
485///
486/// # Examples
487/// ```
488/// use scirs2_optimize::multiobjective::indicators::delta_metric;
489/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
490/// let delta = delta_metric(&front, &[]);
491/// assert!(delta >= 0.0);
492/// ```
493pub fn delta_metric(front: &[Vec<f64>], true_extremes: &[Vec<f64>]) -> f64 {
494    let n = front.len();
495    if n == 0 {
496        return f64::INFINITY;
497    }
498    if n == 1 {
499        return 0.0;
500    }
501
502    // Nearest-neighbour distances for all solutions
503    let dists: Vec<f64> = (0..n)
504        .map(|i| {
505            (0..n)
506                .filter(|&j| j != i)
507                .map(|j| euclidean_distance(&front[i], &front[j]))
508                .fold(f64::INFINITY, f64::min)
509        })
510        .collect();
511
512    let d_bar = dists.iter().sum::<f64>() / n as f64;
513
514    // Distance from extreme approximation points to their true-front counterparts
515    let (d_f, d_l) = if true_extremes.is_empty() {
516        (0.0_f64, 0.0_f64)
517    } else {
518        // Sort front by first objective
519        let mut sorted_front = front.to_vec();
520        sorted_front.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal));
521
522        let first = &sorted_front[0];
523        let last = &sorted_front[n - 1];
524
525        let df = true_extremes
526            .iter()
527            .map(|p| euclidean_distance(first, p))
528            .fold(f64::INFINITY, f64::min);
529        let dl = true_extremes
530            .iter()
531            .map(|p| euclidean_distance(last, p))
532            .fold(f64::INFINITY, f64::min);
533
534        (df.min(1e10), dl.min(1e10))
535    };
536
537    let sum_diff: f64 = dists.iter().map(|d| (d - d_bar).abs()).sum();
538    let denominator = d_f + d_l + (n as f64 - 1.0) * d_bar;
539
540    if denominator < f64::EPSILON {
541        0.0
542    } else {
543        (d_f + d_l + sum_diff) / denominator
544    }
545}
546
547/// Inverted Generational Distance Plus (IGD+).
548///
549/// IGD+ is a Pareto-compliant variant of IGD.  It uses a modified distance
550/// d+(p, q) = sqrt(Σ_i max(q_i - p_i, 0)²) that only penalises objectives
551/// where the approximation point q is worse (larger) than the true point p.
552///
553/// For each point p ∈ `true_front`:
554/// IGD+(p, A) = min_{q ∈ A} d+(p, q)
555///
556/// IGD+ = (1/|true_front|) Σ_{p ∈ true_front} IGD+(p, A)
557///
558/// # Returns
559/// IGD+ ∈ [0, ∞).  Returns `f64::INFINITY` if either input is empty.
560/// A value of 0 means the approximation set weakly dominates the entire true front.
561///
562/// # References
563/// - Ishibuchi et al. (2015). Evolutionary Computation, 26(3):411-440.
564///
565/// # Examples
566/// ```
567/// use scirs2_optimize::multiobjective::indicators::igd_plus;
568/// let tf = vec![vec![0.0,1.0], vec![1.0,0.0]];
569/// let af = tf.clone();
570/// assert!(igd_plus(&tf, &af) < 1e-10);
571/// ```
572pub fn igd_plus(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
573    if true_front.is_empty() || approx_front.is_empty() {
574        return f64::INFINITY;
575    }
576
577    let sum: f64 = true_front
578        .iter()
579        .map(|tp| {
580            approx_front
581                .iter()
582                .map(|ap| igd_plus_dist(tp, ap))
583                .fold(f64::INFINITY, f64::min)
584        })
585        .sum();
586
587    sum / true_front.len() as f64
588}
589
590/// Modified distance for IGD+: d+(p, q) = sqrt(Σ max(q_i - p_i, 0)²).
591fn igd_plus_dist(true_point: &[f64], approx_point: &[f64]) -> f64 {
592    true_point
593        .iter()
594        .zip(approx_point.iter())
595        .map(|(p, q)| (q - p).max(0.0).powi(2))
596        .sum::<f64>()
597        .sqrt()
598}
599
600/// Scalarizing utility function type for the R2 indicator.
601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
602pub enum R2Utility {
603    /// Weighted Tchebycheff scalarization:
604    /// u(f|λ,z*) = max_i { λ_i · |f_i - z*_i| }
605    Tchebycheff,
606    /// Weighted sum scalarization:
607    /// u(f|λ,z*) = Σ_i λ_i · (f_i - z*_i)
608    WeightedSum,
609    /// Penalty-Based Boundary Intersection (PBI) with penalty θ = 5:
610    /// PBI(f|λ,z*) = d₁ + θ · d₂
611    Pbi,
612}
613
614/// R2 quality indicator.
615///
616/// Evaluates a Pareto front approximation using a set of reference weight
617/// vectors and a scalarizing utility function.  Defined as:
618///
619/// R2(A, Λ, z*) = (1/|Λ|) Σ_{λ ∈ Λ} min_{a ∈ A} u(a | λ, z*)
620///
621/// A lower R2 value indicates a better approximation (closer to ideal).
622///
623/// # Arguments
624/// * `approx_front`    - Approximation set to evaluate.
625/// * `weight_vectors`  - Reference weight vectors (each summing to ~1).
626/// * `reference_point` - Ideal point z* (minimization target; typically
627///   the component-wise minimum of the true Pareto front).
628/// * `utility`         - Scalarizing function variant.
629///
630/// # Returns
631/// R2 value.  Lower is better.  Returns `f64::INFINITY` for empty inputs.
632///
633/// # Examples
634/// ```
635/// use scirs2_optimize::multiobjective::indicators::{r2_indicator, R2Utility};
636/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
637/// let weights = vec![vec![1.0,0.0], vec![0.5,0.5], vec![0.0,1.0]];
638/// let z_star = vec![0.0, 0.0];
639/// let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::Tchebycheff);
640/// assert!(r2 >= 0.0);
641/// ```
642pub fn r2_indicator(
643    approx_front: &[Vec<f64>],
644    weight_vectors: &[Vec<f64>],
645    reference_point: &[f64],
646    utility: R2Utility,
647) -> f64 {
648    if approx_front.is_empty() || weight_vectors.is_empty() {
649        return f64::INFINITY;
650    }
651
652    let sum: f64 = weight_vectors
653        .iter()
654        .map(|w| {
655            approx_front
656                .iter()
657                .map(|a| r2_utility_value(a, w, reference_point, utility))
658                .fold(f64::INFINITY, f64::min)
659        })
660        .sum();
661
662    sum / weight_vectors.len() as f64
663}
664
665/// Compute the scalarizing utility value for the R2 indicator.
666fn r2_utility_value(f: &[f64], weights: &[f64], reference: &[f64], utility: R2Utility) -> f64 {
667    match utility {
668        R2Utility::Tchebycheff => f
669            .iter()
670            .zip(weights.iter())
671            .zip(reference.iter())
672            .map(|((fi, wi), zi)| wi * (fi - zi).abs())
673            .fold(f64::NEG_INFINITY, f64::max),
674
675        R2Utility::WeightedSum => f
676            .iter()
677            .zip(weights.iter())
678            .zip(reference.iter())
679            .map(|((fi, wi), zi)| wi * (fi - zi))
680            .sum(),
681
682        R2Utility::Pbi => {
683            let theta = 5.0_f64;
684
685            // Translate f by reference: f_shifted = f - z*
686            let f_shifted: Vec<f64> = f
687                .iter()
688                .zip(reference.iter())
689                .map(|(fi, zi)| fi - zi)
690                .collect();
691
692            // d1: distance along the weight vector direction
693            let w_norm_sq: f64 = weights.iter().map(|w| w * w).sum();
694            let dot: f64 = f_shifted
695                .iter()
696                .zip(weights.iter())
697                .map(|(a, b)| a * b)
698                .sum();
699
700            let d1 = if w_norm_sq < 1e-14 {
701                0.0
702            } else {
703                (dot / w_norm_sq.sqrt()).abs()
704            };
705
706            // d2: perpendicular distance from the weight vector direction
707            let w_proj: Vec<f64> = if w_norm_sq < 1e-14 {
708                vec![0.0; weights.len()]
709            } else {
710                weights.iter().map(|w| dot * w / w_norm_sq).collect()
711            };
712
713            let d2 = f_shifted
714                .iter()
715                .zip(w_proj.iter())
716                .map(|(fi, wp)| (fi - wp).powi(2))
717                .sum::<f64>()
718                .sqrt();
719
720            d1 + theta * d2
721        }
722    }
723}
724
725/// Hypervolume contribution of each point in a Pareto front.
726///
727/// The exclusive hypervolume contribution (HVC) of a point p is the reduction
728/// in total hypervolume when p is removed:
729///
730/// HVC(p) = HV(front) - HV(front \ {p})
731///
732/// Solutions with larger HVC are more critical for the overall coverage.
733///
734/// # Arguments
735/// * `front`           - Non-dominated objective vectors.
736/// * `reference_point` - Upper bound (all objectives < reference for contribution > 0).
737/// * `mc_samples`      - Monte Carlo samples for N-D hypervolume (≥ 3 objectives).
738/// * `seed`            - RNG seed for Monte Carlo reproducibility.
739///
740/// # Returns
741/// Vector of contribution values, one per front point.
742/// Uses exact sweep for 2-D, Monte Carlo for higher dimensions.
743///
744/// # Examples
745/// ```
746/// use scirs2_optimize::multiobjective::indicators::hypervolume_contribution;
747/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
748/// let hvc = hypervolume_contribution(&front, &[2.0,2.0], 10_000, 42);
749/// assert_eq!(hvc.len(), 3);
750/// for v in &hvc { assert!(*v >= 0.0); }
751/// ```
752pub fn hypervolume_contribution(
753    front: &[Vec<f64>],
754    reference_point: &[f64],
755    mc_samples: usize,
756    seed: u64,
757) -> Vec<f64> {
758    let n = front.len();
759    if n == 0 {
760        return vec![];
761    }
762
763    let n_obj = reference_point.len();
764    let total_hv = if n_obj == 2 {
765        hypervolume_2d(front, reference_point)
766    } else {
767        hypervolume_mc(front, reference_point, mc_samples, seed)
768    };
769
770    (0..n)
771        .map(|i| {
772            let reduced: Vec<Vec<f64>> = front
773                .iter()
774                .enumerate()
775                .filter(|&(j, _)| j != i)
776                .map(|(_, p)| p.clone())
777                .collect();
778
779            let hv_without = if reduced.is_empty() {
780                0.0
781            } else if n_obj == 2 {
782                hypervolume_2d(&reduced, reference_point)
783            } else {
784                hypervolume_mc(&reduced, reference_point, mc_samples, seed)
785            };
786
787            (total_hv - hv_without).max(0.0)
788        })
789        .collect()
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    // ── dominates ────────────────────────────────────────────────────────────
797
798    #[test]
799    fn test_dominates_basic_2d() {
800        assert!(dominates(&[1.0, 1.0], &[2.0, 2.0]));
801        assert!(dominates(&[1.0, 2.0], &[2.0, 2.0]));
802        assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
803        assert!(!dominates(&[2.0, 2.0], &[2.0, 2.0])); // equal: not dominating
804    }
805
806    #[test]
807    fn test_dominates_identical_points() {
808        assert!(!dominates(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]));
809    }
810
811    #[test]
812    fn test_dominates_three_objectives() {
813        assert!(dominates(&[1.0, 1.0, 1.0], &[2.0, 2.0, 2.0]));
814        assert!(!dominates(&[1.0, 2.0, 1.0], &[1.0, 1.0, 2.0])); // trade-off
815    }
816
817    // ── non_dominated_sort ───────────────────────────────────────────────────
818
819    #[test]
820    fn test_non_dominated_sort_trivial() {
821        let pts = vec![vec![1.0, 2.0], vec![2.0, 1.0], vec![3.0, 3.0]];
822        let fronts = non_dominated_sort(&pts);
823        assert_eq!(fronts.len(), 2);
824        assert_eq!(fronts[0].len(), 2); // (1,2) and (2,1) are non-dominated
825        assert_eq!(fronts[1].len(), 1); // (3,3)
826        assert!(fronts[1].contains(&2));
827    }
828
829    #[test]
830    fn test_non_dominated_sort_all_non_dominated() {
831        let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
832        let fronts = non_dominated_sort(&pts);
833        assert_eq!(fronts.len(), 1);
834        assert_eq!(fronts[0].len(), 3);
835    }
836
837    #[test]
838    fn test_non_dominated_sort_chain() {
839        // Strictly ordered: 0 dominates 1 dominates 2
840        let pts = vec![vec![1.0, 1.0], vec![2.0, 2.0], vec![3.0, 3.0]];
841        let fronts = non_dominated_sort(&pts);
842        assert_eq!(fronts.len(), 3);
843        assert_eq!(fronts[0], vec![0]);
844        assert_eq!(fronts[1], vec![1]);
845        assert_eq!(fronts[2], vec![2]);
846    }
847
848    #[test]
849    fn test_non_dominated_sort_empty() {
850        let fronts = non_dominated_sort(&[]);
851        assert!(fronts.is_empty());
852    }
853
854    // ── hypervolume_2d ───────────────────────────────────────────────────────
855
856    #[test]
857    fn test_hypervolume_2d_single_point() {
858        let front = vec![vec![1.0, 1.0]];
859        let hv = hypervolume_2d(&front, &[2.0, 2.0]);
860        assert!((hv - 1.0).abs() < 1e-10, "Expected 1.0, got {hv}");
861    }
862
863    #[test]
864    fn test_hypervolume_2d_two_points() {
865        // (0,2) and (2,0) with reference (3,3) → expected area = 5
866        let front = vec![vec![0.0, 2.0], vec![2.0, 0.0]];
867        let hv = hypervolume_2d(&front, &[3.0, 3.0]);
868        assert!((hv - 5.0).abs() < 1e-10, "Expected 5.0, got {hv}");
869    }
870
871    #[test]
872    fn test_hypervolume_2d_empty_front() {
873        let hv = hypervolume_2d(&[], &[2.0, 2.0]);
874        assert_eq!(hv, 0.0);
875    }
876
877    #[test]
878    fn test_hypervolume_2d_point_outside_reference() {
879        // Point at (5,5) does not dominate reference (2,2) → HV = 0
880        let front = vec![vec![5.0, 5.0]];
881        let hv = hypervolume_2d(&front, &[2.0, 2.0]);
882        assert_eq!(hv, 0.0);
883    }
884
885    #[test]
886    fn test_hypervolume_2d_with_dominated_point() {
887        // (1,1) dominates (2,2); effectively only (1,1) contributes
888        let front = vec![vec![1.0, 1.0], vec![2.0, 2.0]];
889        let hv = hypervolume_2d(&front, &[3.0, 3.0]);
890        // Expected: area covered by (1,1) up to (3,3) = 2*2 = 4
891        assert!((hv - 4.0).abs() < 1e-10, "Expected 4.0, got {hv}");
892    }
893
894    // ── hypervolume_mc ───────────────────────────────────────────────────────
895
896    #[test]
897    fn test_hypervolume_mc_approximation() {
898        // Single point at (1,1) with reference (2,2) → exact HV = 1
899        let front = vec![vec![1.0, 1.0]];
900        let hv = hypervolume_mc(&front, &[2.0, 2.0], 100_000, 42);
901        // With 100k samples, should be within 2% of exact value 1.0
902        assert!((hv - 1.0).abs() < 0.05, "MC approx too far from 1.0: {hv}");
903    }
904
905    #[test]
906    fn test_hypervolume_mc_empty() {
907        let hv = hypervolume_mc(&[], &[2.0, 2.0], 1000, 42);
908        assert_eq!(hv, 0.0);
909    }
910
911    // ── igd ──────────────────────────────────────────────────────────────────
912
913    #[test]
914    fn test_igd_perfect_coverage() {
915        let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
916        let af = tf.clone();
917        let val = igd(&tf, &af);
918        assert!(val < 1e-10, "IGD of identical fronts: {val}");
919    }
920
921    #[test]
922    fn test_igd_offset_front() {
923        // approx_front is shifted by (0.1, 0.1)
924        let tf = vec![vec![0.0, 0.0]];
925        let af = vec![vec![0.1, 0.1]];
926        let val = igd(&tf, &af);
927        let expected = (0.1f64.powi(2) + 0.1f64.powi(2)).sqrt();
928        assert!((val - expected).abs() < 1e-10);
929    }
930
931    #[test]
932    fn test_igd_empty_inputs() {
933        assert_eq!(igd(&[], &[vec![1.0, 1.0]]), f64::INFINITY);
934        assert_eq!(igd(&[vec![1.0, 1.0]], &[]), f64::INFINITY);
935    }
936
937    // ── generational_distance ────────────────────────────────────────────────
938
939    #[test]
940    fn test_gd_perfect_coverage() {
941        let tf = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
942        let af = tf.clone();
943        let val = generational_distance(&tf, &af);
944        assert!(val < 1e-10, "GD of identical fronts: {val}");
945    }
946
947    // ── additive_epsilon_indicator ───────────────────────────────────────────
948
949    #[test]
950    fn test_epsilon_perfect_coverage() {
951        let tf = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
952        let af = tf.clone();
953        let eps = additive_epsilon_indicator(&tf, &af);
954        assert!(eps.abs() < 1e-10, "Epsilon for identical fronts: {eps}");
955    }
956
957    #[test]
958    fn test_epsilon_offset_front() {
959        // approx is uniformly better by 0.5 on f1 only
960        let tf = vec![vec![1.0, 1.0]];
961        let af = vec![vec![0.5, 1.0]]; // a-t = (-0.5, 0.0) → max = 0.0
962        let eps = additive_epsilon_indicator(&tf, &af);
963        assert!(
964            eps <= 0.0,
965            "approx is better: eps should be <= 0, got {eps}"
966        );
967    }
968
969    #[test]
970    fn test_epsilon_worse_front() {
971        // approx is uniformly worse by 1.0
972        let tf = vec![vec![0.0, 0.0]];
973        let af = vec![vec![1.0, 1.0]]; // a - t = 1 for both
974        let eps = additive_epsilon_indicator(&tf, &af);
975        assert!((eps - 1.0).abs() < 1e-10, "Expected eps=1.0, got {eps}");
976    }
977
978    // ── spread ───────────────────────────────────────────────────────────────
979
980    #[test]
981    fn test_spread_uniform_distribution() {
982        // Uniformly spaced points should have zero spread (no variation)
983        let front: Vec<Vec<f64>> = (0..5)
984            .map(|i| {
985                let f1 = i as f64 * 0.25;
986                vec![f1, 1.0 - f1]
987            })
988            .collect();
989        let sp = spread(&front);
990        // Perfectly uniform → std dev of nn_dists is 0
991        assert!(
992            sp < 0.01,
993            "Uniform front should have near-zero spread: {sp}"
994        );
995    }
996
997    #[test]
998    fn test_spread_single_point() {
999        let front = vec![vec![0.5, 0.5]];
1000        assert_eq!(spread(&front), 0.0);
1001    }
1002
1003    // ── spacing_metric ───────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn test_spacing_metric_uniform() {
1007        // Uniformly spaced front: all nn-distances are equal → std dev = 0
1008        let front: Vec<Vec<f64>> = (0..5)
1009            .map(|i| vec![i as f64 * 0.25, 1.0 - i as f64 * 0.25])
1010            .collect();
1011        let sp = spacing_metric(&front);
1012        assert!(sp < 1e-10, "uniform front spacing should be 0, got {sp}");
1013    }
1014
1015    #[test]
1016    fn test_spacing_metric_single_point() {
1017        assert_eq!(spacing_metric(&[vec![0.5, 0.5]]), 0.0);
1018    }
1019
1020    #[test]
1021    fn test_spacing_metric_non_uniform() {
1022        // Points with asymmetric nearest-neighbour distances yield sp > 0.
1023        // NNDs: d(0,1)=0.1, d(1,0)=0.1, d(2,3)=0.1, d(3,2)=0.1
1024        // but we need the gaps to differ; use an odd-one-out layout:
1025        // [0.0,1.0],[0.1,0.9],[0.5,0.5],[1.0,0.0]
1026        // NN: pt0→pt1≈0.141, pt1→pt0≈0.141, pt2→pt1≈0.566, pt3→pt2≈0.707
1027        // → mean≠individual dists, variance > 0
1028        let clustered = vec![
1029            vec![0.0, 1.0],
1030            vec![0.1, 0.9],
1031            vec![0.5, 0.5],
1032            vec![1.0, 0.0],
1033        ];
1034        let sp = spacing_metric(&clustered);
1035        assert!(
1036            sp > 0.0,
1037            "clustered front should have nonzero spacing, got {sp}"
1038        );
1039    }
1040
1041    // ── delta_metric ─────────────────────────────────────────────────────────
1042
1043    #[test]
1044    fn test_delta_metric_uniform_no_extremes() {
1045        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1046        let d = delta_metric(&front, &[]);
1047        assert!(d >= 0.0, "delta metric should be non-negative, got {d}");
1048    }
1049
1050    #[test]
1051    fn test_delta_metric_empty() {
1052        assert_eq!(delta_metric(&[], &[]), f64::INFINITY);
1053    }
1054
1055    #[test]
1056    fn test_delta_metric_single_point() {
1057        assert_eq!(delta_metric(&[vec![0.5, 0.5]], &[]), 0.0);
1058    }
1059
1060    #[test]
1061    fn test_delta_metric_with_true_extremes() {
1062        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1063        let true_extremes = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
1064        let d = delta_metric(&front, &true_extremes);
1065        assert!(d >= 0.0);
1066        // With perfect coverage of extremes, d_f + d_l = 0 → delta ≈ 0
1067        assert!(d < 0.5, "well-spread front with covered extremes: {d}");
1068    }
1069
1070    // ── igd_plus ─────────────────────────────────────────────────────────────
1071
1072    #[test]
1073    fn test_igd_plus_identical_fronts() {
1074        let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1075        let af = tf.clone();
1076        let val = igd_plus(&tf, &af);
1077        assert!(
1078            val < 1e-10,
1079            "IGD+ of identical fronts should be 0, got {val}"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_igd_plus_empty_inputs() {
1085        assert_eq!(igd_plus(&[], &[vec![1.0]]), f64::INFINITY);
1086        assert_eq!(igd_plus(&[vec![1.0]], &[]), f64::INFINITY);
1087    }
1088
1089    #[test]
1090    fn test_igd_plus_dominating_approx() {
1091        // Approx dominates true: q_i < p_i for all i → d+ = 0
1092        let tf = vec![vec![1.0, 1.0]];
1093        let af = vec![vec![0.5, 0.5]]; // dominates tf
1094        let val = igd_plus(&tf, &af);
1095        assert!(
1096            val < 1e-10,
1097            "dominating approx should give IGD+=0, got {val}"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_igd_plus_worse_approx() {
1103        // Approx is worse: q_i > p_i → d+ = Euclidean distance
1104        let tf = vec![vec![0.0, 0.0]];
1105        let af = vec![vec![1.0, 1.0]];
1106        let val = igd_plus(&tf, &af);
1107        let expected = (1.0f64.powi(2) + 1.0f64.powi(2)).sqrt();
1108        assert!(
1109            (val - expected).abs() < 1e-10,
1110            "Expected {expected}, got {val}"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_igd_plus_less_or_equal_to_igd() {
1116        // IGD+ ≤ IGD because d+ ≤ Euclidean distance
1117        let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1118        let af = vec![vec![0.2, 0.8], vec![0.6, 0.4], vec![0.9, 0.1]];
1119        let igdp = igd_plus(&tf, &af);
1120        let igd_val = igd(&tf, &af);
1121        assert!(
1122            igdp <= igd_val + 1e-10,
1123            "IGD+={igdp} should be <= IGD={igd_val}"
1124        );
1125    }
1126
1127    // ── r2_indicator ─────────────────────────────────────────────────────────
1128
1129    #[test]
1130    fn test_r2_tchebycheff_basic() {
1131        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1132        let weights = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.0, 1.0]];
1133        let z_star = vec![0.0, 0.0];
1134        let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::Tchebycheff);
1135        assert!(r2.is_finite(), "R2 should be finite");
1136        assert!(r2 >= 0.0, "R2 should be non-negative for this case");
1137    }
1138
1139    #[test]
1140    fn test_r2_weighted_sum_basic() {
1141        let front = vec![vec![0.5, 0.5]];
1142        let weights = vec![vec![0.5, 0.5]];
1143        let z_star = vec![0.0, 0.0];
1144        let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::WeightedSum);
1145        // 0.5*0.5 + 0.5*0.5 = 0.5
1146        assert!((r2 - 0.5).abs() < 1e-10, "Expected 0.5, got {r2}");
1147    }
1148
1149    #[test]
1150    fn test_r2_pbi_basic() {
1151        let front = vec![vec![0.5, 0.5], vec![0.0, 1.0], vec![1.0, 0.0]];
1152        let weights = vec![vec![0.5, 0.5]];
1153        let z_star = vec![0.0, 0.0];
1154        let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::Pbi);
1155        assert!(r2.is_finite());
1156    }
1157
1158    #[test]
1159    fn test_r2_empty_inputs() {
1160        assert_eq!(
1161            r2_indicator(&[], &[vec![0.5, 0.5]], &[0.0, 0.0], R2Utility::Tchebycheff),
1162            f64::INFINITY
1163        );
1164        assert_eq!(
1165            r2_indicator(&[vec![0.5, 0.5]], &[], &[0.0, 0.0], R2Utility::Tchebycheff),
1166            f64::INFINITY
1167        );
1168    }
1169
1170    #[test]
1171    fn test_r2_better_front_lower_r2() {
1172        // A front closer to the ideal should have lower R2
1173        let weights = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.0, 1.0]];
1174        let z_star = vec![0.0, 0.0];
1175
1176        let good_front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
1177        let bad_front = vec![vec![0.5, 2.0], vec![1.5, 1.5], vec![2.0, 0.5]];
1178
1179        let r2_good = r2_indicator(&good_front, &weights, &z_star, R2Utility::Tchebycheff);
1180        let r2_bad = r2_indicator(&bad_front, &weights, &z_star, R2Utility::Tchebycheff);
1181
1182        assert!(
1183            r2_good < r2_bad,
1184            "Good front R2={r2_good} should be < bad front R2={r2_bad}"
1185        );
1186    }
1187
1188    // ── hypervolume_contribution ─────────────────────────────────────────────
1189
1190    #[test]
1191    fn test_hv_contribution_2d_basic() {
1192        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1193        let ref_pt = vec![2.0, 2.0];
1194        let hvc = hypervolume_contribution(&front, &ref_pt, 10_000, 42);
1195
1196        assert_eq!(hvc.len(), 3);
1197        for &v in &hvc {
1198            assert!(v >= 0.0, "HVC must be non-negative");
1199        }
1200        // Total HVC should sum close to total HV
1201        let total_hvc: f64 = hvc.iter().sum();
1202        let total_hv = hypervolume_2d(&front, &ref_pt);
1203        // Note: sum of exclusive contributions ≤ total HV (no overlaps in 2D for a Pareto front)
1204        assert!(
1205            total_hvc <= total_hv + 1e-9,
1206            "Sum of HVC should not exceed total HV"
1207        );
1208    }
1209
1210    #[test]
1211    fn test_hv_contribution_empty_front() {
1212        let hvc = hypervolume_contribution(&[], &[2.0, 2.0], 1000, 42);
1213        assert!(hvc.is_empty());
1214    }
1215
1216    #[test]
1217    fn test_hv_contribution_extreme_points_higher() {
1218        // In a 2D Pareto front, extreme points typically contribute more
1219        let front = vec![vec![0.0, 2.0], vec![1.0, 1.0], vec![2.0, 0.0]];
1220        let ref_pt = vec![3.0, 3.0];
1221        let hvc = hypervolume_contribution(&front, &ref_pt, 10_000, 42);
1222
1223        // The middle point (1,1) typically has lower contribution than extremes
1224        assert!(hvc[0] > 0.0, "Extreme point 0 should have positive HVC");
1225        assert!(hvc[2] > 0.0, "Extreme point 2 should have positive HVC");
1226    }
1227}