Skip to main content

optirs_nas/multi_objective/
decomposition.rs

1//! Decomposition primitives shared by the reference-direction based optimizers
2//! in this module: MOEA/D ([`super::moead`]) and NSGA-III ([`super::nsga3`]).
3//!
4//! Everything here works in the **pure-minimization convention**. Callers holding
5//! raw objective vectors (which may mix `Minimize` and `Maximize` directions) map
6//! them first with [`super::hypervolume::normalize_front_for_minimization`].
7//!
8//! References:
9//! * Das & Dennis, "Normal-Boundary Intersection" (SIAM J. Optim. 1998) — the
10//!   structured simplex-lattice weight vectors used by both algorithms.
11//! * Zhang & Li, "MOEA/D: A Multiobjective Evolutionary Algorithm Based on
12//!   Decomposition" (IEEE TEVC 2007) — weighted-sum / Tchebycheff / PBI
13//!   scalarizations and the neighbourhood structure.
14//! * Deb & Jain, "An Evolutionary Many-Objective Optimization Algorithm Using
15//!   Reference-Point-Based Nondominated Sorting Approach, Part I" (IEEE TEVC 2014)
16//!   — the adaptive ideal-point / hyperplane-intercept normalization and the
17//!   niche-preserving selection NSGA-III uses.
18
19use scirs2_core::numeric::Float;
20
21/// Smallest weight component a scalarization will divide by. Structured
22/// Das-Dennis lattices contain vectors with exact zeros (the simplex corners);
23/// Tchebycheff and the achievement scalarizing function divide by (or scale with)
24/// each component, so an exact zero would either drop an objective entirely or
25/// produce an infinity. Flooring at this value is the standard remedy and keeps
26/// the corner subproblems meaningful.
27pub const MIN_WEIGHT_COMPONENT: f64 = 1e-6;
28
29/// Penalty factor `theta` of the penalty-based boundary intersection
30/// scalarization. `5.0` is the value used throughout the MOEA/D literature.
31pub const PBI_PENALTY: f64 = 5.0;
32
33/// Upper bound on the number of simplex-lattice divisions
34/// [`divisions_for_at_least`] will consider before giving up. Guards the search
35/// against pathological targets; the resulting lattice already has well over a
36/// million vectors for three objectives.
37const MAX_LATTICE_DIVISIONS: usize = 2000;
38
39/// Number of Das-Dennis weight vectors for `num_objectives` objectives and
40/// `divisions` divisions per axis, i.e. `C(divisions + m - 1, m - 1)`.
41///
42/// Returns `None` on overflow instead of wrapping, so callers can report an
43/// honest configuration error rather than sizing a population from a wrapped
44/// count.
45pub fn lattice_size(num_objectives: usize, divisions: usize) -> Option<usize> {
46    if num_objectives == 0 {
47        return Some(0);
48    }
49    if num_objectives == 1 {
50        return Some(1);
51    }
52    // C(n, k) with n = divisions + m - 1, k = m - 1, computed multiplicatively so
53    // intermediate values stay as small as possible.
54    let n = divisions.checked_add(num_objectives - 1)?;
55    let k = num_objectives - 1;
56    let k = k.min(n.checked_sub(k)?);
57    let mut result: usize = 1;
58    for i in 0..k {
59        result = result.checked_mul(n - i)?;
60        result /= i + 1;
61    }
62    Some(result)
63}
64
65/// Smallest number of divisions whose Das-Dennis lattice has at least `target`
66/// weight vectors, together with that lattice's size.
67///
68/// MOEA/D's population size *is* the number of subproblems, and NSGA-III's
69/// population is sized to the number of reference directions, so both need to go
70/// from a requested population size to a lattice that can supply it.
71pub fn divisions_for_at_least(num_objectives: usize, target: usize) -> Option<(usize, usize)> {
72    if num_objectives == 0 {
73        return None;
74    }
75    if num_objectives == 1 {
76        return Some((1, 1));
77    }
78    for divisions in 1..=MAX_LATTICE_DIVISIONS {
79        let size = lattice_size(num_objectives, divisions)?;
80        if size >= target {
81            return Some((divisions, size));
82        }
83    }
84    None
85}
86
87/// Structured Das-Dennis weight vectors: every vector of non-negative multiples
88/// of `1/divisions` whose components sum to exactly one.
89///
90/// The lattice is deterministic and covers the objective simplex uniformly, which
91/// is what makes decomposition-based search reproducible and its subproblems
92/// evenly spread. `divisions` is treated as at least one (a zero-division lattice
93/// would be the all-zero vector, which is not a weight vector).
94pub fn das_dennis_weights<T: Float>(num_objectives: usize, divisions: usize) -> Vec<Vec<T>> {
95    if num_objectives == 0 {
96        return Vec::new();
97    }
98    if num_objectives == 1 {
99        return vec![vec![T::one()]];
100    }
101    let divisions = divisions.max(1);
102    let mut out: Vec<Vec<usize>> = Vec::new();
103    let mut current: Vec<usize> = Vec::with_capacity(num_objectives);
104    lattice_recurse(num_objectives, divisions, &mut current, &mut out);
105
106    let denominator = T::from(divisions).unwrap_or_else(T::one);
107    out.into_iter()
108        .map(|counts| {
109            counts
110                .into_iter()
111                .map(|count| T::from(count).unwrap_or_else(T::zero) / denominator)
112                .collect()
113        })
114        .collect()
115}
116
117/// Enumerate the integer compositions of `left` into `num_objectives` parts.
118fn lattice_recurse(
119    num_objectives: usize,
120    left: usize,
121    current: &mut Vec<usize>,
122    out: &mut Vec<Vec<usize>>,
123) {
124    if current.len() + 1 == num_objectives {
125        current.push(left);
126        out.push(current.clone());
127        current.pop();
128        return;
129    }
130    for share in 0..=left {
131        current.push(share);
132        lattice_recurse(num_objectives, left - share, current, out);
133        current.pop();
134    }
135}
136
137/// Indices of the `size` weight vectors closest (Euclidean) to each weight
138/// vector, itself included — the MOEA/D neighbourhood structure `B(i)`.
139///
140/// Ties are broken by index so the neighbourhoods are deterministic.
141pub fn weight_neighborhoods<T: Float>(weights: &[Vec<T>], size: usize) -> Vec<Vec<usize>> {
142    let size = size.max(1).min(weights.len().max(1));
143    (0..weights.len())
144        .map(|i| {
145            let mut ordered: Vec<(usize, f64)> = (0..weights.len())
146                .map(|j| (j, squared_distance(&weights[i], &weights[j])))
147                .collect();
148            ordered.sort_by(|a, b| {
149                a.1.partial_cmp(&b.1)
150                    .unwrap_or(std::cmp::Ordering::Equal)
151                    .then_with(|| a.0.cmp(&b.0))
152            });
153            ordered
154                .into_iter()
155                .take(size)
156                .map(|(index, _)| index)
157                .collect()
158        })
159        .collect()
160}
161
162fn squared_distance<T: Float>(a: &[T], b: &[T]) -> f64 {
163    let len = a.len().min(b.len());
164    let mut total = 0.0;
165    for index in 0..len {
166        let diff = (a[index] - b[index]).to_f64().unwrap_or(0.0);
167        total += diff * diff;
168    }
169    total
170}
171
172/// Scalarization used to reduce a subproblem's objective vector to a single
173/// comparable cost. Mirrors [`super::moead::DecompositionMethod`]; kept separate
174/// so this module has no dependency on the optimizer that configures it.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum Scalarization {
177    /// `sum_i w_i * (f_i - z*_i)`.
178    WeightedSum,
179    /// `max_i w_i * |f_i - z*_i|`.
180    Tchebycheff,
181    /// `d1 + theta * d2` along and perpendicular to the weight direction.
182    PenaltyBoundaryIntersection,
183    /// `max_i (f_i - z*_i) / w_i`.
184    AchievementScalarizing,
185}
186
187/// Scalarized cost of `objectives` for the subproblem defined by `weight`,
188/// relative to the ideal point `ideal`. **Lower is better** for every variant.
189///
190/// All inputs are in minimization space. A missing ideal-point entry is treated
191/// as zero, and every weight component is floored at
192/// [`MIN_WEIGHT_COMPONENT`] so the simplex corners remain usable.
193pub fn scalarize<T: Float>(
194    method: Scalarization,
195    objectives: &[T],
196    weight: &[T],
197    ideal: &[T],
198) -> T {
199    let len = objectives.len().min(weight.len());
200    if len == 0 {
201        return T::zero();
202    }
203    let floor = T::from(MIN_WEIGHT_COMPONENT).unwrap_or_else(T::zero);
204    let translated: Vec<T> = (0..len)
205        .map(|i| objectives[i] - ideal.get(i).copied().unwrap_or_else(T::zero))
206        .collect();
207
208    match method {
209        Scalarization::WeightedSum => {
210            let mut total = T::zero();
211            for i in 0..len {
212                total = total + weight[i] * translated[i];
213            }
214            total
215        }
216        Scalarization::Tchebycheff => {
217            let mut worst = T::neg_infinity();
218            for i in 0..len {
219                let scaled = weight[i].max(floor) * translated[i].abs();
220                if scaled > worst {
221                    worst = scaled;
222                }
223            }
224            if worst.is_finite() {
225                worst
226            } else {
227                T::zero()
228            }
229        }
230        Scalarization::AchievementScalarizing => {
231            let mut worst = T::neg_infinity();
232            for i in 0..len {
233                let scaled = translated[i] / weight[i].max(floor);
234                if scaled > worst {
235                    worst = scaled;
236                }
237            }
238            if worst.is_finite() {
239                worst
240            } else {
241                T::zero()
242            }
243        }
244        Scalarization::PenaltyBoundaryIntersection => {
245            let norm = {
246                let mut sum = T::zero();
247                for value in weight.iter().take(len) {
248                    sum = sum + *value * *value;
249                }
250                sum.sqrt()
251            };
252            if norm <= T::zero() {
253                return T::zero();
254            }
255            let mut dot = T::zero();
256            for i in 0..len {
257                dot = dot + translated[i] * weight[i];
258            }
259            let d1 = dot / norm;
260            let mut perpendicular_sq = T::zero();
261            for i in 0..len {
262                let along = weight[i] / norm * d1;
263                let diff = translated[i] - along;
264                perpendicular_sq = perpendicular_sq + diff * diff;
265            }
266            let d2 = perpendicular_sq.sqrt();
267            let theta = T::from(PBI_PENALTY).unwrap_or_else(T::one);
268            d1 + theta * d2
269        }
270    }
271}
272
273/// Fold `objectives` into a running ideal point, extending it when the point has
274/// more objectives than have been seen so far. Returns `true` when the ideal
275/// point moved.
276pub fn update_ideal_point<T: Float>(ideal: &mut Vec<T>, objectives: &[T]) -> bool {
277    let mut moved = false;
278    if ideal.len() < objectives.len() {
279        ideal.resize(objectives.len(), T::infinity());
280        moved = true;
281    }
282    for (index, value) in objectives.iter().enumerate() {
283        if *value < ideal[index] {
284            ideal[index] = *value;
285            moved = true;
286        }
287    }
288    moved
289}
290
291/// Perpendicular (orthogonal) distance from `point` to the ray through the
292/// origin with direction `direction`. This is the distance NSGA-III associates
293/// solutions to reference directions by.
294pub fn perpendicular_distance<T: Float>(point: &[T], direction: &[T]) -> T {
295    let len = point.len().min(direction.len());
296    if len == 0 {
297        return T::zero();
298    }
299    let mut norm_sq = T::zero();
300    for value in direction.iter().take(len) {
301        norm_sq = norm_sq + *value * *value;
302    }
303    if norm_sq <= T::zero() {
304        // A zero direction defines no ray; the honest distance is the point's own
305        // norm, which is what an all-zero reference direction would measure.
306        let mut point_norm_sq = T::zero();
307        for value in point.iter().take(len) {
308            point_norm_sq = point_norm_sq + *value * *value;
309        }
310        return point_norm_sq.sqrt();
311    }
312    let norm = norm_sq.sqrt();
313    let mut dot = T::zero();
314    for i in 0..len {
315        dot = dot + point[i] * direction[i];
316    }
317    let projection = dot / norm;
318    let mut residual_sq = T::zero();
319    for i in 0..len {
320        let along = direction[i] / norm * projection;
321        let diff = point[i] - along;
322        residual_sq = residual_sq + diff * diff;
323    }
324    residual_sq.sqrt()
325}
326
327/// Per-objective minima of `points` — the ideal point of a set.
328pub fn ideal_point_of<T: Float>(points: &[Vec<T>]) -> Option<Vec<T>> {
329    let num_objectives = points.iter().map(|p| p.len()).max()?;
330    if num_objectives == 0 {
331        return None;
332    }
333    let mut ideal = vec![T::infinity(); num_objectives];
334    for point in points {
335        for (index, value) in point.iter().enumerate() {
336            if *value < ideal[index] {
337                ideal[index] = *value;
338            }
339        }
340    }
341    if ideal.iter().any(|value| !value.is_finite()) {
342        return None;
343    }
344    Some(ideal)
345}
346
347/// The `m` extreme points of a translated (ideal-point-subtracted) set: for each
348/// axis `j`, the point minimizing the achievement scalarizing function with a
349/// weight vector concentrated on `j`.
350///
351/// Returns the indices into `translated`, one per objective.
352pub fn extreme_point_indices<T: Float>(translated: &[Vec<T>], num_objectives: usize) -> Vec<usize> {
353    let mut indices = Vec::with_capacity(num_objectives);
354    for axis in 0..num_objectives {
355        let mut weight =
356            vec![T::from(MIN_WEIGHT_COMPONENT).unwrap_or_else(T::zero); num_objectives];
357        if axis < weight.len() {
358            weight[axis] = T::one();
359        }
360        let mut best_index = 0usize;
361        let mut best_value = T::infinity();
362        for (index, point) in translated.iter().enumerate() {
363            let value = scalarize(Scalarization::AchievementScalarizing, point, &weight, &[]);
364            if value < best_value {
365                best_value = value;
366                best_index = index;
367            }
368        }
369        indices.push(best_index);
370    }
371    indices
372}
373
374/// Solve `A x = b` by Gaussian elimination with partial pivoting.
375///
376/// Returns `None` when the matrix is singular to working precision, which is the
377/// signal NSGA-III uses to fall back from hyperplane intercepts to the observed
378/// per-objective maxima.
379pub fn solve_linear_system<T: Float>(matrix: &[Vec<T>], rhs: &[T]) -> Option<Vec<T>> {
380    let n = rhs.len();
381    if matrix.len() != n || matrix.iter().any(|row| row.len() != n) || n == 0 {
382        return None;
383    }
384    let mut augmented: Vec<Vec<T>> = matrix
385        .iter()
386        .zip(rhs.iter())
387        .map(|(row, value)| {
388            let mut new_row = row.clone();
389            new_row.push(*value);
390            new_row
391        })
392        .collect();
393
394    for column in 0..n {
395        // Partial pivot.
396        let mut pivot_row = column;
397        let mut pivot_magnitude = augmented[column][column].abs();
398        for (index, row_values) in augmented.iter().enumerate().skip(column + 1) {
399            let magnitude = row_values[column].abs();
400            if magnitude > pivot_magnitude {
401                pivot_magnitude = magnitude;
402                pivot_row = index;
403            }
404        }
405        if pivot_magnitude <= T::from(1e-12).unwrap_or_else(T::zero) {
406            return None;
407        }
408        if pivot_row != column {
409            augmented.swap(pivot_row, column);
410        }
411        let pivot = augmented[column][column];
412        // The pivot row is read-only for the rest of this column's elimination, so
413        // it is copied out once instead of being re-borrowed against every target
414        // row.
415        let pivot_values = augmented[column].clone();
416        for target in augmented.iter_mut().skip(column + 1) {
417            let factor = target[column] / pivot;
418            if factor == T::zero() {
419                continue;
420            }
421            for (col, pivot_value) in pivot_values.iter().enumerate().skip(column) {
422                target[col] = target[col] - factor * *pivot_value;
423            }
424        }
425    }
426
427    let mut solution = vec![T::zero(); n];
428    for row in (0..n).rev() {
429        let mut accumulated = augmented[row][n];
430        for col in (row + 1)..n {
431            accumulated = accumulated - augmented[row][col] * solution[col];
432        }
433        let pivot = augmented[row][row];
434        if pivot == T::zero() {
435            return None;
436        }
437        solution[row] = accumulated / pivot;
438    }
439    Some(solution)
440}
441
442/// NSGA-III's adaptive normalization: translate `points` by their ideal point and
443/// divide each objective by the intercept of the hyperplane through the `m`
444/// extreme points.
445///
446/// Returns the normalized points together with the intercepts actually used. When
447/// the extreme points are degenerate (fewer than `m` distinct ones, or a singular
448/// system, or a non-positive intercept) the per-objective maxima are used
449/// instead — this is the documented fallback in the original paper, not a
450/// fabricated value.
451pub fn normalize_by_hyperplane<T: Float>(
452    points: &[Vec<T>],
453    num_objectives: usize,
454) -> Option<(Vec<Vec<T>>, Vec<T>)> {
455    if points.is_empty() || num_objectives == 0 {
456        return None;
457    }
458    let ideal = ideal_point_of(points)?;
459    let translated: Vec<Vec<T>> = points
460        .iter()
461        .map(|point| {
462            (0..num_objectives)
463                .map(|index| {
464                    point.get(index).copied().unwrap_or_else(T::zero)
465                        - ideal.get(index).copied().unwrap_or_else(T::zero)
466                })
467                .collect()
468        })
469        .collect();
470
471    let mut intercepts = fallback_intercepts(&translated, num_objectives);
472    let extreme = extreme_point_indices(&translated, num_objectives);
473    let distinct: std::collections::BTreeSet<usize> = extreme.iter().copied().collect();
474    if distinct.len() == num_objectives {
475        let matrix: Vec<Vec<T>> = extreme
476            .iter()
477            .map(|index| translated[*index].clone())
478            .collect();
479        let rhs = vec![T::one(); num_objectives];
480        if let Some(plane) = solve_linear_system(&matrix, &rhs) {
481            let candidate: Vec<T> = plane
482                .iter()
483                .map(|coefficient| {
484                    if *coefficient > T::zero() {
485                        T::one() / *coefficient
486                    } else {
487                        T::zero()
488                    }
489                })
490                .collect();
491            let epsilon = T::from(1e-10).unwrap_or_else(T::zero);
492            if candidate.iter().all(|value| *value > epsilon) {
493                intercepts = candidate;
494            }
495        }
496    }
497
498    let normalized: Vec<Vec<T>> = translated
499        .iter()
500        .map(|point| {
501            point
502                .iter()
503                .zip(intercepts.iter())
504                .map(|(value, intercept)| {
505                    if *intercept > T::zero() {
506                        *value / *intercept
507                    } else {
508                        *value
509                    }
510                })
511                .collect()
512        })
513        .collect();
514    Some((normalized, intercepts))
515}
516
517/// Per-objective maxima of a translated set, floored so a degenerate axis does
518/// not divide by zero.
519fn fallback_intercepts<T: Float>(translated: &[Vec<T>], num_objectives: usize) -> Vec<T> {
520    let floor = T::from(1e-10).unwrap_or_else(T::zero);
521    (0..num_objectives)
522        .map(|axis| {
523            let mut maximum = T::zero();
524            for point in translated {
525                if let Some(value) = point.get(axis) {
526                    if *value > maximum {
527                        maximum = *value;
528                    }
529                }
530            }
531            if maximum > floor {
532                maximum
533            } else {
534                T::one()
535            }
536        })
537        .collect()
538}
539
540/// Association of each point with its nearest reference direction:
541/// `(direction index, perpendicular distance)`.
542pub fn associate_with_references<T: Float>(
543    normalized: &[Vec<T>],
544    references: &[Vec<T>],
545) -> Vec<(usize, T)> {
546    normalized
547        .iter()
548        .map(|point| {
549            let mut best_index = 0usize;
550            let mut best_distance = T::infinity();
551            for (index, reference) in references.iter().enumerate() {
552                let distance = perpendicular_distance(point, reference);
553                if distance < best_distance {
554                    best_distance = distance;
555                    best_index = index;
556                }
557            }
558            (best_index, best_distance)
559        })
560        .collect()
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn das_dennis_lattice_has_the_published_size_and_sums_to_one() {
569        // C(H + m - 1, m - 1): m = 3, H = 4 -> C(6, 2) = 15.
570        let weights = das_dennis_weights::<f64>(3, 4);
571        assert_eq!(weights.len(), 15);
572        assert_eq!(lattice_size(3, 4), Some(15));
573        for weight in &weights {
574            assert_eq!(weight.len(), 3);
575            let total: f64 = weight.iter().sum();
576            assert!(
577                (total - 1.0).abs() < 1e-12,
578                "weight did not sum to 1: {weight:?}"
579            );
580            assert!(weight.iter().all(|value| *value >= 0.0));
581        }
582        // m = 2, H = 4 -> 5 evenly spaced vectors on the line w1 + w2 = 1.
583        let two = das_dennis_weights::<f64>(2, 4);
584        assert_eq!(two.len(), 5);
585        let mut firsts: Vec<f64> = two.iter().map(|weight| weight[0]).collect();
586        firsts.sort_by(|a, b| a.partial_cmp(b).expect("finite weights"));
587        for (index, value) in firsts.iter().enumerate() {
588            assert!((value - index as f64 * 0.25).abs() < 1e-12);
589        }
590        // The lattice is a set: no duplicates.
591        let mut keys: Vec<String> = weights
592            .iter()
593            .map(|weight| format!("{:?}", weight))
594            .collect();
595        keys.sort();
596        let before = keys.len();
597        keys.dedup();
598        assert_eq!(keys.len(), before);
599    }
600
601    #[test]
602    fn lattice_size_matches_enumeration_and_reports_overflow() {
603        for objectives in 1..=4 {
604            for divisions in 1..=6 {
605                assert_eq!(
606                    lattice_size(objectives, divisions),
607                    Some(das_dennis_weights::<f64>(objectives, divisions).len()),
608                    "m={objectives} H={divisions}"
609                );
610            }
611        }
612        assert_eq!(lattice_size(30, usize::MAX), None);
613    }
614
615    #[test]
616    fn divisions_for_at_least_is_the_minimal_lattice() {
617        let (divisions, size) = divisions_for_at_least(3, 12).expect("lattice exists");
618        assert_eq!((divisions, size), (4, 15));
619        let (smaller, smaller_size) =
620            (divisions - 1, lattice_size(3, divisions - 1).expect("size"));
621        assert!(smaller_size < 12, "H={smaller} should not already suffice");
622        assert_eq!(divisions_for_at_least(1, 50), Some((1, 1)));
623    }
624
625    #[test]
626    fn neighborhoods_contain_self_and_the_nearest_weights() {
627        let weights = das_dennis_weights::<f64>(2, 4);
628        let neighborhoods = weight_neighborhoods(&weights, 3);
629        assert_eq!(neighborhoods.len(), weights.len());
630        for (index, neighborhood) in neighborhoods.iter().enumerate() {
631            assert_eq!(neighborhood.len(), 3);
632            assert_eq!(neighborhood[0], index, "self must be the nearest weight");
633            // Distances are non-decreasing along the neighbourhood.
634            let mut previous = 0.0;
635            for neighbor in neighborhood {
636                let distance = squared_distance(&weights[index], &weights[*neighbor]);
637                assert!(distance >= previous - 1e-12);
638                previous = distance;
639            }
640        }
641        // Requesting more neighbours than exist is clamped, not a panic.
642        assert_eq!(weight_neighborhoods(&weights, 99)[0].len(), weights.len());
643    }
644
645    #[test]
646    fn scalarizations_have_their_textbook_values() {
647        let weight = vec![0.5f64, 0.5];
648        let ideal = vec![0.0f64, 0.0];
649        let point = vec![2.0f64, 4.0];
650
651        assert!(
652            (scalarize(Scalarization::WeightedSum, &point, &weight, &ideal) - 3.0).abs() < 1e-12
653        );
654        // max(0.5*2, 0.5*4) = 2
655        assert!(
656            (scalarize(Scalarization::Tchebycheff, &point, &weight, &ideal) - 2.0).abs() < 1e-12
657        );
658        // max(2/0.5, 4/0.5) = 8
659        assert!(
660            (scalarize(
661                Scalarization::AchievementScalarizing,
662                &point,
663                &weight,
664                &ideal
665            ) - 8.0)
666                .abs()
667                < 1e-12
668        );
669        // w/|w| = (0.7071, 0.7071); d1 = (2+4)/sqrt(2) = 4.2426;
670        // along = (3, 3); d2 = ||(-1, 1)|| = 1.4142; g = 4.2426 + 5*1.4142 = 11.3137
671        let pbi = scalarize(
672            Scalarization::PenaltyBoundaryIntersection,
673            &point,
674            &weight,
675            &ideal,
676        );
677        assert!((pbi - 11.313708498984761).abs() < 1e-9, "pbi = {pbi}");
678    }
679
680    #[test]
681    fn scalarizations_survive_a_simplex_corner_weight() {
682        let weight = vec![1.0f64, 0.0];
683        let ideal = vec![0.0f64, 0.0];
684        let point = vec![1.0f64, 3.0];
685        for method in [
686            Scalarization::WeightedSum,
687            Scalarization::Tchebycheff,
688            Scalarization::AchievementScalarizing,
689            Scalarization::PenaltyBoundaryIntersection,
690        ] {
691            let value = scalarize(method, &point, &weight, &ideal);
692            assert!(value.is_finite(), "{method:?} produced {value}");
693        }
694        // The zero component is floored rather than dropped, so the ignored
695        // objective still breaks ties between points that agree on the weighted
696        // one. (The floor is deliberately tiny: it must not outrank the objective
697        // the corner subproblem is actually about.)
698        let tied = vec![0.0f64, 3.0];
699        let tied_but_worse = vec![0.0f64, 300.0];
700        assert!(
701            scalarize(Scalarization::Tchebycheff, &tied_but_worse, &weight, &ideal)
702                > scalarize(Scalarization::Tchebycheff, &tied, &weight, &ideal)
703        );
704        // ... and it does not: the corner subproblem still prefers the point that
705        // is better on its own objective, however bad the other one is.
706        assert!(
707            scalarize(Scalarization::Tchebycheff, &tied_but_worse, &weight, &ideal)
708                < scalarize(Scalarization::Tchebycheff, &point, &weight, &ideal)
709        );
710    }
711
712    #[test]
713    fn scalarization_respects_the_ideal_point_translation() {
714        let weight = vec![0.5f64, 0.5];
715        let point = vec![2.0f64, 4.0];
716        let shifted_ideal = vec![1.0f64, 1.0];
717        // Tchebycheff on (1, 3) -> max(0.5, 1.5) = 1.5
718        assert!(
719            (scalarize(Scalarization::Tchebycheff, &point, &weight, &shifted_ideal) - 1.5).abs()
720                < 1e-12
721        );
722    }
723
724    #[test]
725    fn ideal_point_tracks_the_minimum_per_objective() {
726        let mut ideal: Vec<f64> = Vec::new();
727        assert!(update_ideal_point(&mut ideal, &[3.0, 5.0]));
728        assert_eq!(ideal, vec![3.0, 5.0]);
729        assert!(update_ideal_point(&mut ideal, &[4.0, 1.0]));
730        assert_eq!(ideal, vec![3.0, 1.0]);
731        assert!(!update_ideal_point(&mut ideal, &[9.0, 9.0]));
732        assert_eq!(ideal, vec![3.0, 1.0]);
733    }
734
735    #[test]
736    fn perpendicular_distance_is_zero_on_the_ray_and_the_height_otherwise() {
737        let direction = vec![1.0f64, 1.0];
738        assert!(perpendicular_distance(&[2.0, 2.0], &direction).abs() < 1e-12);
739        // (1, 0) against the diagonal: height = 1/sqrt(2)
740        let distance = perpendicular_distance(&[1.0f64, 0.0], &direction);
741        assert!((distance - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-12);
742        // A zero direction reports the point's own norm rather than NaN.
743        assert!((perpendicular_distance(&[3.0f64, 4.0], &[0.0, 0.0]) - 5.0).abs() < 1e-12);
744    }
745
746    #[test]
747    fn linear_solver_matches_a_hand_computed_solution_and_detects_singularity() {
748        let matrix = vec![vec![2.0f64, 1.0], vec![1.0, 3.0]];
749        let rhs = vec![5.0f64, 10.0];
750        let solution = solve_linear_system(&matrix, &rhs).expect("non-singular");
751        assert!((solution[0] - 1.0).abs() < 1e-12, "{solution:?}");
752        assert!((solution[1] - 3.0).abs() < 1e-12, "{solution:?}");
753
754        let singular = vec![vec![1.0f64, 2.0], vec![2.0, 4.0]];
755        assert!(solve_linear_system(&singular, &[1.0, 2.0]).is_none());
756    }
757
758    #[test]
759    fn hyperplane_normalization_maps_the_extreme_points_onto_the_unit_simplex() {
760        // A linear front between (0, 4) and (2, 0): the hyperplane through the two
761        // extreme points has intercepts (2, 4) after ideal-point translation.
762        let front = vec![vec![0.0f64, 4.0], vec![1.0, 2.0], vec![2.0, 0.0]];
763        let (normalized, intercepts) =
764            normalize_by_hyperplane(&front, 2).expect("normalization succeeds");
765        assert!((intercepts[0] - 2.0).abs() < 1e-9, "{intercepts:?}");
766        assert!((intercepts[1] - 4.0).abs() < 1e-9, "{intercepts:?}");
767        // Extremes land on the unit axes, the middle point on the unit simplex.
768        assert!((normalized[0][0] - 0.0).abs() < 1e-9);
769        assert!((normalized[0][1] - 1.0).abs() < 1e-9);
770        assert!((normalized[2][0] - 1.0).abs() < 1e-9);
771        assert!((normalized[2][1] - 0.0).abs() < 1e-9);
772        let middle_sum: f64 = normalized[1].iter().sum();
773        assert!((middle_sum - 1.0).abs() < 1e-9, "{:?}", normalized[1]);
774    }
775
776    #[test]
777    fn hyperplane_normalization_falls_back_when_the_front_is_degenerate() {
778        // A single point cannot define m distinct extreme points.
779        let front = vec![vec![3.0f64, 7.0]];
780        let (normalized, intercepts) = normalize_by_hyperplane(&front, 2).expect("fallback");
781        assert!(intercepts.iter().all(|value| *value > 0.0));
782        assert!(normalized[0].iter().all(|value| value.is_finite()));
783        // Identical points: still finite, never NaN.
784        let flat = vec![vec![1.0f64, 1.0], vec![1.0, 1.0]];
785        let (normalized, _) = normalize_by_hyperplane(&flat, 2).expect("fallback");
786        assert!(normalized
787            .iter()
788            .all(|point| point.iter().all(|value| value.is_finite())));
789    }
790
791    #[test]
792    fn association_picks_the_nearest_reference_direction() {
793        let references = das_dennis_weights::<f64>(2, 4);
794        // Exactly on the (0.25, 0.75) direction.
795        let points = vec![vec![0.25f64, 0.75], vec![1.0, 0.0]];
796        let associations = associate_with_references(&points, &references);
797        assert!(associations[0].1.abs() < 1e-12);
798        assert!(
799            (references[associations[0].0][0] - 0.25).abs() < 1e-12,
800            "associated with {:?}",
801            references[associations[0].0]
802        );
803        assert!((references[associations[1].0][0] - 1.0).abs() < 1e-12);
804    }
805}