use crate::error::{OptimError, Result};
use crate::nas_engine::OptimizationDirection;
use scirs2_core::numeric::Float;
use scirs2_core::random::Random;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt::Debug;
#[derive(Debug)]
pub struct HypervolumeCalculator<T: Float + Debug + Send + Sync + 'static> {
pub(super) method: HypervolumeMethod,
pub(super) reference_point: Vec<T>,
pub(super) cache: HashMap<String, T>,
pub(super) monte_carlo_samples: usize,
pub(super) monte_carlo_seed: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HypervolumeMethod {
WFG,
Quick,
HSO,
MonteCarlo,
}
pub(super) const HV_HIGH_D_POINT_CAP: usize = 64;
fn retain_non_dominated_min<T: Float>(points: &[Vec<T>]) -> Vec<Vec<T>> {
let mut kept: Vec<Vec<T>> = Vec::new();
for (i, p) in points.iter().enumerate() {
let mut dominated = false;
for (j, q) in points.iter().enumerate() {
if i == j {
continue;
}
let mut all_le = true;
let mut any_lt = false;
let len = p.len().min(q.len());
for k in 0..len {
if q[k] > p[k] {
all_le = false;
break;
}
if q[k] < p[k] {
any_lt = true;
}
}
if all_le && any_lt {
dominated = true;
break;
}
}
if !dominated {
kept.push(p.clone());
}
}
kept
}
fn hv_recursive<T: Float>(points: &[Vec<T>], reference: &[T], dims: usize) -> T {
if points.is_empty() || dims == 0 {
return T::zero();
}
if dims == 1 {
let mut best = reference[0];
for p in points {
if p[0] < best {
best = p[0];
}
}
let extent = reference[0] - best;
return if extent > T::zero() {
extent
} else {
T::zero()
};
}
let last = dims - 1;
let mut sorted: Vec<&Vec<T>> = points.iter().collect();
sorted.sort_by(|a, b| a[last].partial_cmp(&b[last]).unwrap_or(Ordering::Equal));
let mut volume = T::zero();
for i in 0..sorted.len() {
let lower = sorted[i][last];
let upper = if i + 1 < sorted.len() {
sorted[i + 1][last]
} else {
reference[last]
};
let depth = upper - lower;
if depth <= T::zero() {
continue;
}
let projections: Vec<Vec<T>> = sorted[..=i].iter().map(|p| p[..last].to_vec()).collect();
let area = hv_recursive(&projections, &reference[..last], last);
volume = volume + area * depth;
}
volume
}
pub fn hypervolume_minimization<T: Float>(points: &[Vec<T>], reference: &[T]) -> T {
let dims = reference.len();
if points.is_empty() || dims == 0 {
return T::zero();
}
let feasible: Vec<Vec<T>> = points
.iter()
.filter(|p| p.len() >= dims && (0..dims).all(|k| p[k] < reference[k]))
.map(|p| p[..dims].to_vec())
.collect();
let mut front = retain_non_dominated_min(&feasible);
if front.is_empty() {
return T::zero();
}
if dims >= 4 && front.len() > HV_HIGH_D_POINT_CAP {
let mut ideal = vec![T::infinity(); dims];
for p in &front {
for k in 0..dims {
if p[k] < ideal[k] {
ideal[k] = p[k];
}
}
}
front.sort_by(|a, b| {
let da = (0..dims).fold(T::zero(), |acc, k| acc + (a[k] - ideal[k]).abs());
let db = (0..dims).fold(T::zero(), |acc, k| acc + (b[k] - ideal[k]).abs());
da.partial_cmp(&db).unwrap_or(Ordering::Equal)
});
front.truncate(HV_HIGH_D_POINT_CAP);
}
hv_recursive(&front, reference, dims)
}
fn negate_maximized<T: Float>(values: &[T], directions: &[OptimizationDirection]) -> Vec<T> {
values
.iter()
.enumerate()
.map(|(k, &v)| match directions.get(k) {
Some(OptimizationDirection::Maximize) => -v,
_ => v,
})
.collect()
}
pub fn normalize_front_for_minimization<T: Float>(
front: &[Vec<T>],
directions: &[OptimizationDirection],
) -> Vec<Vec<T>> {
front
.iter()
.map(|point| negate_maximized(point, directions))
.collect()
}
pub fn normalize_reference_for_minimization<T: Float>(
reference: &[T],
directions: &[OptimizationDirection],
) -> Vec<T> {
negate_maximized(reference, directions)
}
const HV_REFERENCE_MARGIN_FRACTION: f64 = 0.1;
const HV_REFERENCE_MARGIN_FLOOR: f64 = 1e-3;
pub fn derive_reference_point<T: Float>(front: &[Vec<T>]) -> Option<Vec<T>> {
let dims = front.first()?.len();
if dims == 0 {
return None;
}
let mut min_values = vec![T::infinity(); dims];
let mut max_values = vec![T::neg_infinity(); dims];
for point in front {
if point.len() < dims {
return None;
}
for k in 0..dims {
if point[k] < min_values[k] {
min_values[k] = point[k];
}
if point[k] > max_values[k] {
max_values[k] = point[k];
}
}
}
let fraction: T =
scirs2_core::numeric::NumCast::from(HV_REFERENCE_MARGIN_FRACTION).unwrap_or_else(T::zero);
let floor: T =
scirs2_core::numeric::NumCast::from(HV_REFERENCE_MARGIN_FLOOR).unwrap_or_else(T::zero);
let mut reference = Vec::with_capacity(dims);
for k in 0..dims {
if !min_values[k].is_finite() || !max_values[k].is_finite() {
return None;
}
let range = max_values[k] - min_values[k];
let margin = (range * fraction)
.max(max_values[k].abs() * fraction)
.max(floor);
reference.push(max_values[k] + margin);
}
Some(reference)
}
impl<T: Float + Debug + Send + Sync + 'static> HypervolumeCalculator<T> {
pub const DEFAULT_MONTE_CARLO_SAMPLES: usize = 100_000;
pub fn new(method: HypervolumeMethod, reference_point: Vec<T>) -> Self {
Self {
method,
reference_point,
cache: HashMap::new(),
monte_carlo_samples: Self::DEFAULT_MONTE_CARLO_SAMPLES,
monte_carlo_seed: 0x5EED_1234_ABCD_0001,
}
}
pub fn method(&self) -> HypervolumeMethod {
self.method
}
pub fn reference_point(&self) -> &[T] {
&self.reference_point
}
pub fn set_reference_point(&mut self, reference_point: Vec<T>) {
self.reference_point = reference_point;
self.cache.clear();
}
pub fn set_monte_carlo_samples(&mut self, samples: usize) {
self.monte_carlo_samples = samples;
self.cache.clear();
}
pub fn clear_cache(&mut self) {
self.cache.clear();
}
pub fn calculate(&mut self, points: &[Vec<T>]) -> Result<T> {
if self.reference_point.is_empty() {
return Err(OptimError::InvalidConfig(
"hypervolume requires a non-empty reference point".to_string(),
));
}
let dims = self.reference_point.len();
if let Some(bad) = points.iter().find(|p| p.len() < dims) {
return Err(OptimError::InvalidParameter(format!(
"hypervolume point has {} objectives but the reference point has {}",
bad.len(),
dims
)));
}
if points.is_empty() {
return Ok(T::zero());
}
let key = self.cache_key(points);
if let Some(cached) = self.cache.get(&key) {
return Ok(*cached);
}
let value = match self.method {
HypervolumeMethod::HSO | HypervolumeMethod::WFG | HypervolumeMethod::Quick => {
hypervolume_minimization(points, &self.reference_point)
}
HypervolumeMethod::MonteCarlo => self.monte_carlo_estimate(points)?,
};
self.cache.insert(key, value);
Ok(value)
}
pub fn contribution(&mut self, points: &[Vec<T>], index: usize) -> Result<T> {
if index >= points.len() {
return Err(OptimError::InvalidParameter(format!(
"hypervolume contribution index {} is out of range for {} points",
index,
points.len()
)));
}
let total = self.calculate(points)?;
let without: Vec<Vec<T>> = points
.iter()
.enumerate()
.filter(|(i, _)| *i != index)
.map(|(_, p)| p.clone())
.collect();
let reduced = self.calculate(&without)?;
Ok(total - reduced)
}
fn monte_carlo_estimate(&self, points: &[Vec<T>]) -> Result<T> {
let dims = self.reference_point.len();
if self.monte_carlo_samples == 0 {
return Err(OptimError::InvalidConfig(
"Monte Carlo hypervolume requires at least one sample".to_string(),
));
}
let feasible: Vec<&Vec<T>> = points
.iter()
.filter(|p| (0..dims).all(|k| p[k] < self.reference_point[k]))
.collect();
if feasible.is_empty() {
return Ok(T::zero());
}
let mut lower = vec![T::infinity(); dims];
for point in &feasible {
for (slot, value) in lower.iter_mut().zip(point.iter()) {
if *value < *slot {
*slot = *value;
}
}
}
let mut box_volume = 1.0f64;
let mut spans = Vec::with_capacity(dims);
for (bound, floor) in self.reference_point.iter().zip(lower.iter()) {
let span = (*bound - *floor).to_f64().unwrap_or(0.0);
if span <= 0.0 {
return Ok(T::zero());
}
spans.push(span);
box_volume *= span;
}
let lower_f64: Vec<f64> = lower.iter().map(|v| v.to_f64().unwrap_or(0.0)).collect();
let feasible_f64: Vec<Vec<f64>> = feasible
.iter()
.map(|p| (0..dims).map(|k| p[k].to_f64().unwrap_or(0.0)).collect())
.collect();
let mut rng = Random::seed(self.monte_carlo_seed);
let mut hits = 0usize;
let mut sample = vec![0.0f64; dims];
for _ in 0..self.monte_carlo_samples {
for k in 0..dims {
sample[k] = lower_f64[k] + rng.gen_range(0.0..1.0) * spans[k];
}
if feasible_f64
.iter()
.any(|p| (0..dims).all(|k| p[k] <= sample[k]))
{
hits += 1;
}
}
let fraction = hits as f64 / self.monte_carlo_samples as f64;
Ok(scirs2_core::numeric::NumCast::from(box_volume * fraction).unwrap_or_else(T::zero))
}
fn cache_key(&self, points: &[Vec<T>]) -> String {
let mut rows: Vec<Vec<u64>> = points
.iter()
.map(|p| {
p.iter()
.map(|v| v.to_f64().unwrap_or(f64::NAN).to_bits())
.collect()
})
.collect();
rows.sort_unstable();
let reference: Vec<u64> = self
.reference_point
.iter()
.map(|v| v.to_f64().unwrap_or(f64::NAN).to_bits())
.collect();
format!(
"{:?}|{:?}|{}|{:?}",
self.method, reference, self.monte_carlo_samples, rows
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_objective_hypervolume_matches_the_published_value() {
let front = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let reference = vec![2.0, 2.0];
let hv = hypervolume_minimization(&front, &reference);
assert!(
(hv - 3.0).abs() < 1e-12,
"expected exactly 3.0, got {hv} (the pre-fix bounding-box heuristic returned 4.0 * 2 = 8.0)"
);
}
#[test]
fn three_objective_hypervolume_matches_the_published_value() {
let front = vec![
vec![1.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.0, 1.0],
];
let reference = vec![2.0, 2.0, 2.0];
let hv = hypervolume_minimization(&front, &reference);
assert!(
(hv - 7.0).abs() < 1e-12,
"expected exactly 7.0 (12 - 6 + 1), got {hv}"
);
}
#[test]
fn three_point_convex_front_has_the_hand_computed_hypervolume() {
let front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
let reference = vec![1.0, 1.0];
let hv = hypervolume_minimization(&front, &reference);
assert!((hv - 0.33).abs() < 1e-12, "expected 0.33, got {hv}");
}
#[test]
fn single_point_hypervolume_is_its_own_box() {
let hv = hypervolume_minimization(&[vec![0.5, 0.25]], &[1.0, 1.0]);
assert!((hv - 0.375).abs() < 1e-12, "expected 0.5 * 0.75, got {hv}");
}
#[test]
fn hypervolume_is_monotone_and_ignores_dominated_points() {
let reference = vec![2.0, 2.0];
let base = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let with_dominated = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.5, 1.5]];
let with_new = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.5, 0.5]];
let hv_base = hypervolume_minimization(&base, &reference);
let hv_dominated = hypervolume_minimization(&with_dominated, &reference);
let hv_new = hypervolume_minimization(&with_new, &reference);
assert!(
(hv_base - hv_dominated).abs() < 1e-12,
"a dominated point must not change the hypervolume: {hv_base} vs {hv_dominated}"
);
assert!(
hv_new > hv_base + 1e-12,
"a non-dominated point must strictly increase it: {hv_new} vs {hv_base}"
);
}
#[test]
fn points_not_dominating_the_reference_contribute_nothing() {
let hv = hypervolume_minimization(&[vec![3.0, 3.0], vec![5.0, 0.5]], &[2.0, 2.0]);
assert_eq!(hv, 0.0);
}
#[test]
fn maximized_objectives_are_negated_on_both_front_and_reference() {
let directions = vec![
OptimizationDirection::Maximize,
OptimizationDirection::Minimize,
];
let front = vec![vec![0.9, 1.0], vec![0.6, 0.5]];
let reference = vec![0.0, 2.0];
let min_front = normalize_front_for_minimization(&front, &directions);
let min_reference = normalize_reference_for_minimization(&reference, &directions);
assert_eq!(min_front, vec![vec![-0.9, 1.0], vec![-0.6, 0.5]]);
assert_eq!(min_reference, vec![0.0, 2.0]);
let hv = hypervolume_minimization(&min_front, &min_reference);
assert!((hv - 1.2).abs() < 1e-12, "expected 1.2, got {hv}");
assert_eq!(hypervolume_minimization(&front, &reference), 0.0);
}
#[test]
fn every_exact_method_returns_the_same_value() {
let front = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
for method in [
HypervolumeMethod::HSO,
HypervolumeMethod::WFG,
HypervolumeMethod::Quick,
] {
let mut calc = HypervolumeCalculator::new(method, vec![2.0, 2.0]);
let hv = calc.calculate(&front).expect("exact hypervolume");
assert!((hv - 3.0).abs() < 1e-12, "{method:?} returned {hv}");
}
}
#[test]
fn monte_carlo_estimate_is_close_to_the_exact_value_and_reproducible() {
let front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
let mut calc = HypervolumeCalculator::new(HypervolumeMethod::MonteCarlo, vec![1.0, 1.0]);
calc.set_monte_carlo_samples(200_000);
let first = calc.calculate(&front).expect("monte carlo hypervolume");
calc.clear_cache();
let second = calc.calculate(&front).expect("monte carlo hypervolume");
assert_eq!(first, second, "a seeded estimator must be reproducible");
assert!(
(first - 0.33).abs() < 5e-3,
"estimate {first} is too far from the exact 0.33"
);
assert_ne!(first, 0.33);
}
#[test]
fn calculate_rejects_a_missing_reference_and_short_points() {
let mut no_reference: HypervolumeCalculator<f64> =
HypervolumeCalculator::new(HypervolumeMethod::HSO, Vec::new());
assert!(no_reference.calculate(&[vec![0.0, 0.0]]).is_err());
let mut calc = HypervolumeCalculator::new(HypervolumeMethod::HSO, vec![1.0, 1.0]);
assert!(calc.calculate(&[vec![0.5]]).is_err());
assert_eq!(calc.calculate(&[]).expect("empty front"), 0.0);
}
#[test]
fn exclusive_contribution_is_the_volume_lost_by_removing_a_point() {
let front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
let mut calc = HypervolumeCalculator::new(HypervolumeMethod::HSO, vec![1.0, 1.0]);
let contribution = calc.contribution(&front, 1).expect("contribution");
assert!(
(contribution - 0.16).abs() < 1e-12,
"expected 0.16, got {contribution}"
);
assert!(calc.contribution(&front, 9).is_err());
}
#[test]
fn derived_reference_point_is_strictly_worse_than_the_whole_front() {
let front = vec![vec![0.1, 0.9], vec![0.9, 0.1]];
let reference = derive_reference_point(&front).expect("reference from a non-empty front");
assert_eq!(reference.len(), 2);
for point in &front {
for k in 0..2 {
assert!(
point[k] < reference[k],
"reference {:?} must dominate-bound {:?}",
reference,
point
);
}
}
assert!(
hypervolume_minimization(&front, &reference) > 0.0,
"a derived reference must yield a positive hypervolume"
);
let single = derive_reference_point(&[vec![0.0, 0.0]]).expect("single point reference");
assert!(single.iter().all(|v| *v > 0.0), "got {single:?}");
assert!(derive_reference_point::<f64>(&[]).is_none());
}
}