Skip to main content

optirs_nas/multi_objective/
hypervolume.rs

1//! Hypervolume indicator: exact and bounded-approximate computation (F17), plus
2//! the pluggable [`HypervolumeCalculator`] / [`HypervolumeMethod`] front end.
3//!
4//! The calculator is used directly by [`super::nsga2`], [`super::nsga3`] and
5//! [`super::moead`] to derive the `convergence` front metric. (It previously
6//! also backed an `SmsEmoa` struct that had no constructor and no methods; that
7//! type is gone, the calculator is not.)
8
9use crate::error::{OptimError, Result};
10use crate::nas_engine::OptimizationDirection;
11use scirs2_core::numeric::Float;
12use scirs2_core::random::Random;
13use std::cmp::Ordering;
14use std::collections::HashMap;
15use std::fmt::Debug;
16
17/// Hypervolume calculator
18#[derive(Debug)]
19pub struct HypervolumeCalculator<T: Float + Debug + Send + Sync + 'static> {
20    /// Calculation method
21    pub(super) method: HypervolumeMethod,
22    /// Reference point
23    pub(super) reference_point: Vec<T>,
24    /// Cached hypervolumes
25    pub(super) cache: HashMap<String, T>,
26    /// Number of samples drawn by [`HypervolumeMethod::MonteCarlo`].
27    pub(super) monte_carlo_samples: usize,
28    /// Seed used by [`HypervolumeMethod::MonteCarlo`] so estimates are
29    /// reproducible for a given point set.
30    pub(super) monte_carlo_seed: u64,
31}
32/// Hypervolume calculation methods
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HypervolumeMethod {
35    /// Walking Fish Group algorithm.
36    ///
37    /// WFG computes the *exact* hypervolume, so this crate evaluates it with the
38    /// same exact slicing recursion as [`HypervolumeMethod::HSO`]. The two
39    /// therefore return bit-identical results; they differ only in the internal
40    /// traversal order a dedicated implementation would use, never in the value.
41    WFG,
42    /// Quick hypervolume (QHV).
43    ///
44    /// Also an *exact* algorithm, evaluated here through the same exact
45    /// recursion. Selecting it is honest — the returned number is the true
46    /// hypervolume — it simply does not use QHV's own pivot decomposition.
47    Quick,
48    /// Hypervolume by slicing objectives (the recursion implemented here).
49    HSO,
50    /// Monte Carlo estimation by uniform sampling of the reference box.
51    ///
52    /// This is a genuine estimator (not a fallback to the exact routine): it
53    /// draws `HypervolumeCalculator::monte_carlo_samples` uniform points from
54    /// the box spanned by the front's ideal corner and the reference point and
55    /// returns `box_volume * dominated_fraction`. Use it only when an
56    /// approximation is acceptable; it is seeded, so repeated calls on the same
57    /// input agree.
58    MonteCarlo,
59}
60/// Maximum number of front points fed to the exact hypervolume recursion when
61/// there are four or more objectives. Beyond this the front is truncated to
62/// the points closest to the ideal, yielding a documented lower-bound
63/// approximation that keeps the (super-cubic) recursion tractable. Two- and
64/// three-objective fronts are always computed exactly.
65pub(super) const HV_HIGH_D_POINT_CAP: usize = 64;
66
67/// Remove points that are Pareto-dominated by another point under a pure
68/// minimization convention. Dominated points contribute no additional
69/// hypervolume, so discarding them is exact and both cheapens the recursion
70/// and makes the metric invariant to adding dominated points.
71fn retain_non_dominated_min<T: Float>(points: &[Vec<T>]) -> Vec<Vec<T>> {
72    let mut kept: Vec<Vec<T>> = Vec::new();
73    for (i, p) in points.iter().enumerate() {
74        let mut dominated = false;
75        for (j, q) in points.iter().enumerate() {
76            if i == j {
77                continue;
78            }
79            // `q` dominates `p` when q <= p in every objective and q < p in at
80            // least one. Identical vectors do not dominate one another and are
81            // both retained (duplicates add no volume to the union).
82            let mut all_le = true;
83            let mut any_lt = false;
84            let len = p.len().min(q.len());
85            for k in 0..len {
86                if q[k] > p[k] {
87                    all_le = false;
88                    break;
89                }
90                if q[k] < p[k] {
91                    any_lt = true;
92                }
93            }
94            if all_le && any_lt {
95                dominated = true;
96                break;
97            }
98        }
99        if !dominated {
100            kept.push(p.clone());
101        }
102    }
103    kept
104}
105
106/// Recursive Hypervolume by Slicing Objectives (HSO). `points` are oriented so
107/// that smaller is better in every objective and `reference` is the (worse
108/// than every point) upper corner. The algorithm sweeps the last objective,
109/// accumulating the (dims-1)-dimensional cross-sectional volume of the active
110/// point set times each slab depth. Exact for any dimension; complexity grows
111/// with dimension and point count, so callers cap the point count for
112/// high-dimensional fronts.
113fn hv_recursive<T: Float>(points: &[Vec<T>], reference: &[T], dims: usize) -> T {
114    if points.is_empty() || dims == 0 {
115        return T::zero();
116    }
117    if dims == 1 {
118        // Extent from the best (smallest) coordinate to the reference.
119        let mut best = reference[0];
120        for p in points {
121            if p[0] < best {
122                best = p[0];
123            }
124        }
125        let extent = reference[0] - best;
126        return if extent > T::zero() {
127            extent
128        } else {
129            T::zero()
130        };
131    }
132
133    let last = dims - 1;
134    let mut sorted: Vec<&Vec<T>> = points.iter().collect();
135    sorted.sort_by(|a, b| a[last].partial_cmp(&b[last]).unwrap_or(Ordering::Equal));
136
137    let mut volume = T::zero();
138    for i in 0..sorted.len() {
139        let lower = sorted[i][last];
140        let upper = if i + 1 < sorted.len() {
141            sorted[i + 1][last]
142        } else {
143            reference[last]
144        };
145        let depth = upper - lower;
146        if depth <= T::zero() {
147            continue;
148        }
149        // Cross-section: the (dims-1)-dimensional hypervolume of the points
150        // active in this slab (those with a smaller-or-equal last coordinate),
151        // projected onto the leading objectives.
152        let projections: Vec<Vec<T>> = sorted[..=i].iter().map(|p| p[..last].to_vec()).collect();
153        let area = hv_recursive(&projections, &reference[..last], last);
154        volume = volume + area * depth;
155    }
156    volume
157}
158
159/// Exact (two- and three-objective) / bounded-approximate (four or more
160/// objective) hypervolume of `points` relative to `reference`, under a pure
161/// minimization convention (F17). Points that do not strictly dominate the
162/// reference contribute nothing and are discarded; Pareto-dominated points are
163/// likewise removed (they add no volume), which makes the metric invariant to
164/// adding dominated points and strictly increasing when a genuinely
165/// non-dominated point is added.
166pub fn hypervolume_minimization<T: Float>(points: &[Vec<T>], reference: &[T]) -> T {
167    let dims = reference.len();
168    if points.is_empty() || dims == 0 {
169        return T::zero();
170    }
171
172    // Keep only points that strictly dominate the reference in every objective.
173    let feasible: Vec<Vec<T>> = points
174        .iter()
175        .filter(|p| p.len() >= dims && (0..dims).all(|k| p[k] < reference[k]))
176        .map(|p| p[..dims].to_vec())
177        .collect();
178
179    // Discard dominated points (exact, and it cheapens the recursion).
180    let mut front = retain_non_dominated_min(&feasible);
181    if front.is_empty() {
182        return T::zero();
183    }
184
185    // In four or more objectives the recursion is super-cubic; cap the point
186    // count to keep it tractable (documented lower-bound approximation). The
187    // retained points are those closest (L1) to the front-derived ideal.
188    if dims >= 4 && front.len() > HV_HIGH_D_POINT_CAP {
189        let mut ideal = vec![T::infinity(); dims];
190        for p in &front {
191            for k in 0..dims {
192                if p[k] < ideal[k] {
193                    ideal[k] = p[k];
194                }
195            }
196        }
197        front.sort_by(|a, b| {
198            let da = (0..dims).fold(T::zero(), |acc, k| acc + (a[k] - ideal[k]).abs());
199            let db = (0..dims).fold(T::zero(), |acc, k| acc + (b[k] - ideal[k]).abs());
200            da.partial_cmp(&db).unwrap_or(Ordering::Equal)
201        });
202        front.truncate(HV_HIGH_D_POINT_CAP);
203    }
204
205    hv_recursive(&front, reference, dims)
206}
207
208/// Negate every objective whose optimization direction is
209/// [`OptimizationDirection::Maximize`], turning an arbitrary mixed-direction
210/// objective vector into the pure-minimization convention that
211/// [`hypervolume_minimization`] (and every other indicator in
212/// [`super::metrics`]) requires.
213///
214/// The same transform must be applied to the reference point, otherwise a
215/// maximized objective is compared against a bound on the wrong side and the
216/// indicator silently collapses to zero. [`normalize_front_for_minimization`]
217/// and [`normalize_reference_for_minimization`] exist so callers cannot forget.
218fn negate_maximized<T: Float>(values: &[T], directions: &[OptimizationDirection]) -> Vec<T> {
219    values
220        .iter()
221        .enumerate()
222        .map(|(k, &v)| match directions.get(k) {
223            Some(OptimizationDirection::Maximize) => -v,
224            _ => v,
225        })
226        .collect()
227}
228
229/// Map a whole front into the pure-minimization convention (see
230/// `negate_maximized`).
231pub fn normalize_front_for_minimization<T: Float>(
232    front: &[Vec<T>],
233    directions: &[OptimizationDirection],
234) -> Vec<Vec<T>> {
235    front
236        .iter()
237        .map(|point| negate_maximized(point, directions))
238        .collect()
239}
240
241/// Map a reference point into the pure-minimization convention (see
242/// `negate_maximized`).
243///
244/// The transform is its own inverse (negating the same entries twice restores the
245/// original), so the same call converts a minimization-space reference back into
246/// raw objective space.
247pub fn normalize_reference_for_minimization<T: Float>(
248    reference: &[T],
249    directions: &[OptimizationDirection],
250) -> Vec<T> {
251    negate_maximized(reference, directions)
252}
253
254/// Relative margin added beyond the observed nadir when a reference point has
255/// to be derived from the first observed front.
256const HV_REFERENCE_MARGIN_FRACTION: f64 = 0.1;
257
258/// Absolute floor for the derived reference margin, so a degenerate (single
259/// point, or zero-range) front still yields a strictly positive reference box
260/// instead of a zero hypervolume.
261const HV_REFERENCE_MARGIN_FLOOR: f64 = 1e-3;
262
263/// Derive a reference point from an observed front, in the pure-minimization
264/// convention.
265///
266/// For each objective the reference is placed past the observed nadir (the worst
267/// value on the front) by `max(0.1 * range, 0.1 * |nadir|, 1e-3)`. Callers are
268/// expected to derive this **once** and then hold it fixed: a reference that
269/// tracks the current front makes the hypervolume non-comparable between
270/// generations, since the measured box itself moves.
271pub fn derive_reference_point<T: Float>(front: &[Vec<T>]) -> Option<Vec<T>> {
272    let dims = front.first()?.len();
273    if dims == 0 {
274        return None;
275    }
276    let mut min_values = vec![T::infinity(); dims];
277    let mut max_values = vec![T::neg_infinity(); dims];
278    for point in front {
279        if point.len() < dims {
280            return None;
281        }
282        for k in 0..dims {
283            if point[k] < min_values[k] {
284                min_values[k] = point[k];
285            }
286            if point[k] > max_values[k] {
287                max_values[k] = point[k];
288            }
289        }
290    }
291
292    let fraction: T =
293        scirs2_core::numeric::NumCast::from(HV_REFERENCE_MARGIN_FRACTION).unwrap_or_else(T::zero);
294    let floor: T =
295        scirs2_core::numeric::NumCast::from(HV_REFERENCE_MARGIN_FLOOR).unwrap_or_else(T::zero);
296
297    let mut reference = Vec::with_capacity(dims);
298    for k in 0..dims {
299        if !min_values[k].is_finite() || !max_values[k].is_finite() {
300            return None;
301        }
302        let range = max_values[k] - min_values[k];
303        let margin = (range * fraction)
304            .max(max_values[k].abs() * fraction)
305            .max(floor);
306        reference.push(max_values[k] + margin);
307    }
308    Some(reference)
309}
310
311impl<T: Float + Debug + Send + Sync + 'static> HypervolumeCalculator<T> {
312    /// Default number of Monte Carlo samples.
313    pub const DEFAULT_MONTE_CARLO_SAMPLES: usize = 100_000;
314
315    /// Create a calculator for `method` measuring against `reference_point`
316    /// (expressed in the pure-minimization convention).
317    pub fn new(method: HypervolumeMethod, reference_point: Vec<T>) -> Self {
318        Self {
319            method,
320            reference_point,
321            cache: HashMap::new(),
322            monte_carlo_samples: Self::DEFAULT_MONTE_CARLO_SAMPLES,
323            monte_carlo_seed: 0x5EED_1234_ABCD_0001,
324        }
325    }
326
327    /// The configured calculation method.
328    pub fn method(&self) -> HypervolumeMethod {
329        self.method
330    }
331
332    /// The reference point currently in use.
333    pub fn reference_point(&self) -> &[T] {
334        &self.reference_point
335    }
336
337    /// Replace the reference point. The cache is invalidated because cached
338    /// values are only meaningful for the reference they were computed against.
339    pub fn set_reference_point(&mut self, reference_point: Vec<T>) {
340        self.reference_point = reference_point;
341        self.cache.clear();
342    }
343
344    /// Configure the Monte Carlo sample count (ignored by the exact methods).
345    pub fn set_monte_carlo_samples(&mut self, samples: usize) {
346        self.monte_carlo_samples = samples;
347        self.cache.clear();
348    }
349
350    /// Drop every cached hypervolume.
351    pub fn clear_cache(&mut self) {
352        self.cache.clear();
353    }
354
355    /// Compute the hypervolume of `points` (pure-minimization convention).
356    ///
357    /// Returns an error rather than a fabricated number when the reference point
358    /// is missing or a point has fewer objectives than the reference.
359    pub fn calculate(&mut self, points: &[Vec<T>]) -> Result<T> {
360        if self.reference_point.is_empty() {
361            return Err(OptimError::InvalidConfig(
362                "hypervolume requires a non-empty reference point".to_string(),
363            ));
364        }
365        let dims = self.reference_point.len();
366        if let Some(bad) = points.iter().find(|p| p.len() < dims) {
367            return Err(OptimError::InvalidParameter(format!(
368                "hypervolume point has {} objectives but the reference point has {}",
369                bad.len(),
370                dims
371            )));
372        }
373        if points.is_empty() {
374            return Ok(T::zero());
375        }
376
377        let key = self.cache_key(points);
378        if let Some(cached) = self.cache.get(&key) {
379            return Ok(*cached);
380        }
381
382        let value = match self.method {
383            // All three exact methods return the true hypervolume; see the
384            // variant docs for why they share one implementation.
385            HypervolumeMethod::HSO | HypervolumeMethod::WFG | HypervolumeMethod::Quick => {
386                hypervolume_minimization(points, &self.reference_point)
387            }
388            HypervolumeMethod::MonteCarlo => self.monte_carlo_estimate(points)?,
389        };
390        self.cache.insert(key, value);
391        Ok(value)
392    }
393
394    /// Exclusive hypervolume contribution of `points[index]`: how much
395    /// hypervolume is lost when that single point is removed. This is the
396    /// quantity SMS-EMOA's environmental selection ranks on.
397    pub fn contribution(&mut self, points: &[Vec<T>], index: usize) -> Result<T> {
398        if index >= points.len() {
399            return Err(OptimError::InvalidParameter(format!(
400                "hypervolume contribution index {} is out of range for {} points",
401                index,
402                points.len()
403            )));
404        }
405        let total = self.calculate(points)?;
406        let without: Vec<Vec<T>> = points
407            .iter()
408            .enumerate()
409            .filter(|(i, _)| *i != index)
410            .map(|(_, p)| p.clone())
411            .collect();
412        let reduced = self.calculate(&without)?;
413        Ok(total - reduced)
414    }
415
416    /// Monte Carlo estimate: sample the box spanned by the front's ideal corner
417    /// and the reference point, and scale the box volume by the fraction of
418    /// samples that are dominated by at least one point.
419    fn monte_carlo_estimate(&self, points: &[Vec<T>]) -> Result<T> {
420        let dims = self.reference_point.len();
421        if self.monte_carlo_samples == 0 {
422            return Err(OptimError::InvalidConfig(
423                "Monte Carlo hypervolume requires at least one sample".to_string(),
424            ));
425        }
426
427        // Only points that strictly dominate the reference bound any volume.
428        let feasible: Vec<&Vec<T>> = points
429            .iter()
430            .filter(|p| (0..dims).all(|k| p[k] < self.reference_point[k]))
431            .collect();
432        if feasible.is_empty() {
433            return Ok(T::zero());
434        }
435
436        let mut lower = vec![T::infinity(); dims];
437        for point in &feasible {
438            for (slot, value) in lower.iter_mut().zip(point.iter()) {
439                if *value < *slot {
440                    *slot = *value;
441                }
442            }
443        }
444
445        let mut box_volume = 1.0f64;
446        let mut spans = Vec::with_capacity(dims);
447        for (bound, floor) in self.reference_point.iter().zip(lower.iter()) {
448            let span = (*bound - *floor).to_f64().unwrap_or(0.0);
449            if span <= 0.0 {
450                return Ok(T::zero());
451            }
452            spans.push(span);
453            box_volume *= span;
454        }
455
456        let lower_f64: Vec<f64> = lower.iter().map(|v| v.to_f64().unwrap_or(0.0)).collect();
457        let feasible_f64: Vec<Vec<f64>> = feasible
458            .iter()
459            .map(|p| (0..dims).map(|k| p[k].to_f64().unwrap_or(0.0)).collect())
460            .collect();
461
462        let mut rng = Random::seed(self.monte_carlo_seed);
463        let mut hits = 0usize;
464        let mut sample = vec![0.0f64; dims];
465        for _ in 0..self.monte_carlo_samples {
466            for k in 0..dims {
467                sample[k] = lower_f64[k] + rng.gen_range(0.0..1.0) * spans[k];
468            }
469            if feasible_f64
470                .iter()
471                .any(|p| (0..dims).all(|k| p[k] <= sample[k]))
472            {
473                hits += 1;
474            }
475        }
476
477        let fraction = hits as f64 / self.monte_carlo_samples as f64;
478        Ok(scirs2_core::numeric::NumCast::from(box_volume * fraction).unwrap_or_else(T::zero))
479    }
480
481    /// Deterministic cache key covering the method, the reference point and the
482    /// (order-independent) point set, all by exact bit pattern.
483    fn cache_key(&self, points: &[Vec<T>]) -> String {
484        let mut rows: Vec<Vec<u64>> = points
485            .iter()
486            .map(|p| {
487                p.iter()
488                    .map(|v| v.to_f64().unwrap_or(f64::NAN).to_bits())
489                    .collect()
490            })
491            .collect();
492        rows.sort_unstable();
493        let reference: Vec<u64> = self
494            .reference_point
495            .iter()
496            .map(|v| v.to_f64().unwrap_or(f64::NAN).to_bits())
497            .collect();
498        format!(
499            "{:?}|{:?}|{}|{:?}",
500            self.method, reference, self.monte_carlo_samples, rows
501        )
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    /// Exact 2-objective hypervolume, verifiable by hand: the union of
510    /// `[1,2]x[0,2]` and `[0,2]x[1,2]` has area `2 + 2 - 1 = 3`.
511    #[test]
512    fn two_objective_hypervolume_matches_the_published_value() {
513        let front = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
514        let reference = vec![2.0, 2.0];
515        let hv = hypervolume_minimization(&front, &reference);
516        assert!(
517            (hv - 3.0).abs() < 1e-12,
518            "expected exactly 3.0, got {hv} (the pre-fix bounding-box heuristic returned 4.0 * 2 = 8.0)"
519        );
520    }
521
522    /// Exact 3-objective hypervolume by inclusion-exclusion:
523    /// `3 * 4 - 3 * 2 + 1 = 7`.
524    #[test]
525    fn three_objective_hypervolume_matches_the_published_value() {
526        let front = vec![
527            vec![1.0, 0.0, 0.0],
528            vec![0.0, 1.0, 0.0],
529            vec![0.0, 0.0, 1.0],
530        ];
531        let reference = vec![2.0, 2.0, 2.0];
532        let hv = hypervolume_minimization(&front, &reference);
533        assert!(
534            (hv - 7.0).abs() < 1e-12,
535            "expected exactly 7.0 (12 - 6 + 1), got {hv}"
536        );
537    }
538
539    /// A convex 2-objective front on the unit box: inclusion-exclusion gives
540    /// `0.43 - 0.11 + 0.01 = 0.33`.
541    #[test]
542    fn three_point_convex_front_has_the_hand_computed_hypervolume() {
543        let front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
544        let reference = vec![1.0, 1.0];
545        let hv = hypervolume_minimization(&front, &reference);
546        assert!((hv - 0.33).abs() < 1e-12, "expected 0.33, got {hv}");
547    }
548
549    /// A single point spans exactly its own box; the old heuristic multiplied a
550    /// zero-range bounding box by the point count instead.
551    #[test]
552    fn single_point_hypervolume_is_its_own_box() {
553        let hv = hypervolume_minimization(&[vec![0.5, 0.25]], &[1.0, 1.0]);
554        assert!((hv - 0.375).abs() < 1e-12, "expected 0.5 * 0.75, got {hv}");
555    }
556
557    /// Adding a dominated point cannot change the indicator, and adding a
558    /// genuinely non-dominated point must strictly increase it. The old
559    /// bounding-box-times-count heuristic violated both.
560    #[test]
561    fn hypervolume_is_monotone_and_ignores_dominated_points() {
562        let reference = vec![2.0, 2.0];
563        let base = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
564        let with_dominated = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.5, 1.5]];
565        let with_new = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.5, 0.5]];
566
567        let hv_base = hypervolume_minimization(&base, &reference);
568        let hv_dominated = hypervolume_minimization(&with_dominated, &reference);
569        let hv_new = hypervolume_minimization(&with_new, &reference);
570
571        assert!(
572            (hv_base - hv_dominated).abs() < 1e-12,
573            "a dominated point must not change the hypervolume: {hv_base} vs {hv_dominated}"
574        );
575        assert!(
576            hv_new > hv_base + 1e-12,
577            "a non-dominated point must strictly increase it: {hv_new} vs {hv_base}"
578        );
579    }
580
581    /// Points on the far side of the reference point contribute nothing.
582    #[test]
583    fn points_not_dominating_the_reference_contribute_nothing() {
584        let hv = hypervolume_minimization(&[vec![3.0, 3.0], vec![5.0, 0.5]], &[2.0, 2.0]);
585        assert_eq!(hv, 0.0);
586    }
587
588    #[test]
589    fn maximized_objectives_are_negated_on_both_front_and_reference() {
590        let directions = vec![
591            OptimizationDirection::Maximize,
592            OptimizationDirection::Minimize,
593        ];
594        // Raw space: maximize accuracy (0.9 is good), minimize memory.
595        let front = vec![vec![0.9, 1.0], vec![0.6, 0.5]];
596        let reference = vec![0.0, 2.0];
597
598        let min_front = normalize_front_for_minimization(&front, &directions);
599        let min_reference = normalize_reference_for_minimization(&reference, &directions);
600        assert_eq!(min_front, vec![vec![-0.9, 1.0], vec![-0.6, 0.5]]);
601        assert_eq!(min_reference, vec![0.0, 2.0]);
602
603        // Union of [-0.9,0]x[1,2] and [-0.6,0]x[0.5,2]: 0.9*1 + 0.6*1.5 - 0.6*1
604        let hv = hypervolume_minimization(&min_front, &min_reference);
605        assert!((hv - 1.2).abs() < 1e-12, "expected 1.2, got {hv}");
606
607        // Forgetting the negation collapses the indicator to zero, which is the
608        // silent failure this helper pair exists to prevent.
609        assert_eq!(hypervolume_minimization(&front, &reference), 0.0);
610    }
611
612    #[test]
613    fn every_exact_method_returns_the_same_value() {
614        let front = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
615        for method in [
616            HypervolumeMethod::HSO,
617            HypervolumeMethod::WFG,
618            HypervolumeMethod::Quick,
619        ] {
620            let mut calc = HypervolumeCalculator::new(method, vec![2.0, 2.0]);
621            let hv = calc.calculate(&front).expect("exact hypervolume");
622            assert!((hv - 3.0).abs() < 1e-12, "{method:?} returned {hv}");
623        }
624    }
625
626    #[test]
627    fn monte_carlo_estimate_is_close_to_the_exact_value_and_reproducible() {
628        let front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
629        let mut calc = HypervolumeCalculator::new(HypervolumeMethod::MonteCarlo, vec![1.0, 1.0]);
630        calc.set_monte_carlo_samples(200_000);
631
632        let first = calc.calculate(&front).expect("monte carlo hypervolume");
633        calc.clear_cache();
634        let second = calc.calculate(&front).expect("monte carlo hypervolume");
635
636        assert_eq!(first, second, "a seeded estimator must be reproducible");
637        assert!(
638            (first - 0.33).abs() < 5e-3,
639            "estimate {first} is too far from the exact 0.33"
640        );
641        // It must be an estimate, not a silent hand-off to the exact routine.
642        assert_ne!(first, 0.33);
643    }
644
645    #[test]
646    fn calculate_rejects_a_missing_reference_and_short_points() {
647        let mut no_reference: HypervolumeCalculator<f64> =
648            HypervolumeCalculator::new(HypervolumeMethod::HSO, Vec::new());
649        assert!(no_reference.calculate(&[vec![0.0, 0.0]]).is_err());
650
651        let mut calc = HypervolumeCalculator::new(HypervolumeMethod::HSO, vec![1.0, 1.0]);
652        assert!(calc.calculate(&[vec![0.5]]).is_err());
653        assert_eq!(calc.calculate(&[]).expect("empty front"), 0.0);
654    }
655
656    #[test]
657    fn exclusive_contribution_is_the_volume_lost_by_removing_a_point() {
658        // Middle point of the convex front: 0.33 - (0.09 + 0.09 - 0.01) = 0.16
659        let front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
660        let mut calc = HypervolumeCalculator::new(HypervolumeMethod::HSO, vec![1.0, 1.0]);
661        let contribution = calc.contribution(&front, 1).expect("contribution");
662        assert!(
663            (contribution - 0.16).abs() < 1e-12,
664            "expected 0.16, got {contribution}"
665        );
666        assert!(calc.contribution(&front, 9).is_err());
667    }
668
669    #[test]
670    fn derived_reference_point_is_strictly_worse_than_the_whole_front() {
671        let front = vec![vec![0.1, 0.9], vec![0.9, 0.1]];
672        let reference = derive_reference_point(&front).expect("reference from a non-empty front");
673        assert_eq!(reference.len(), 2);
674        for point in &front {
675            for k in 0..2 {
676                assert!(
677                    point[k] < reference[k],
678                    "reference {:?} must dominate-bound {:?}",
679                    reference,
680                    point
681                );
682            }
683        }
684        assert!(
685            hypervolume_minimization(&front, &reference) > 0.0,
686            "a derived reference must yield a positive hypervolume"
687        );
688
689        // Degenerate single-point front still yields a usable box.
690        let single = derive_reference_point(&[vec![0.0, 0.0]]).expect("single point reference");
691        assert!(single.iter().all(|v| *v > 0.0), "got {single:?}");
692        assert!(derive_reference_point::<f64>(&[]).is_none());
693    }
694}