Skip to main content

sphereql_layout/
quality.rs

1use 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;
9/// Angular distance below which two points are considered overlapping.
10pub(crate) const OVERLAP_THRESHOLD: f64 = 0.01;
11/// Below this point count, the outer pair-scan loop stays serial —
12/// rayon's thread-pool startup dominates for small inputs.
13const 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}