sphereql_layout/
quality.rs1use std::f64::consts::PI;
2
3use rayon::prelude::*;
4use sphereql_core::{SphericalPoint, angular_distance};
5
6use crate::types::LayoutQuality;
7
8pub(crate) const MAX_QUALITY_N: usize = 5000;
9pub(crate) const OVERLAP_THRESHOLD: f64 = 0.01;
11const SERIAL_THRESHOLD: usize = 128;
14
15fn sample_positions(positions: &[SphericalPoint]) -> Vec<SphericalPoint> {
16 if positions.len() <= MAX_QUALITY_N {
17 return positions.to_vec();
18 }
19 let step = positions.len() / MAX_QUALITY_N;
20 positions
21 .iter()
22 .step_by(step)
23 .take(MAX_QUALITY_N)
24 .copied()
25 .collect()
26}
27
28pub fn compute_dispersion(positions: &[SphericalPoint]) -> f64 {
29 let positions = sample_positions(positions);
30 let n = positions.len();
31 if n <= 1 {
32 return 1.0;
33 }
34
35 let ideal_spacing = (4.0 * PI / n as f64).sqrt();
36
37 let per_i_min = |i: usize| -> f64 {
38 let mut local = f64::MAX;
39 for j in (i + 1)..n {
40 let d = angular_distance(&positions[i], &positions[j]);
41 if d < local {
42 local = d;
43 }
44 }
45 local
46 };
47 let min_dist: f64 = if n < SERIAL_THRESHOLD {
48 (0..n).map(per_i_min).fold(f64::MAX, f64::min)
49 } else {
50 (0..n)
51 .into_par_iter()
52 .map(per_i_min)
53 .reduce(|| f64::MAX, f64::min)
54 };
55
56 (min_dist / ideal_spacing).clamp(0.0, 1.0)
57}
58
59pub fn compute_overlap(positions: &[SphericalPoint], threshold: f64) -> f64 {
60 let positions = sample_positions(positions);
61 let n = positions.len();
62 if n < 2 {
63 return 0.0;
64 }
65
66 let total_pairs = n * (n - 1) / 2;
67
68 let per_i_overlap = |i: usize| -> usize {
69 let mut c = 0usize;
70 for j in (i + 1)..n {
71 if angular_distance(&positions[i], &positions[j]) < threshold {
72 c += 1;
73 }
74 }
75 c
76 };
77 let overlapping: usize = if n < SERIAL_THRESHOLD {
78 (0..n).map(per_i_overlap).sum()
79 } else {
80 (0..n).into_par_iter().map(per_i_overlap).sum()
81 };
82
83 overlapping as f64 / total_pairs as f64
84}
85
86pub fn compute_quality(positions: &[SphericalPoint]) -> LayoutQuality {
87 LayoutQuality {
88 dispersion_score: compute_dispersion(positions),
89 overlap_score: compute_overlap(positions, OVERLAP_THRESHOLD),
90 silhouette_score: 0.0,
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use std::f64::consts::FRAC_PI_2;
98
99 #[test]
100 fn empty_and_single_point() {
101 assert!((compute_dispersion(&[]) - 1.0).abs() < 1e-12);
102 assert!(compute_overlap(&[], 0.01).abs() < 1e-12);
103
104 let single = vec![SphericalPoint::new_unchecked(1.0, 0.0, FRAC_PI_2)];
105 assert!((compute_dispersion(&single) - 1.0).abs() < 1e-12);
106 assert!(compute_overlap(&single, 0.01).abs() < 1e-12);
107 }
108
109 #[test]
110 fn two_opposite_points_high_dispersion() {
111 let positions = vec![
112 SphericalPoint::new_unchecked(1.0, 0.0, 0.0),
113 SphericalPoint::new_unchecked(1.0, 0.0, PI),
114 ];
115 let d = compute_dispersion(&positions);
116 assert!(d > 0.7, "dispersion {d} should be high for opposite points");
117 }
118
119 #[test]
120 fn two_identical_points_full_overlap() {
121 let positions = vec![
122 SphericalPoint::new_unchecked(1.0, 0.5, 1.0),
123 SphericalPoint::new_unchecked(1.0, 0.5, 1.0),
124 ];
125 let o = compute_overlap(&positions, 0.01);
126 assert!(
127 (o - 1.0).abs() < 1e-12,
128 "overlap {o} should be 1.0 for identical points"
129 );
130 }
131
132 #[test]
133 fn well_spaced_points_decent_dispersion() {
134 let positions = vec![
135 SphericalPoint::new_unchecked(1.0, 0.0, 0.0),
136 SphericalPoint::new_unchecked(1.0, 0.0, PI),
137 SphericalPoint::new_unchecked(1.0, 0.0, FRAC_PI_2),
138 SphericalPoint::new_unchecked(1.0, FRAC_PI_2, FRAC_PI_2),
139 SphericalPoint::new_unchecked(1.0, PI, FRAC_PI_2),
140 SphericalPoint::new_unchecked(1.0, 3.0 * FRAC_PI_2, FRAC_PI_2),
141 ];
142 let d = compute_dispersion(&positions);
143 assert!(
144 d > 0.5,
145 "dispersion {d} should be > 0.5 for well-spaced points"
146 );
147 }
148
149 #[test]
150 fn compute_quality_combines_metrics() {
151 let positions = vec![
152 SphericalPoint::new_unchecked(1.0, 0.0, 0.0),
153 SphericalPoint::new_unchecked(1.0, 0.0, PI),
154 ];
155 let q = compute_quality(&positions);
156 assert!(q.dispersion_score > 0.0);
157 assert!(q.overlap_score >= 0.0);
158 assert!((q.silhouette_score).abs() < 1e-12);
159 }
160}