Skip to main content

scirs2_optimize/multiobjective/
hypervolume.rs

1//! Hypervolume indicator computation for multi-objective optimization.
2//!
3//! Provides exact and approximate algorithms for computing the hypervolume
4//! dominated by a Pareto front with respect to a reference point.
5//!
6//! # Algorithms
7//!
8//! | Function | Algorithm | Complexity |
9//! |----------|-----------|------------|
10//! | [`hypervolume_2d`]      | Sweep-line on sorted front    | O(n log n) |
11//! | [`hypervolume_3d`]      | Slice-and-sweep               | O(n² log n) |
12//! | [`hypervolume_wfg`]     | WFG recursive algorithm       | O(n^(d-1)) |
13//! | [`hypervolume_contribution_wfg`] | Remove-and-recompute | O(n^d) |
14//! | [`exclusive_hypervolume`] | Per-solution contributions  | O(n^d) |
15//!
16//! # References
17//!
18//! - While, L., Hingston, P., Barone, L., & Huband, S. (2006). A faster
19//!   algorithm for calculating hypervolume. *IEEE Transactions on Evolutionary
20//!   Computation*, 10(1), 29-38.
21//! - Emmerich, M., Beume, N., & Naujoks, B. (2005). An EMO algorithm using the
22//!   hypervolume measure as selection criterion.
23//! - Bradstreet, L., While, L., & Barone, L. (2007). A fast many-objective
24//!   hypervolume algorithm. *ISPA*, 3-10.
25
26use crate::error::{OptimizeError, OptimizeResult};
27
28// ─────────────────────────────────────────────────────────────────────────────
29// 2-D hypervolume (O(n log n) sweep)
30// ─────────────────────────────────────────────────────────────────────────────
31
32/// Compute the hypervolume dominated by a 2-D Pareto front.
33///
34/// The front need not be pre-sorted or pre-filtered; dominated points are
35/// handled automatically.
36///
37/// # Arguments
38/// * `front`     — Pareto front as `&[(f1, f2)]`. All objectives minimised.
39/// * `reference` — Reference point `(r1, r2)`.  Every front point must
40///   satisfy `f_i < r_i` for all `i` for a positive contribution.
41///
42/// # Returns
43/// The hypervolume (area) dominated by the front and bounded by `reference`.
44/// Returns `0.0` if the front is empty.
45///
46/// # Errors
47/// Returns an error if `reference` dimensions do not match.
48///
49/// # Examples
50/// ```
51/// use scirs2_optimize::multiobjective::hypervolume::hypervolume_2d;
52/// let front = &[(0.0, 1.0), (0.5, 0.5), (1.0, 0.0)];
53/// let hv = hypervolume_2d(front, (2.0, 2.0)).expect("valid input");
54/// assert!((hv - 2.75).abs() < 1e-10);
55/// ```
56pub fn hypervolume_2d(front: &[(f64, f64)], reference: (f64, f64)) -> OptimizeResult<f64> {
57    if front.is_empty() {
58        return Ok(0.0);
59    }
60
61    // Filter points that contribute (both coords less than reference)
62    let mut pts: Vec<(f64, f64)> = front
63        .iter()
64        .filter(|&&(f1, f2)| f1 < reference.0 && f2 < reference.1)
65        .copied()
66        .collect();
67
68    if pts.is_empty() {
69        return Ok(0.0);
70    }
71
72    // Sort by first objective ascending
73    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
74
75    // Remove dominated points (keep only those with strictly decreasing f2)
76    let mut non_dom: Vec<(f64, f64)> = Vec::with_capacity(pts.len());
77    let mut min_f2 = f64::INFINITY;
78    // Process in ascending f1 order: a point is non-dominated iff its f2 < all previously seen f2
79    for (f1, f2) in pts {
80        if f2 < min_f2 {
81            non_dom.push((f1, f2));
82            min_f2 = f2;
83        }
84    }
85
86    // Sweep: area contributed by each point is (next_f1 - curr_f1) * (ref_f2 - curr_f2)
87    let mut hv = 0.0_f64;
88    let n = non_dom.len();
89    for i in 0..n {
90        let f1_next = if i + 1 < n {
91            non_dom[i + 1].0
92        } else {
93            reference.0
94        };
95        let width = f1_next - non_dom[i].0;
96        let height = reference.1 - non_dom[i].1;
97        if width > 0.0 && height > 0.0 {
98            hv += width * height;
99        }
100    }
101
102    Ok(hv)
103}
104
105// ─────────────────────────────────────────────────────────────────────────────
106// 3-D hypervolume (slice-and-sweep)
107// ─────────────────────────────────────────────────────────────────────────────
108
109/// Compute the exact 3-D hypervolume using a slice-and-sweep approach.
110///
111/// The algorithm iterates over distinct z-values, computes the 2-D hypervolume
112/// of the projected front at each slice, and integrates over z.
113///
114/// # Arguments
115/// * `front`     — Slice of 3-element arrays `[f1, f2, f3]`.
116/// * `reference` — 3-element reference point array `[r1, r2, r3]`.
117///
118/// # Errors
119/// Returns an error if any point in `front` has length != 3.
120///
121/// # Examples
122/// ```
123/// use scirs2_optimize::multiobjective::hypervolume::hypervolume_3d;
124/// let front = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]];
125/// let hv = hypervolume_3d(&front, &[2.0, 2.0, 2.0]).expect("valid input");
126/// assert!(hv > 0.0);
127/// ```
128pub fn hypervolume_3d(front: &[[f64; 3]], reference: &[f64; 3]) -> OptimizeResult<f64> {
129    if front.is_empty() {
130        return Ok(0.0);
131    }
132
133    // Filter: only points with all coordinates < reference
134    let mut pts: Vec<[f64; 3]> = front
135        .iter()
136        .filter(|p| p[0] < reference[0] && p[1] < reference[1] && p[2] < reference[2])
137        .copied()
138        .collect();
139
140    if pts.is_empty() {
141        return Ok(0.0);
142    }
143
144    // Sort by f3 (z-axis) ascending
145    pts.sort_by(|a, b| a[2].partial_cmp(&b[2]).unwrap_or(std::cmp::Ordering::Equal));
146
147    // Collect unique z breakpoints (ascending order)
148    let mut z_vals: Vec<f64> = pts.iter().map(|p| p[2]).collect();
149    z_vals.dedup_by(|a, b| (*a - *b).abs() < 1e-15);
150
151    // Slice-and-sweep: for the slab [z_vals[i], z_vals[i+1]) use active set
152    // of all points with p[2] <= z_vals[i].  The first point that becomes
153    // active at z_vals[0] contributes from z_vals[0] upward, so there is no
154    // slab below z_vals[0].
155    let mut hv = 0.0_f64;
156    let n_z = z_vals.len();
157
158    for i in 0..n_z {
159        // Active set: all points that entered at or before z_vals[i]
160        let active: Vec<(f64, f64)> = pts
161            .iter()
162            .filter(|p| p[2] <= z_vals[i])
163            .map(|p| (p[0], p[1]))
164            .collect();
165
166        if active.is_empty() {
167            continue;
168        }
169
170        let hv_2d = hypervolume_2d(&active, (reference[0], reference[1]))?;
171        // This 2D cross-section is valid from z_vals[i] up to the next breakpoint
172        let z_next = if i + 1 < n_z {
173            z_vals[i + 1]
174        } else {
175            reference[2]
176        };
177        let dz = z_next - z_vals[i];
178        if dz > 0.0 {
179            hv += hv_2d * dz;
180        }
181    }
182
183    Ok(hv)
184}
185
186// ─────────────────────────────────────────────────────────────────────────────
187// WFG algorithm — arbitrary dimensions
188// ─────────────────────────────────────────────────────────────────────────────
189
190/// Compute the exact hypervolume using the WFG (While-Hingston-Barone-Huband)
191/// recursive algorithm for arbitrary dimensionality.
192///
193/// WFG is asymptotically optimal for the general case and handles any number
194/// of objectives. For 2-D, prefer [`hypervolume_2d`] which is faster in practice.
195///
196/// # Arguments
197/// * `front`     — Pareto front as `&[Vec<f64>]`.  All objectives minimised.
198///   Every inner vector must have the same length.
199/// * `reference` — Reference point slice, same length as each objective vector.
200///
201/// # Returns
202/// Exact hypervolume dominated by `front` relative to `reference`.
203/// Returns `0.0` for an empty front.
204///
205/// # Errors
206/// Returns an error if objective-vector lengths are inconsistent.
207///
208/// # Examples
209/// ```
210/// use scirs2_optimize::multiobjective::hypervolume::hypervolume_wfg;
211/// let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
212/// let hv = hypervolume_wfg(&front, &[2.0, 2.0]).expect("valid input");
213/// assert!((hv - 2.75).abs() < 1e-10);
214/// ```
215pub fn hypervolume_wfg(front: &[Vec<f64>], reference: &[f64]) -> OptimizeResult<f64> {
216    if front.is_empty() {
217        return Ok(0.0);
218    }
219    let n_obj = reference.len();
220    for (i, pt) in front.iter().enumerate() {
221        if pt.len() != n_obj {
222            return Err(OptimizeError::InvalidInput(format!(
223                "front[{i}] has length {} but reference has length {n_obj}",
224                pt.len()
225            )));
226        }
227    }
228
229    // Filter points that are strictly dominated by the reference (all coords < ref)
230    let mut pts: Vec<Vec<f64>> = front
231        .iter()
232        .filter(|pt| pt.iter().zip(reference.iter()).all(|(f, r)| f < r))
233        .cloned()
234        .collect();
235
236    if pts.is_empty() {
237        return Ok(0.0);
238    }
239
240    Ok(wfg_recurse(&mut pts, reference))
241}
242
243/// Internal WFG recursive computation. Mutates `pts` (sorting is applied).
244fn wfg_recurse(pts: &mut Vec<Vec<f64>>, reference: &[f64]) -> f64 {
245    let n = pts.len();
246    if n == 0 {
247        return 0.0;
248    }
249    let d = reference.len();
250
251    // Base case: 1-D hypervolume is just (reference - min_val) clamped >= 0
252    if d == 1 {
253        let min_f: f64 = pts.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
254        return (reference[0] - min_f).max(0.0);
255    }
256
257    // Base case: single point
258    if n == 1 {
259        let vol: f64 = pts[0]
260            .iter()
261            .zip(reference.iter())
262            .map(|(f, r)| (r - f).max(0.0))
263            .product();
264        return vol;
265    }
266
267    // Sort by last objective ASCENDING (smallest last-obj first).
268    // Each point i "owns" the slab from its z up to the next point's z
269    // (or the reference if it is the last point).
270    pts.sort_by(|a, b| {
271        a[d - 1]
272            .partial_cmp(&b[d - 1])
273            .unwrap_or(std::cmp::Ordering::Equal)
274    });
275
276    // Remove points that are dominated (keeps non-dominated wrt all d objectives)
277    let non_dom = filter_nd_non_dominated(pts);
278    let n_nd = non_dom.len();
279
280    // Slice-sweep: HV(P, R) = Σ_i hv_{d-1}(non_dom[0..=i] projected, R') × dz_i
281    // where dz_i is the slice thickness from non_dom[i][d-1] to the next boundary.
282    let mut total = 0.0_f64;
283
284    for i in 0..n_nd {
285        // Upper boundary of this point's slab in the last dimension
286        let z_next = if i + 1 < n_nd {
287            non_dom[i + 1][d - 1]
288        } else {
289            reference[d - 1]
290        };
291
292        let dz = z_next - non_dom[i][d - 1];
293        if dz <= 0.0 {
294            continue;
295        }
296
297        // Project to d-1 dimensions: take all points up to and including i
298        let sub_ref: Vec<f64> = reference[..d - 1].to_vec();
299        let mut sub_pts: Vec<Vec<f64>> =
300            non_dom[..=i].iter().map(|p| p[..d - 1].to_vec()).collect();
301
302        let hv_sub = wfg_recurse(&mut sub_pts, &sub_ref);
303        total += hv_sub * dz;
304    }
305
306    total
307}
308
309/// Filter non-dominated points from `pts` (already sorted by last objective desc).
310fn filter_nd_non_dominated(pts: &[Vec<f64>]) -> Vec<Vec<f64>> {
311    let mut result: Vec<Vec<f64>> = Vec::with_capacity(pts.len());
312    'outer: for pt in pts {
313        for existing in &result {
314            // If existing dominates pt, skip pt
315            if nd_dominates(existing, pt) {
316                continue 'outer;
317            }
318        }
319        // Remove from result any points that pt dominates
320        result.retain(|existing| !nd_dominates(pt, existing));
321        result.push(pt.clone());
322    }
323    result
324}
325
326/// Returns true if `a` weakly dominates `b` in all objectives (a_i <= b_i for all i).
327/// For the WFG internal filtering we use weak domination (a_i <= b_i AND at least one strict).
328fn nd_dominates(a: &[f64], b: &[f64]) -> bool {
329    let mut any_strict = false;
330    for (ai, bi) in a.iter().zip(b.iter()) {
331        if *ai > *bi {
332            return false;
333        }
334        if *ai < *bi {
335            any_strict = true;
336        }
337    }
338    any_strict
339}
340
341// ─────────────────────────────────────────────────────────────────────────────
342// Hypervolume contribution of a single point
343// ─────────────────────────────────────────────────────────────────────────────
344
345/// Compute the hypervolume contribution of solution `idx` to the total hypervolume.
346///
347/// The contribution is defined as:
348/// ```text
349/// HVC(x_i) = HV(P) - HV(P \ {x_i})
350/// ```
351/// where `P` is the full Pareto front.
352///
353/// # Arguments
354/// * `front`     — Full Pareto front as `&[Vec<f64>]`.
355/// * `reference` — Reference point.
356/// * `idx`       — Index of the solution whose contribution to compute.
357///
358/// # Errors
359/// Returns an error if `idx >= front.len()` or dimensions mismatch.
360///
361/// # Examples
362/// ```
363/// use scirs2_optimize::multiobjective::hypervolume::hypervolume_contribution_wfg;
364/// let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
365/// let contrib = hypervolume_contribution_wfg(&front, &[2.0, 2.0], 1).expect("valid input");
366/// assert!(contrib >= 0.0);
367/// ```
368pub fn hypervolume_contribution_wfg(
369    front: &[Vec<f64>],
370    reference: &[f64],
371    idx: usize,
372) -> OptimizeResult<f64> {
373    if idx >= front.len() {
374        return Err(OptimizeError::InvalidInput(format!(
375            "idx={idx} out of range for front of length {}",
376            front.len()
377        )));
378    }
379
380    let total_hv = hypervolume_wfg(front, reference)?;
381
382    // Front without the idx-th point
383    let reduced: Vec<Vec<f64>> = front
384        .iter()
385        .enumerate()
386        .filter(|(i, _)| *i != idx)
387        .map(|(_, pt)| pt.clone())
388        .collect();
389
390    let reduced_hv = hypervolume_wfg(&reduced, reference)?;
391    Ok((total_hv - reduced_hv).max(0.0))
392}
393
394// ─────────────────────────────────────────────────────────────────────────────
395// Exclusive (per-solution) hypervolume contributions
396// ─────────────────────────────────────────────────────────────────────────────
397
398/// Compute the exclusive hypervolume contribution of every solution in the front.
399///
400/// Returns a `Vec<f64>` of the same length as `front`, where each element is
401/// the hypervolume contribution of that solution (HVC(x_i) = HV(P) − HV(P \ {x_i})).
402///
403/// Useful for diversity-aware selection and archiving.
404///
405/// # Arguments
406/// * `front`     — Full Pareto front as `&[Vec<f64>]`.
407/// * `reference` — Reference point.
408///
409/// # Errors
410/// Returns an error if objective-vector dimensions are inconsistent.
411///
412/// # Examples
413/// ```
414/// use scirs2_optimize::multiobjective::hypervolume::exclusive_hypervolume;
415/// let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
416/// let hvc = exclusive_hypervolume(&front, &[2.0, 2.0]).expect("valid input");
417/// assert_eq!(hvc.len(), 3);
418/// let total: f64 = hvc.iter().sum();
419/// assert!(total > 0.0);
420/// ```
421pub fn exclusive_hypervolume(front: &[Vec<f64>], reference: &[f64]) -> OptimizeResult<Vec<f64>> {
422    if front.is_empty() {
423        return Ok(vec![]);
424    }
425
426    let n = front.len();
427    let n_obj = reference.len();
428    for (i, pt) in front.iter().enumerate() {
429        if pt.len() != n_obj {
430            return Err(OptimizeError::InvalidInput(format!(
431                "front[{i}] has length {} but reference has length {n_obj}",
432                pt.len()
433            )));
434        }
435    }
436
437    let total_hv = hypervolume_wfg(front, reference)?;
438    let mut contributions = Vec::with_capacity(n);
439
440    for idx in 0..n {
441        let reduced: Vec<Vec<f64>> = front
442            .iter()
443            .enumerate()
444            .filter(|(i, _)| *i != idx)
445            .map(|(_, pt)| pt.clone())
446            .collect();
447
448        let reduced_hv = hypervolume_wfg(&reduced, reference)?;
449        contributions.push((total_hv - reduced_hv).max(0.0));
450    }
451
452    Ok(contributions)
453}
454
455// ─────────────────────────────────────────────────────────────────────────────
456// Tests
457// ─────────────────────────────────────────────────────────────────────────────
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    // ── hypervolume_2d ────────────────────────────────────────────────────────
464
465    #[test]
466    fn test_hv2d_empty_front() {
467        let hv = hypervolume_2d(&[], (2.0, 2.0)).expect("failed to create hv");
468        assert_eq!(hv, 0.0);
469    }
470
471    #[test]
472    fn test_hv2d_single_point() {
473        // Point (0,0), reference (2,2): area = 2*2 = 4
474        let hv = hypervolume_2d(&[(0.0, 0.0)], (2.0, 2.0)).expect("failed to create hv");
475        assert!((hv - 4.0).abs() < 1e-10, "expected 4.0, got {hv}");
476    }
477
478    #[test]
479    fn test_hv2d_three_points_on_front() {
480        // Known: for (0,1), (0.5,0.5), (1,0) with ref (2,2)
481        // areas: [0→0.5]*[2-1] + [0.5→1]*[2-0.5] + [1→2]*[2-0] = 0.5 + 0.75 + 2 = 3.25
482        let front = &[(0.0f64, 1.0f64), (0.5, 0.5), (1.0, 0.0)];
483        let hv = hypervolume_2d(front, (2.0, 2.0)).expect("failed to create hv");
484        assert!((hv - 3.25).abs() < 1e-10, "expected 3.25, got {hv}");
485    }
486
487    #[test]
488    fn test_hv2d_unsorted_input() {
489        // Same as above but shuffled
490        let front = &[(1.0f64, 0.0f64), (0.0, 1.0), (0.5, 0.5)];
491        let hv = hypervolume_2d(front, (2.0, 2.0)).expect("failed to create hv");
492        assert!((hv - 3.25).abs() < 1e-10, "expected 3.25, got {hv}");
493    }
494
495    #[test]
496    fn test_hv2d_with_dominated_points() {
497        // Adding a dominated point (0.5, 1.0) shouldn't change HV
498        let front = &[(0.0, 1.0), (0.5, 0.5), (1.0, 0.0), (0.5, 1.0)];
499        let hv = hypervolume_2d(front, (2.0, 2.0)).expect("failed to create hv");
500        assert!((hv - 3.25).abs() < 1e-10, "expected 3.25, got {hv}");
501    }
502
503    #[test]
504    fn test_hv2d_point_outside_reference() {
505        // Point outside reference should not contribute
506        let front = &[(3.0, 0.5)]; // f1 > ref[0]
507        let hv = hypervolume_2d(front, (2.0, 2.0)).expect("failed to create hv");
508        assert_eq!(hv, 0.0);
509    }
510
511    // ── hypervolume_3d ────────────────────────────────────────────────────────
512
513    #[test]
514    fn test_hv3d_empty_front() {
515        let hv = hypervolume_3d(&[], &[2.0, 2.0, 2.0]).expect("failed to create hv");
516        assert_eq!(hv, 0.0);
517    }
518
519    #[test]
520    fn test_hv3d_single_point_unit_cube() {
521        // Point (0,0,0) with ref (1,1,1): volume = 1
522        let hv = hypervolume_3d(&[[0.0, 0.0, 0.0]], &[1.0, 1.0, 1.0]).expect("failed to create hv");
523        assert!((hv - 1.0).abs() < 1e-10, "expected 1.0, got {hv}");
524    }
525
526    #[test]
527    fn test_hv3d_two_non_dominated_points() {
528        // [0,0,1] and [0,1,0] with ref [2,2,2]
529        let front = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
530        let hv = hypervolume_3d(&front, &[2.0, 2.0, 2.0]).expect("failed to create hv");
531        assert!(hv > 0.0, "hypervolume should be positive, got {hv}");
532    }
533
534    #[test]
535    fn test_hv3d_matches_wfg() {
536        // Verify 3D result matches WFG on the same data
537        let front = [[0.2, 0.5, 0.8], [0.5, 0.3, 0.6], [0.8, 0.2, 0.4]];
538        let ref3 = [1.5, 1.5, 1.5];
539        let hv3d = hypervolume_3d(&front, &ref3).expect("failed to create hv3d");
540        let front_vecs: Vec<Vec<f64>> = front.iter().map(|p| p.to_vec()).collect();
541        let hv_wfg = hypervolume_wfg(&front_vecs, &ref3).expect("failed to create hv_wfg");
542        assert!(
543            (hv3d - hv_wfg).abs() < 1e-6,
544            "3D ({hv3d}) and WFG ({hv_wfg}) should agree"
545        );
546    }
547
548    // ── hypervolume_wfg ───────────────────────────────────────────────────────
549
550    #[test]
551    fn test_wfg_empty_front() {
552        let hv = hypervolume_wfg(&[], &[2.0, 2.0]).expect("failed to create hv");
553        assert_eq!(hv, 0.0);
554    }
555
556    #[test]
557    fn test_wfg_2d_matches_hv2d() {
558        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
559        let hv_wfg = hypervolume_wfg(&front, &[2.0, 2.0]).expect("failed to create hv_wfg");
560        let front_2d: Vec<(f64, f64)> = front.iter().map(|p| (p[0], p[1])).collect();
561        let hv_2d = hypervolume_2d(&front_2d, (2.0, 2.0)).expect("failed to create hv_2d");
562        assert!((hv_wfg - hv_2d).abs() < 1e-10, "WFG={hv_wfg}, 2D={hv_2d}");
563    }
564
565    #[test]
566    fn test_wfg_single_point() {
567        let front = vec![vec![1.0, 1.0, 1.0]];
568        let hv = hypervolume_wfg(&front, &[3.0, 3.0, 3.0]).expect("failed to create hv");
569        assert!((hv - 8.0).abs() < 1e-10, "expected 8.0, got {hv}");
570    }
571
572    #[test]
573    fn test_wfg_dimension_mismatch_error() {
574        let front = vec![vec![1.0, 2.0, 3.0]];
575        let result = hypervolume_wfg(&front, &[4.0, 4.0]); // wrong reference dim
576        assert!(result.is_err());
577    }
578
579    // ── hypervolume_contribution_wfg ─────────────────────────────────────────
580
581    #[test]
582    fn test_hvc_sum_leq_total() {
583        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
584        let total = hypervolume_wfg(&front, &[2.0, 2.0]).expect("failed to create total");
585        let hvc0 =
586            hypervolume_contribution_wfg(&front, &[2.0, 2.0], 0).expect("failed to create hvc0");
587        let hvc1 =
588            hypervolume_contribution_wfg(&front, &[2.0, 2.0], 1).expect("failed to create hvc1");
589        let hvc2 =
590            hypervolume_contribution_wfg(&front, &[2.0, 2.0], 2).expect("failed to create hvc2");
591        // All contributions should be non-negative
592        assert!(hvc0 >= 0.0 && hvc1 >= 0.0 && hvc2 >= 0.0);
593        // No single contribution exceeds total
594        assert!(hvc0 <= total + 1e-10);
595        assert!(hvc1 <= total + 1e-10);
596        assert!(hvc2 <= total + 1e-10);
597    }
598
599    #[test]
600    fn test_hvc_out_of_bounds_error() {
601        let front = vec![vec![0.0, 1.0]];
602        let result = hypervolume_contribution_wfg(&front, &[2.0, 2.0], 5);
603        assert!(result.is_err());
604    }
605
606    // ── exclusive_hypervolume ─────────────────────────────────────────────────
607
608    #[test]
609    fn test_exclusive_hv_correct_length() {
610        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
611        let hvc = exclusive_hypervolume(&front, &[2.0, 2.0]).expect("failed to create hvc");
612        assert_eq!(hvc.len(), 3);
613    }
614
615    #[test]
616    fn test_exclusive_hv_non_negative() {
617        let front = vec![
618            vec![0.1, 0.9],
619            vec![0.3, 0.7],
620            vec![0.5, 0.5],
621            vec![0.7, 0.3],
622            vec![0.9, 0.1],
623        ];
624        let hvc = exclusive_hypervolume(&front, &[1.5, 1.5]).expect("failed to create hvc");
625        for (i, &c) in hvc.iter().enumerate() {
626            assert!(c >= 0.0, "contribution[{i}] = {c} should be >= 0");
627        }
628    }
629
630    #[test]
631    fn test_exclusive_hv_empty_front() {
632        let hvc = exclusive_hypervolume(&[], &[2.0, 2.0]).expect("failed to create hvc");
633        assert!(hvc.is_empty());
634    }
635
636    #[test]
637    fn test_exclusive_hv_dominated_gets_zero() {
638        // Point (1.0, 1.0) is dominated by (0.5, 0.5)
639        let front = vec![vec![0.5, 0.5], vec![1.0, 1.0]];
640        let hvc = exclusive_hypervolume(&front, &[2.0, 2.0]).expect("failed to create hvc");
641        // Dominated point should contribute 0 (within floating tolerance)
642        assert!(
643            hvc[1] < 1e-10,
644            "dominated point contribution = {} should ~0",
645            hvc[1]
646        );
647    }
648
649    #[test]
650    fn test_exclusive_hv_extreme_points_larger_contribution() {
651        // Boundary points often have larger contributions
652        let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
653        let hvc = exclusive_hypervolume(&front, &[2.0, 2.0]).expect("failed to create hvc");
654        // Interior point (0.5, 0.5) should have non-zero contribution
655        assert!(hvc[1] >= 0.0, "interior contribution should be >= 0");
656    }
657}