1use scirs2_core::numeric::Float;
20
21pub const MIN_WEIGHT_COMPONENT: f64 = 1e-6;
28
29pub const PBI_PENALTY: f64 = 5.0;
32
33const MAX_LATTICE_DIVISIONS: usize = 2000;
38
39pub 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 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
65pub 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
87pub 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
117fn 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
137pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum Scalarization {
177 WeightedSum,
179 Tchebycheff,
181 PenaltyBoundaryIntersection,
183 AchievementScalarizing,
185}
186
187pub 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
273pub 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
291pub 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 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
327pub 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
347pub 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
374pub 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 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 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
442pub 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
517fn 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
540pub 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 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 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 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 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 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 assert!(
656 (scalarize(Scalarization::Tchebycheff, &point, &weight, &ideal) - 2.0).abs() < 1e-12
657 );
658 assert!(
660 (scalarize(
661 Scalarization::AchievementScalarizing,
662 &point,
663 &weight,
664 &ideal
665 ) - 8.0)
666 .abs()
667 < 1e-12
668 );
669 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 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 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 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 let distance = perpendicular_distance(&[1.0f64, 0.0], &direction);
741 assert!((distance - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-12);
742 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 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 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 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 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 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}