Skip to main content

fcmaes_core/
indicators.rs

1//! Auditable quality indicators for minimized multi-objective fronts.
2//!
3//! Every function rejects empty, non-finite, or dimensionally inconsistent
4//! input. Hypervolume additionally requires an explicit reference point that
5//! is weakly worse than every approximation point. Exact and sampled results
6//! are different enum variants so an approximation cannot be reported as an
7//! exact value accidentally.
8
9use std::error::Error;
10use std::fmt;
11
12use crate::Rng;
13
14const DEFAULT_MONTE_CARLO_SAMPLES: usize = 100_000;
15const DEFAULT_MONTE_CARLO_SEED: u64 = 0x4856_2d4d_4f4e_5445;
16
17/// Validation failures returned by quality indicators.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum IndicatorError {
20    /// A front or reference set was empty.
21    EmptySet,
22    /// A point had no objective coordinates.
23    EmptyPoint,
24    /// Point and reference dimensions differed.
25    DimensionMismatch,
26    /// An objective coordinate was NaN or infinite.
27    NonFiniteValue,
28    /// A hypervolume point lay outside the reference box.
29    ReferencePointViolation,
30    /// A sampled estimate requested fewer than two samples.
31    InsufficientSamples,
32}
33
34impl fmt::Display for IndicatorError {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        let message = match self {
37            Self::EmptySet => "indicator sets must not be empty",
38            Self::EmptyPoint => "indicator points must have at least one objective",
39            Self::DimensionMismatch => "indicator point dimensions must match",
40            Self::NonFiniteValue => "indicator coordinates must be finite",
41            Self::ReferencePointViolation => {
42                "hypervolume points must be weakly better than the reference point"
43            }
44            Self::InsufficientSamples => "Monte Carlo hypervolume requires at least two samples",
45        };
46        formatter.write_str(message)
47    }
48}
49
50impl Error for IndicatorError {}
51
52/// Explicit, validated hypervolume reference point for minimized objectives.
53#[derive(Clone, Debug, PartialEq)]
54pub struct ReferencePoint(Vec<f64>);
55
56impl ReferencePoint {
57    /// Validate and construct a reference point.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`IndicatorError::EmptyPoint`] for zero objectives and
62    /// [`IndicatorError::NonFiniteValue`] for NaN or infinite coordinates.
63    pub fn new(coordinates: Vec<f64>) -> Result<Self, IndicatorError> {
64        if coordinates.is_empty() {
65            return Err(IndicatorError::EmptyPoint);
66        }
67        if coordinates.iter().any(|value| !value.is_finite()) {
68            return Err(IndicatorError::NonFiniteValue);
69        }
70        Ok(Self(coordinates))
71    }
72
73    /// Borrow the reference coordinates.
74    pub fn as_slice(&self) -> &[f64] {
75        &self.0
76    }
77
78    /// Number of minimized objectives.
79    pub fn dimension(&self) -> usize {
80        self.0.len()
81    }
82}
83
84/// Exact or explicitly sampled hypervolume estimate.
85#[derive(Clone, Debug, PartialEq)]
86pub enum HypervolumeEstimate {
87    /// Exact union volume of the dominated axis-aligned boxes.
88    Exact(f64),
89    /// Uniform Monte Carlo estimate and its Bernoulli standard error.
90    MonteCarlo {
91        /// Estimated dominated volume.
92        value: f64,
93        /// One-standard-deviation sampling uncertainty.
94        standard_error: f64,
95        /// Number of uniform samples used.
96        samples: usize,
97        /// Seed used by the deterministic sampler.
98        seed: u64,
99    },
100}
101
102impl HypervolumeEstimate {
103    /// Numeric volume regardless of exact or sampled provenance.
104    pub fn value(&self) -> f64 {
105        match self {
106            Self::Exact(value) | Self::MonteCarlo { value, .. } => *value,
107        }
108    }
109}
110
111/// Hypervolume value plus deterministic front-cleanup accounting.
112#[derive(Clone, Debug, PartialEq)]
113pub struct HypervolumeReport {
114    /// Exact or Monte Carlo volume.
115    pub estimate: HypervolumeEstimate,
116    /// Number of points supplied by the caller.
117    pub input_points: usize,
118    /// Number of unique nondominated points used in the calculation.
119    pub retained_points: usize,
120    /// Exact duplicate points removed before dominance filtering.
121    pub duplicates_collapsed: usize,
122    /// Unique dominated points removed before integration.
123    pub dominated_removed: usize,
124    /// Points excluded because at least one coordinate was worse than the
125    /// reference point. Always zero under [`OutsidePolicy::Strict`].
126    pub outside_reference: usize,
127}
128
129/// Policy for hypervolume points outside the minimized reference box.
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131pub enum OutsidePolicy {
132    /// Reject the complete front with [`IndicatorError::ReferencePointViolation`].
133    Strict,
134    /// Exclude outside points, report their count, and never clip coordinates.
135    Exclude,
136}
137
138fn same_point(left: &[f64], right: &[f64]) -> bool {
139    left.iter().zip(right).all(|(a, b)| a == b)
140}
141
142fn dominates(left: &[f64], right: &[f64]) -> bool {
143    let mut strict = false;
144    for (&a, &b) in left.iter().zip(right) {
145        if a > b {
146            return false;
147        }
148        strict |= a < b;
149    }
150    strict
151}
152
153struct CleanedHypervolumeFront {
154    points: Vec<Vec<f64>>,
155    duplicates: usize,
156    dominated: usize,
157    outside: usize,
158}
159
160fn clean_hypervolume_front(
161    front: &[Vec<f64>],
162    reference: &ReferencePoint,
163    policy: OutsidePolicy,
164) -> Result<CleanedHypervolumeFront, IndicatorError> {
165    validate_set(front, Some(reference.dimension()))?;
166    let outside = |point: &Vec<f64>| {
167        point
168            .iter()
169            .zip(reference.as_slice())
170            .any(|(&value, &limit)| value > limit)
171    };
172    let outside_reference = front.iter().filter(|point| outside(point)).count();
173    if policy == OutsidePolicy::Strict && outside_reference > 0 {
174        return Err(IndicatorError::ReferencePointViolation);
175    }
176
177    let mut unique: Vec<Vec<f64>> = front
178        .iter()
179        .filter(|point| !outside(point))
180        .cloned()
181        .collect();
182    unique.sort_by(|left, right| {
183        left.iter()
184            .zip(right)
185            .find_map(|(a, b)| (a != b).then(|| a.total_cmp(b)))
186            .unwrap_or(std::cmp::Ordering::Equal)
187    });
188    unique.dedup_by(|left, right| same_point(left, right));
189    let duplicates = front.len() - outside_reference - unique.len();
190    let retained: Vec<Vec<f64>> = unique
191        .iter()
192        .enumerate()
193        .filter(|(index, point)| {
194            !unique
195                .iter()
196                .enumerate()
197                .any(|(other, candidate)| other != *index && dominates(candidate, point))
198        })
199        .map(|(_, point)| point.clone())
200        .collect();
201    let dominated = unique.len() - retained.len();
202    Ok(CleanedHypervolumeFront {
203        points: retained,
204        duplicates,
205        dominated,
206        outside: outside_reference,
207    })
208}
209
210fn exact_union(points: &[&[f64]], reference: &[f64], dimensions: usize) -> f64 {
211    if points.is_empty() {
212        return 0.0;
213    }
214    if dimensions == 1 {
215        let minimum = points
216            .iter()
217            .map(|point| point[0])
218            .fold(reference[0], f64::min);
219        return (reference[0] - minimum).max(0.0);
220    }
221    let axis = dimensions - 1;
222    let mut cuts: Vec<f64> = points
223        .iter()
224        .map(|point| point[axis])
225        .filter(|value| *value < reference[axis])
226        .collect();
227    cuts.sort_by(f64::total_cmp);
228    cuts.dedup_by(|left, right| left == right);
229    let mut volume = 0.0;
230    for (index, &lower) in cuts.iter().enumerate() {
231        let upper = cuts.get(index + 1).copied().unwrap_or(reference[axis]);
232        if upper <= lower {
233            continue;
234        }
235        let active: Vec<&[f64]> = points
236            .iter()
237            .copied()
238            .filter(|point| point[axis] <= lower)
239            .collect();
240        volume += (upper - lower) * exact_union(&active, reference, axis);
241    }
242    volume
243}
244
245fn report(
246    input_points: usize,
247    retained: &[Vec<f64>],
248    duplicates_collapsed: usize,
249    dominated_removed: usize,
250    outside_reference: usize,
251    estimate: HypervolumeEstimate,
252) -> HypervolumeReport {
253    HypervolumeReport {
254        estimate,
255        input_points,
256        retained_points: retained.len(),
257        duplicates_collapsed,
258        dominated_removed,
259        outside_reference,
260    }
261}
262
263/// Compute exact hypervolume through four objectives and deterministic Monte
264/// Carlo hypervolume above four objectives.
265///
266/// The default approximation uses 100,000 samples and a fixed seed. Published
267/// experiments should call [`hypervolume_monte_carlo`] with an explicit seed.
268///
269/// # Errors
270///
271/// Returns an [`IndicatorError`] for empty, non-finite, dimensionally
272/// inconsistent input or for a point outside the reference box.
273pub fn hypervolume(
274    front: &[Vec<f64>],
275    reference: &ReferencePoint,
276) -> Result<HypervolumeReport, IndicatorError> {
277    hypervolume_with(front, reference, OutsidePolicy::Strict)
278}
279
280/// Compute hypervolume with an explicit outside-reference policy.
281///
282/// Exact integration is used through four objectives and deterministic Monte
283/// Carlo integration above four. [`OutsidePolicy::Exclude`] removes points
284/// with an empty dominated box and records the count in
285/// [`HypervolumeReport::outside_reference`]; it never clips coordinates.
286///
287/// # Errors
288///
289/// Returns an [`IndicatorError`] for empty, non-finite, or dimensionally
290/// inconsistent input. Strict policy also rejects any point outside the
291/// reference box.
292pub fn hypervolume_with(
293    front: &[Vec<f64>],
294    reference: &ReferencePoint,
295    policy: OutsidePolicy,
296) -> Result<HypervolumeReport, IndicatorError> {
297    if reference.dimension() > 4 {
298        return hypervolume_monte_carlo_impl(
299            front,
300            reference,
301            DEFAULT_MONTE_CARLO_SAMPLES,
302            DEFAULT_MONTE_CARLO_SEED,
303            policy,
304        );
305    }
306    let cleaned = clean_hypervolume_front(front, reference, policy)?;
307    let borrowed: Vec<&[f64]> = cleaned.points.iter().map(Vec::as_slice).collect();
308    let value = exact_union(&borrowed, reference.as_slice(), reference.dimension());
309    Ok(report(
310        front.len(),
311        &cleaned.points,
312        cleaned.duplicates,
313        cleaned.dominated,
314        cleaned.outside,
315        HypervolumeEstimate::Exact(value),
316    ))
317}
318
319/// Estimate hypervolume uniformly inside the front/reference bounding box.
320///
321/// # Errors
322///
323/// Returns an [`IndicatorError`] for invalid front/reference data or when
324/// `samples < 2`.
325pub fn hypervolume_monte_carlo(
326    front: &[Vec<f64>],
327    reference: &ReferencePoint,
328    samples: usize,
329    seed: u64,
330) -> Result<HypervolumeReport, IndicatorError> {
331    hypervolume_monte_carlo_impl(front, reference, samples, seed, OutsidePolicy::Strict)
332}
333
334fn hypervolume_monte_carlo_impl(
335    front: &[Vec<f64>],
336    reference: &ReferencePoint,
337    samples: usize,
338    seed: u64,
339    policy: OutsidePolicy,
340) -> Result<HypervolumeReport, IndicatorError> {
341    if samples < 2 {
342        return Err(IndicatorError::InsufficientSamples);
343    }
344    let cleaned = clean_hypervolume_front(front, reference, policy)?;
345    let dimensions = reference.dimension();
346    let lower: Vec<f64> = (0..dimensions)
347        .map(|axis| {
348            cleaned
349                .points
350                .iter()
351                .map(|point| point[axis])
352                .fold(reference.as_slice()[axis], f64::min)
353        })
354        .collect();
355    let bounding_volume: f64 = lower
356        .iter()
357        .zip(reference.as_slice())
358        .map(|(&lo, &hi)| hi - lo)
359        .product();
360    let mut rng = Rng::new(seed);
361    let mut dominated_samples = 0usize;
362    for _ in 0..samples {
363        let sample: Vec<f64> = lower
364            .iter()
365            .zip(reference.as_slice())
366            .map(|(&lo, &hi)| lo + (hi - lo) * rng.uniform01())
367            .collect();
368        if cleaned.points.iter().any(|point| {
369            point
370                .iter()
371                .zip(&sample)
372                .all(|(&value, &draw)| value <= draw)
373        }) {
374            dominated_samples += 1;
375        }
376    }
377    let probability = dominated_samples as f64 / samples as f64;
378    let value = bounding_volume * probability;
379    let standard_error =
380        bounding_volume * (probability * (1.0 - probability) / samples as f64).sqrt();
381    Ok(report(
382        front.len(),
383        &cleaned.points,
384        cleaned.duplicates,
385        cleaned.dominated,
386        cleaned.outside,
387        HypervolumeEstimate::MonteCarlo {
388            value,
389            standard_error,
390            samples,
391            seed,
392        },
393    ))
394}
395
396fn validate_set(points: &[Vec<f64>], dimension: Option<usize>) -> Result<usize, IndicatorError> {
397    let Some(first) = points.first() else {
398        return Err(IndicatorError::EmptySet);
399    };
400    if first.is_empty() {
401        return Err(IndicatorError::EmptyPoint);
402    }
403    let dimension = dimension.unwrap_or(first.len());
404    if points.iter().any(|point| point.len() != dimension) {
405        return Err(IndicatorError::DimensionMismatch);
406    }
407    if points.iter().flatten().any(|value| !value.is_finite()) {
408        return Err(IndicatorError::NonFiniteValue);
409    }
410    Ok(dimension)
411}
412
413/// Partition minimized objective vectors into successive non-dominated fronts.
414///
415/// Returned values are original input indices. Exact duplicate points do not
416/// dominate one another and therefore remain together in the same front.
417/// Front zero is the Pareto set; removing it and repeating produces each later
418/// front.
419///
420/// # Errors
421///
422/// Returns an [`IndicatorError`] for empty, non-finite, empty-point, or
423/// dimensionally inconsistent input.
424pub fn nondominated_sort(points: &[Vec<f64>]) -> Result<Vec<Vec<usize>>, IndicatorError> {
425    validate_set(points, None)?;
426    let count = points.len();
427    let mut dominates_indices = vec![Vec::new(); count];
428    let mut domination_count = vec![0usize; count];
429    for left in 0..count {
430        for right in left + 1..count {
431            if dominates(&points[left], &points[right]) {
432                dominates_indices[left].push(right);
433                domination_count[right] += 1;
434            } else if dominates(&points[right], &points[left]) {
435                dominates_indices[right].push(left);
436                domination_count[left] += 1;
437            }
438        }
439    }
440    let mut current: Vec<usize> = domination_count
441        .iter()
442        .enumerate()
443        .filter_map(|(index, &value)| (value == 0).then_some(index))
444        .collect();
445    let mut fronts = Vec::new();
446    while !current.is_empty() {
447        let mut next = Vec::new();
448        for &index in &current {
449            for &dominated in &dominates_indices[index] {
450                domination_count[dominated] -= 1;
451                if domination_count[dominated] == 0 {
452                    next.push(dominated);
453                }
454            }
455        }
456        fronts.push(current);
457        current = next;
458    }
459    Ok(fronts)
460}
461
462/// Compute normalized NSGA-II crowding distances for one supplied front.
463///
464/// Every point attaining a finite objective's minimum or maximum receives
465/// infinity. Exact duplicates are retained; duplicate interior points can
466/// consequently receive zero distance. An objective with zero range adds no
467/// distance. For one or two points, every distance is infinite.
468///
469/// # Errors
470///
471/// Returns an [`IndicatorError`] for empty, non-finite, empty-point, or
472/// dimensionally inconsistent input.
473pub fn crowding_distance(points: &[Vec<f64>]) -> Result<Vec<f64>, IndicatorError> {
474    validate_set(points, None)?;
475    let count = points.len();
476    if count <= 2 {
477        return Ok(vec![f64::INFINITY; count]);
478    }
479    let mut distance = vec![0.0; count];
480    for (objective, _) in points[0].iter().enumerate() {
481        let mut order: Vec<usize> = (0..count).collect();
482        order.sort_by(|&left, &right| points[left][objective].total_cmp(&points[right][objective]));
483        let minimum = points[order[0]][objective];
484        let maximum = points[order[count - 1]][objective];
485        let span = maximum - minimum;
486        if span <= 0.0 {
487            continue;
488        }
489        for &index in &order {
490            if points[index][objective] == minimum || points[index][objective] == maximum {
491                distance[index] = f64::INFINITY;
492            }
493        }
494        for position in 1..count - 1 {
495            let index = order[position];
496            if distance[index].is_finite() {
497                distance[index] += (points[order[position + 1]][objective]
498                    - points[order[position - 1]][objective])
499                    / span;
500            }
501        }
502    }
503    Ok(distance)
504}
505
506fn validate_pair(left: &[Vec<f64>], right: &[Vec<f64>]) -> Result<usize, IndicatorError> {
507    let dimension = validate_set(left, None)?;
508    validate_set(right, Some(dimension))?;
509    Ok(dimension)
510}
511
512fn euclidean(left: &[f64], right: &[f64]) -> f64 {
513    left.iter()
514        .zip(right)
515        .map(|(&a, &b)| (a - b).powi(2))
516        .sum::<f64>()
517        .sqrt()
518}
519
520fn plus_distance(approximation: &[f64], reference: &[f64]) -> f64 {
521    approximation
522        .iter()
523        .zip(reference)
524        .map(|(&a, &r)| (a - r).max(0.0).powi(2))
525        .sum::<f64>()
526        .sqrt()
527}
528
529fn mean_min_distance(
530    sources: &[Vec<f64>],
531    targets: &[Vec<f64>],
532    distance: impl Fn(&[f64], &[f64]) -> f64,
533) -> f64 {
534    sources
535        .iter()
536        .map(|source| {
537            targets
538                .iter()
539                .map(|target| distance(target, source))
540                .fold(f64::INFINITY, f64::min)
541        })
542        .sum::<f64>()
543        / sources.len() as f64
544}
545
546/// Inverted generational distance: mean nearest Euclidean distance from each
547/// reference point to the approximation front.
548///
549/// # Errors
550///
551/// Returns an [`IndicatorError`] for invalid or inconsistent sets.
552pub fn igd(front: &[Vec<f64>], reference_set: &[Vec<f64>]) -> Result<f64, IndicatorError> {
553    validate_pair(front, reference_set)?;
554    Ok(mean_min_distance(reference_set, front, euclidean))
555}
556
557/// IGD+ using only approximation coordinates that are worse than a reference
558/// point under minimization.
559///
560/// # Errors
561///
562/// Returns an [`IndicatorError`] for invalid or inconsistent sets.
563pub fn igd_plus(front: &[Vec<f64>], reference_set: &[Vec<f64>]) -> Result<f64, IndicatorError> {
564    validate_pair(front, reference_set)?;
565    Ok(mean_min_distance(reference_set, front, plus_distance))
566}
567
568/// Generational distance: mean nearest Euclidean distance from each
569/// approximation point to the reference set.
570///
571/// # Errors
572///
573/// Returns an [`IndicatorError`] for invalid or inconsistent sets.
574pub fn gd(front: &[Vec<f64>], reference_set: &[Vec<f64>]) -> Result<f64, IndicatorError> {
575    validate_pair(front, reference_set)?;
576    Ok(mean_min_distance(front, reference_set, euclidean))
577}
578
579/// GD+ using only approximation coordinates that are worse than a reference
580/// point under minimization.
581///
582/// # Errors
583///
584/// Returns an [`IndicatorError`] for invalid or inconsistent sets.
585pub fn gd_plus(front: &[Vec<f64>], reference_set: &[Vec<f64>]) -> Result<f64, IndicatorError> {
586    validate_pair(front, reference_set)?;
587    Ok(front
588        .iter()
589        .map(|approximation| {
590            reference_set
591                .iter()
592                .map(|reference| plus_distance(approximation, reference))
593                .fold(f64::INFINITY, f64::min)
594        })
595        .sum::<f64>()
596        / front.len() as f64)
597}
598
599/// Unary additive epsilon indicator `Iε+(front, reference_set)` for minimized
600/// objectives. Smaller values are better and negative values indicate strict
601/// improvement over the complete reference set.
602///
603/// # Errors
604///
605/// Returns an [`IndicatorError`] for invalid or inconsistent sets.
606pub fn additive_epsilon(
607    front: &[Vec<f64>],
608    reference_set: &[Vec<f64>],
609) -> Result<f64, IndicatorError> {
610    validate_pair(front, reference_set)?;
611    Ok(reference_set
612        .iter()
613        .map(|reference| {
614            front
615                .iter()
616                .map(|approximation| {
617                    approximation
618                        .iter()
619                        .zip(reference)
620                        .map(|(&a, &r)| a - r)
621                        .fold(f64::NEG_INFINITY, f64::max)
622                })
623                .fold(f64::INFINITY, f64::min)
624        })
625        .fold(f64::NEG_INFINITY, f64::max))
626}
627
628/// Standard spacing of nearest-neighbor Manhattan distances within a front.
629/// A single-point front has zero spacing.
630///
631/// # Errors
632///
633/// Returns an [`IndicatorError`] for invalid points.
634pub fn spacing(front: &[Vec<f64>]) -> Result<f64, IndicatorError> {
635    validate_set(front, None)?;
636    if front.len() == 1 {
637        return Ok(0.0);
638    }
639    let nearest: Vec<f64> = front
640        .iter()
641        .enumerate()
642        .map(|(index, point)| {
643            front
644                .iter()
645                .enumerate()
646                .filter(|(other, _)| *other != index)
647                .map(|(_, candidate)| {
648                    point
649                        .iter()
650                        .zip(candidate)
651                        .map(|(&a, &b)| (a - b).abs())
652                        .sum::<f64>()
653                })
654                .fold(f64::INFINITY, f64::min)
655        })
656        .collect();
657    let mean = nearest.iter().sum::<f64>() / nearest.len() as f64;
658    Ok((nearest
659        .iter()
660        .map(|distance| (distance - mean).powi(2))
661        .sum::<f64>()
662        / (nearest.len() - 1) as f64)
663        .sqrt())
664}
665
666/// Generalized Deb spread using nearest-neighbor Euclidean distances and
667/// explicit extreme points. Zero is perfectly even; larger is less uniform.
668///
669/// # Errors
670///
671/// Returns an [`IndicatorError`] for invalid/inconsistent sets or fewer than
672/// two front points.
673pub fn spread(front: &[Vec<f64>], extremes: &[Vec<f64>]) -> Result<f64, IndicatorError> {
674    validate_pair(front, extremes)?;
675    if front.len() < 2 {
676        return Err(IndicatorError::EmptySet);
677    }
678    let nearest: Vec<f64> = front
679        .iter()
680        .enumerate()
681        .map(|(index, point)| {
682            front
683                .iter()
684                .enumerate()
685                .filter(|(other, _)| *other != index)
686                .map(|(_, candidate)| euclidean(point, candidate))
687                .fold(f64::INFINITY, f64::min)
688        })
689        .collect();
690    let mean = nearest.iter().sum::<f64>() / nearest.len() as f64;
691    let edge = extremes
692        .iter()
693        .map(|extreme| {
694            front
695                .iter()
696                .map(|point| euclidean(extreme, point))
697                .fold(f64::INFINITY, f64::min)
698        })
699        .sum::<f64>();
700    let deviation = nearest
701        .iter()
702        .map(|distance| (distance - mean).abs())
703        .sum::<f64>();
704    let denominator = edge + nearest.len() as f64 * mean;
705    Ok(if denominator == 0.0 {
706        0.0
707    } else {
708        (edge + deviation) / denominator
709    })
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn reference(values: &[f64]) -> ReferencePoint {
717        ReferencePoint::new(values.to_vec()).unwrap()
718    }
719
720    #[test]
721    fn exact_hypervolume_has_auditable_cleanup() {
722        let front = vec![
723            vec![1.0, 4.0],
724            vec![2.0, 2.0],
725            vec![4.0, 1.0],
726            vec![2.0, 2.0],
727            vec![3.0, 3.0],
728        ];
729        let result = hypervolume(&front, &reference(&[5.0, 5.0])).unwrap();
730        assert_eq!(result.estimate, HypervolumeEstimate::Exact(11.0));
731        assert_eq!(result.input_points, 5);
732        assert_eq!(result.retained_points, 3);
733        assert_eq!(result.duplicates_collapsed, 1);
734        assert_eq!(result.dominated_removed, 1);
735        assert_eq!(result.outside_reference, 0);
736    }
737
738    #[test]
739    fn outside_reference_policy_is_explicit_and_audited() {
740        let front = vec![
741            vec![1.0, 4.0],
742            vec![2.0, 2.0],
743            vec![6.0, 1.0],
744            vec![7.0, 0.0],
745        ];
746        let reference = reference(&[5.0, 5.0]);
747        assert_eq!(
748            hypervolume(&front, &reference),
749            Err(IndicatorError::ReferencePointViolation)
750        );
751        let report = hypervolume_with(&front, &reference, OutsidePolicy::Exclude).unwrap();
752        assert_eq!(report.estimate, HypervolumeEstimate::Exact(10.0));
753        assert_eq!(report.input_points, 4);
754        assert_eq!(report.retained_points, 2);
755        assert_eq!(report.outside_reference, 2);
756        assert_eq!(report.duplicates_collapsed, 0);
757        assert_eq!(report.dominated_removed, 0);
758
759        let outside_only =
760            hypervolume_with(&[vec![6.0, 1.0]], &reference, OutsidePolicy::Exclude).unwrap();
761        assert_eq!(outside_only.estimate, HypervolumeEstimate::Exact(0.0));
762        assert_eq!(outside_only.retained_points, 0);
763        assert_eq!(outside_only.outside_reference, 1);
764    }
765
766    #[test]
767    fn exact_recursive_volume_handles_four_dimensions() {
768        let front = vec![vec![0.0; 4], vec![0.5; 4]];
769        let result = hypervolume(&front, &reference(&[1.0; 4])).unwrap();
770        assert_eq!(result.estimate, HypervolumeEstimate::Exact(1.0));
771        assert_eq!(result.dominated_removed, 1);
772    }
773
774    #[test]
775    fn exact_two_dimensional_volume_matches_an_independent_cell_union() {
776        let hv_reference = reference(&[10.0, 10.0]);
777        let mut rng = Rng::new(19);
778        for case in 0..1_000 {
779            let point_count = 1 + case % 7;
780            let front: Vec<Vec<f64>> = (0..point_count)
781                .map(|_| {
782                    vec![
783                        (9.0 * rng.uniform01()).floor(),
784                        (9.0 * rng.uniform01()).floor(),
785                    ]
786                })
787                .collect();
788            let brute_force_cells = (0..10)
789                .flat_map(|x| (0..10).map(move |y| (x, y)))
790                .filter(|&(x, y)| {
791                    front
792                        .iter()
793                        .any(|point| point[0] <= x as f64 && point[1] <= y as f64)
794                })
795                .count() as f64;
796            assert_eq!(
797                hypervolume(&front, &hv_reference).unwrap().estimate.value(),
798                brute_force_cells
799            );
800        }
801    }
802
803    #[test]
804    fn sampled_volume_agrees_with_exact_within_reported_uncertainty() {
805        let front = vec![
806            vec![0.1, 0.8, 0.8, 0.8],
807            vec![0.8, 0.1, 0.8, 0.8],
808            vec![0.8, 0.8, 0.1, 0.8],
809            vec![0.8, 0.8, 0.8, 0.1],
810            vec![0.5, 0.5, 0.5, 0.5],
811        ];
812        let exact = hypervolume(&front, &reference(&[1.0; 4]))
813            .unwrap()
814            .estimate
815            .value();
816        let sampled = hypervolume_monte_carlo(&front, &reference(&[1.0; 4]), 500_000, 7).unwrap();
817        let HypervolumeEstimate::MonteCarlo {
818            value,
819            standard_error,
820            samples,
821            seed,
822        } = sampled.estimate
823        else {
824            panic!("sampled API returned exact hypervolume")
825        };
826        assert_eq!(samples, 500_000);
827        assert_eq!(seed, 7);
828        assert!((value - exact).abs() <= 3.0 * standard_error);
829    }
830
831    #[test]
832    fn high_dimensional_default_is_typed_as_monte_carlo() {
833        let result = hypervolume(&[vec![0.0; 5]], &reference(&[1.0; 5])).unwrap();
834        assert!(matches!(
835            result.estimate,
836            HypervolumeEstimate::MonteCarlo {
837                samples: 100_000,
838                ..
839            }
840        ));
841    }
842
843    #[test]
844    fn distances_and_epsilon_match_hand_computed_fixture() {
845        let front = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
846        let ideal = vec![vec![1.0, 1.0]];
847        assert_eq!(igd(&front, &ideal).unwrap(), 1.0);
848        assert_eq!(igd_plus(&front, &ideal).unwrap(), 1.0);
849        assert_eq!(gd(&front, &ideal).unwrap(), 1.0);
850        assert_eq!(gd_plus(&front, &ideal).unwrap(), 1.0);
851        assert_eq!(additive_epsilon(&front, &ideal).unwrap(), 1.0);
852        assert_eq!(spacing(&front).unwrap(), 0.0);
853    }
854
855    #[test]
856    fn nondominated_sort_preserves_indices_and_duplicates() {
857        let points = vec![
858            vec![0.0, 2.0],
859            vec![1.0, 1.0],
860            vec![2.0, 0.0],
861            vec![2.0, 2.0],
862            vec![1.0, 1.0],
863            vec![3.0, 3.0],
864        ];
865        assert_eq!(
866            nondominated_sort(&points).unwrap(),
867            vec![vec![0, 1, 2, 4], vec![3], vec![5]]
868        );
869    }
870
871    #[test]
872    fn crowding_distance_matches_nsga_fixture() {
873        let distance =
874            crowding_distance(&[vec![0.0, 2.0], vec![1.0, 1.0], vec![2.0, 0.0]]).unwrap();
875        assert!(distance[0].is_infinite());
876        assert_eq!(distance[1], 2.0);
877        assert!(distance[2].is_infinite());
878        assert_eq!(
879            crowding_distance(&[vec![1.0, 1.0], vec![1.0, 1.0], vec![1.0, 1.0]]).unwrap(),
880            vec![0.0; 3]
881        );
882    }
883
884    #[test]
885    fn translation_and_positive_scaling_have_correct_factors() {
886        fn transform(points: &[Vec<f64>], scale: f64, offset: f64) -> Vec<Vec<f64>> {
887            points
888                .iter()
889                .map(|point| point.iter().map(|value| scale * value + offset).collect())
890                .collect()
891        }
892
893        fn close(actual: f64, expected: f64) {
894            assert!((actual - expected).abs() <= 1.0e-12 * expected.abs().max(1.0));
895        }
896
897        let front = vec![vec![1.0, 4.0], vec![2.0, 2.0], vec![4.5, 1.0]];
898        let reference_set = vec![vec![0.5, 4.5], vec![2.5, 2.5], vec![4.5, 0.5]];
899        let extremes = vec![reference_set[0].clone(), reference_set[2].clone()];
900        let translated = transform(&front, 1.0, 10.0);
901        let translated_reference = transform(&reference_set, 1.0, 10.0);
902        let translated_extremes = transform(&extremes, 1.0, 10.0);
903        let scaled = transform(&front, 3.0, 0.0);
904        let scaled_reference = transform(&reference_set, 3.0, 0.0);
905        let scaled_extremes = transform(&extremes, 3.0, 0.0);
906        let base = hypervolume(&front, &reference(&[5.0, 5.0]))
907            .unwrap()
908            .estimate
909            .value();
910        let shifted = hypervolume(&translated, &reference(&[15.0, 15.0]))
911            .unwrap()
912            .estimate
913            .value();
914        let expanded = hypervolume(&scaled, &reference(&[15.0, 15.0]))
915            .unwrap()
916            .estimate
917            .value();
918        close(shifted, base);
919        close(expanded, 9.0 * base);
920
921        let distances = [
922            igd(&front, &reference_set).unwrap(),
923            igd_plus(&front, &reference_set).unwrap(),
924            gd(&front, &reference_set).unwrap(),
925            gd_plus(&front, &reference_set).unwrap(),
926            additive_epsilon(&front, &reference_set).unwrap(),
927            spacing(&front).unwrap(),
928        ];
929        let translated_distances = [
930            igd(&translated, &translated_reference).unwrap(),
931            igd_plus(&translated, &translated_reference).unwrap(),
932            gd(&translated, &translated_reference).unwrap(),
933            gd_plus(&translated, &translated_reference).unwrap(),
934            additive_epsilon(&translated, &translated_reference).unwrap(),
935            spacing(&translated).unwrap(),
936        ];
937        let scaled_distances = [
938            igd(&scaled, &scaled_reference).unwrap(),
939            igd_plus(&scaled, &scaled_reference).unwrap(),
940            gd(&scaled, &scaled_reference).unwrap(),
941            gd_plus(&scaled, &scaled_reference).unwrap(),
942            additive_epsilon(&scaled, &scaled_reference).unwrap(),
943            spacing(&scaled).unwrap(),
944        ];
945        for ((original, shifted), expanded) in distances
946            .into_iter()
947            .zip(translated_distances)
948            .zip(scaled_distances)
949        {
950            close(shifted, original);
951            close(expanded, 3.0 * original);
952        }
953        let base_spread = spread(&front, &extremes).unwrap();
954        close(
955            spread(&translated, &translated_extremes).unwrap(),
956            base_spread,
957        );
958        close(spread(&scaled, &scaled_extremes).unwrap(), base_spread);
959    }
960
961    #[test]
962    fn strict_dominance_improves_compliant_indicators_for_ten_thousand_pairs() {
963        let ideal = vec![vec![0.0, 0.0]];
964        let hv_reference = reference(&[2.0, 2.0]);
965        let mut rng = Rng::new(42);
966        for _ in 0..10_000 {
967            let worse = vec![vec![0.2 + rng.uniform01(), 0.2 + rng.uniform01()]];
968            let better = vec![vec![worse[0][0] - 0.1, worse[0][1] - 0.1]];
969            assert!(
970                hypervolume(&better, &hv_reference)
971                    .unwrap()
972                    .estimate
973                    .value()
974                    > hypervolume(&worse, &hv_reference).unwrap().estimate.value()
975            );
976            assert!(igd_plus(&better, &ideal).unwrap() < igd_plus(&worse, &ideal).unwrap());
977            assert!(
978                additive_epsilon(&better, &ideal).unwrap()
979                    < additive_epsilon(&worse, &ideal).unwrap()
980            );
981        }
982    }
983
984    #[test]
985    fn invalid_input_fails_closed() {
986        assert_eq!(ReferencePoint::new(vec![]), Err(IndicatorError::EmptyPoint));
987        assert_eq!(
988            ReferencePoint::new(vec![f64::NAN]),
989            Err(IndicatorError::NonFiniteValue)
990        );
991        assert_eq!(
992            hypervolume(&[], &reference(&[1.0])),
993            Err(IndicatorError::EmptySet)
994        );
995        assert_eq!(
996            hypervolume(&[vec![2.0]], &reference(&[1.0])),
997            Err(IndicatorError::ReferencePointViolation)
998        );
999        assert_eq!(
1000            igd(&[vec![0.0]], &[vec![0.0, 1.0]]),
1001            Err(IndicatorError::DimensionMismatch)
1002        );
1003        assert_eq!(nondominated_sort(&[]), Err(IndicatorError::EmptySet));
1004        assert_eq!(
1005            crowding_distance(&[vec![0.0], vec![f64::INFINITY]]),
1006            Err(IndicatorError::NonFiniteValue)
1007        );
1008    }
1009}