1use 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#[derive(Debug)]
19pub struct HypervolumeCalculator<T: Float + Debug + Send + Sync + 'static> {
20 pub(super) method: HypervolumeMethod,
22 pub(super) reference_point: Vec<T>,
24 pub(super) cache: HashMap<String, T>,
26 pub(super) monte_carlo_samples: usize,
28 pub(super) monte_carlo_seed: u64,
31}
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HypervolumeMethod {
35 WFG,
42 Quick,
48 HSO,
50 MonteCarlo,
59}
60pub(super) const HV_HIGH_D_POINT_CAP: usize = 64;
66
67fn 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 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
106fn 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 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 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
159pub 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 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 let mut front = retain_non_dominated_min(&feasible);
181 if front.is_empty() {
182 return T::zero();
183 }
184
185 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
208fn 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
229pub 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
241pub fn normalize_reference_for_minimization<T: Float>(
248 reference: &[T],
249 directions: &[OptimizationDirection],
250) -> Vec<T> {
251 negate_maximized(reference, directions)
252}
253
254const HV_REFERENCE_MARGIN_FRACTION: f64 = 0.1;
257
258const HV_REFERENCE_MARGIN_FLOOR: f64 = 1e-3;
262
263pub 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 pub const DEFAULT_MONTE_CARLO_SAMPLES: usize = 100_000;
314
315 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 pub fn method(&self) -> HypervolumeMethod {
329 self.method
330 }
331
332 pub fn reference_point(&self) -> &[T] {
334 &self.reference_point
335 }
336
337 pub fn set_reference_point(&mut self, reference_point: Vec<T>) {
340 self.reference_point = reference_point;
341 self.cache.clear();
342 }
343
344 pub fn set_monte_carlo_samples(&mut self, samples: usize) {
346 self.monte_carlo_samples = samples;
347 self.cache.clear();
348 }
349
350 pub fn clear_cache(&mut self) {
352 self.cache.clear();
353 }
354
355 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 let hv = hypervolume_minimization(&min_front, &min_reference);
605 assert!((hv - 1.2).abs() < 1e-12, "expected 1.2, got {hv}");
606
607 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 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 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 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}