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 ¤t_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}