Skip to main content

sphereql_embed/
spatial_quality.rs

1//! Spatial quality metrics computed from category geometry on S².
2//!
3//! Bridges the gap between raw spatial primitives (`sphereql_core::spatial`)
4//! and the category enrichment layer. Computed once at pipeline build time,
5//! then fed into bridge detection, edge weighting, and confidence scoring.
6
7use sphereql_core::SphericalPoint;
8use sphereql_core::spatial::{
9    CoverageReport, VoronoiCell, cap_exclusivity, cap_intersection_area, cap_solid_angle,
10    estimate_coverage, spherical_voronoi,
11};
12
13use crate::category::CategoryGraph;
14use crate::config::PipelineConfig;
15
16/// Pre-computed spatial properties of the category layout on S².
17///
18/// Every field here is derived from the category centroids and angular
19/// spreads — no embedding-space information, pure sphere geometry.
20/// This struct is computed once during `CategoryLayer::build()` and
21/// informs bridge detection, edge weights, and confidence scoring.
22#[derive(Debug, Clone)]
23pub struct SpatialQuality {
24    /// Global explained variance ratio of the projection.
25    pub evr: f64,
26
27    /// Solid angle of each category's cap (2π(1 − cos α)).
28    pub cap_areas: Vec<f64>,
29
30    /// Per-category exclusivity: fraction of cap not overlapped by any other.
31    /// 1.0 = isolated, 0.0 = completely overlapped.
32    pub exclusivities: Vec<f64>,
33
34    /// Voronoi cell for each category (area + neighbor indices).
35    pub voronoi_cells: Vec<VoronoiCell>,
36
37    /// Pairwise cap intersection areas. Keyed by (min(i,j), max(i,j)).
38    pub pairwise_intersections: Vec<PairIntersection>,
39
40    /// Coverage report: what fraction of S² is claimed by any category.
41    pub coverage: CoverageReport,
42
43    /// EVR-adaptive bridge threshold. Higher EVR → looser threshold.
44    /// Formula: 0.5 + (1 − EVR)² × 0.4
45    pub bridge_threshold: f64,
46
47    /// C×C matrix of spatially-adjusted bridge quality between category pairs.
48    /// `matrix[i][j] = max_bridge_strength(i,j) × territorial_factor(i,j)`.
49    /// Empty until [`Self::set_bridge_quality_matrix`] is called with a
50    /// built [`CategoryGraph`] (done during `CategoryLayer::build`).
51    pub bridge_quality_matrix: Vec<Vec<f64>>,
52}
53
54/// Cap intersection area between two categories on S².
55///
56/// Stored in [`SpatialQuality::pairwise_intersections`]; only pairs with
57/// measurable overlap (> 1e-15 sr) are kept to keep the list sparse.
58#[derive(Debug, Clone, Copy)]
59pub struct PairIntersection {
60    /// Lower of the two category indices (`min(i, j)`).
61    pub cat_a: usize,
62    /// Higher of the two category indices (`max(i, j)`).
63    pub cat_b: usize,
64    /// Overlap area of the two caps, in steradians.
65    pub area: f64,
66}
67
68impl SpatialQuality {
69    /// Compute spatial quality from category centroids and angular spreads,
70    /// using the legacy default Monte Carlo sample counts.
71    ///
72    /// Prefer [`Self::compute_with_config`] when you need to tune sample
73    /// counts or the EVR-adaptive bridge threshold formula.
74    pub fn compute(centroids: &[SphericalPoint], half_angles: &[f64], evr: f64) -> Self {
75        Self::compute_with_config(centroids, half_angles, evr, &PipelineConfig::default())
76    }
77
78    /// Compute spatial quality using configurable sample counts and bridge
79    /// threshold parameters.
80    ///
81    /// Cost at default sample counts: ~100-200ms for 31 categories. This is
82    /// a one-time build cost, not per-query.
83    pub fn compute_with_config(
84        centroids: &[SphericalPoint],
85        half_angles: &[f64],
86        evr: f64,
87        config: &PipelineConfig,
88    ) -> Self {
89        let n = centroids.len();
90        let sc = &config.spatial;
91
92        let cap_areas: Vec<f64> = half_angles.iter().map(|&a| cap_solid_angle(a)).collect();
93
94        let exclusivities: Vec<f64> = (0..n)
95            .map(|i| cap_exclusivity(i, centroids, half_angles, sc.exclusivity_samples))
96            .collect();
97
98        let voronoi_cells = spherical_voronoi(centroids, sc.voronoi_samples);
99
100        let mut pairwise_intersections = Vec::with_capacity(n * (n - 1) / 2);
101        for i in 0..n {
102            for j in (i + 1)..n {
103                let area = cap_intersection_area(
104                    &centroids[i],
105                    half_angles[i],
106                    &centroids[j],
107                    half_angles[j],
108                );
109                if area > 1e-15 {
110                    pairwise_intersections.push(PairIntersection {
111                        cat_a: i,
112                        cat_b: j,
113                        area,
114                    });
115                }
116            }
117        }
118
119        let coverage = estimate_coverage(centroids, half_angles, sc.coverage_samples);
120
121        // Higher EVR → looser threshold (more of the geometry is trustworthy).
122        let bridge_threshold = config.bridges.evr_adaptive_threshold(evr);
123
124        Self {
125            evr,
126            cap_areas,
127            exclusivities,
128            voronoi_cells,
129            pairwise_intersections,
130            coverage,
131            bridge_threshold,
132            bridge_quality_matrix: vec![vec![0.0; n]; n],
133        }
134    }
135
136    /// Populate the C×C `bridge_quality_matrix` from a freshly built graph.
137    ///
138    /// Each cell is `edge.max_bridge_strength × territorial_factor(i, j)`,
139    /// left at 0.0 where no edge exists (including the diagonal).
140    pub fn set_bridge_quality_matrix(&mut self, graph: &CategoryGraph) {
141        let n = self.exclusivities.len();
142        self.bridge_quality_matrix = vec![vec![0.0; n]; n];
143        for (i, edges) in graph.adjacency.iter().enumerate() {
144            for edge in edges {
145                let j = edge.target;
146                self.bridge_quality_matrix[i][j] =
147                    edge.max_bridge_strength * self.territorial_factor(i, j);
148            }
149        }
150    }
151
152    /// Exclusivity-based territorial factor for a category pair.
153    ///
154    /// Bridges between categories that heavily overlap (low exclusivity)
155    /// are discounted — they're shared territory, not genuine connectors.
156    /// Returns a value in (0, 1].
157    pub fn territorial_factor(&self, cat_a: usize, cat_b: usize) -> f64 {
158        let ea = self.exclusivities.get(cat_a).copied().unwrap_or(1.0);
159        let eb = self.exclusivities.get(cat_b).copied().unwrap_or(1.0);
160        (ea * eb).sqrt().max(0.05)
161    }
162
163    /// Whether two categories are Voronoi neighbors (geometrically adjacent on S²).
164    pub fn are_voronoi_neighbors(&self, cat_a: usize, cat_b: usize) -> bool {
165        self.voronoi_cells
166            .get(cat_a)
167            .is_some_and(|cell| cell.neighbor_indices.contains(&cat_b))
168    }
169
170    /// Voronoi cell area for a category.
171    pub fn voronoi_area(&self, cat: usize) -> f64 {
172        self.voronoi_cells.get(cat).map_or(0.0, |cell| cell.area)
173    }
174
175    /// Territorial efficiency: items per steradian of Voronoi cell.
176    pub fn territorial_efficiency(&self, cat: usize, item_count: usize) -> f64 {
177        let area = self.voronoi_area(cat);
178        if area > 1e-15 {
179            item_count as f64 / area
180        } else {
181            0.0
182        }
183    }
184
185    /// Cap intersection area between two categories.
186    pub fn intersection_area(&self, cat_a: usize, cat_b: usize) -> f64 {
187        let (lo, hi) = if cat_a < cat_b {
188            (cat_a, cat_b)
189        } else {
190            (cat_b, cat_a)
191        };
192        self.pairwise_intersections
193            .iter()
194            .find(|p| p.cat_a == lo && p.cat_b == hi)
195            .map_or(0.0, |p| p.area)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use std::f64::consts::{FRAC_PI_2, PI};
203
204    fn unit(theta: f64, phi: f64) -> SphericalPoint {
205        SphericalPoint::new_unchecked(1.0, theta, phi)
206    }
207
208    #[test]
209    fn spatial_quality_basic() {
210        let centroids = vec![
211            unit(0.0, FRAC_PI_2),
212            unit(PI, FRAC_PI_2),
213            unit(FRAC_PI_2, FRAC_PI_2),
214        ];
215        let half_angles = vec![0.5, 0.5, 0.5];
216        let sq = SpatialQuality::compute(&centroids, &half_angles, 0.5);
217
218        assert_eq!(sq.cap_areas.len(), 3);
219        assert_eq!(sq.exclusivities.len(), 3);
220        assert_eq!(sq.voronoi_cells.len(), 3);
221        assert!(sq.coverage.coverage_fraction > 0.0);
222        assert!(sq.bridge_threshold > 0.5);
223    }
224
225    #[test]
226    fn bridge_threshold_scales_with_evr() {
227        let centroids = vec![unit(0.0, FRAC_PI_2)];
228        let half_angles = vec![0.5];
229
230        let sq_low = SpatialQuality::compute(&centroids, &half_angles, 0.19);
231        let sq_high = SpatialQuality::compute(&centroids, &half_angles, 0.80);
232
233        assert!(
234            sq_low.bridge_threshold > sq_high.bridge_threshold,
235            "low EVR should have stricter threshold: {} vs {}",
236            sq_low.bridge_threshold,
237            sq_high.bridge_threshold
238        );
239    }
240
241    #[test]
242    fn territorial_factor_range() {
243        let centroids = vec![unit(0.0, FRAC_PI_2), unit(PI, FRAC_PI_2)];
244        let half_angles = vec![0.5, 0.5];
245        let sq = SpatialQuality::compute(&centroids, &half_angles, 0.5);
246
247        let tf = sq.territorial_factor(0, 1);
248        assert!(
249            tf > 0.0 && tf <= 1.0,
250            "territorial factor out of range: {tf}"
251        );
252    }
253
254    #[test]
255    fn voronoi_neighbors_detected() {
256        let centroids = vec![
257            unit(0.0, FRAC_PI_2),
258            unit(0.5, FRAC_PI_2),
259            unit(PI, FRAC_PI_2),
260        ];
261        let half_angles = vec![0.3, 0.3, 0.3];
262        let sq = SpatialQuality::compute(&centroids, &half_angles, 0.5);
263
264        assert!(
265            sq.are_voronoi_neighbors(0, 1),
266            "close centroids should be Voronoi neighbors"
267        );
268    }
269
270    #[test]
271    fn exclusivities_bounded() {
272        let centroids = vec![unit(0.0, FRAC_PI_2), unit(PI, FRAC_PI_2)];
273        let half_angles = vec![0.3, 0.3];
274        let sq = SpatialQuality::compute(&centroids, &half_angles, 0.5);
275
276        for &e in &sq.exclusivities {
277            assert!((0.0..=1.0).contains(&e), "exclusivity out of range: {e}");
278        }
279    }
280}