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