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