1use 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#[derive(Debug, Clone)]
25pub struct SpatialQuality {
26 pub evr: f64,
28
29 pub cap_areas: Vec<f64>,
31
32 pub exclusivities: Vec<f64>,
35
36 pub voronoi_cells: Vec<VoronoiCell>,
38
39 pub pairwise_intersections: HashMap<(usize, usize), f64>,
43
44 pub coverage: CoverageReport,
46
47 pub bridge_threshold: f64,
50
51 pub bridge_quality_matrix: Vec<Vec<f64>>,
56}
57
58impl SpatialQuality {
59 #[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 pub fn compute_with_config(
74 centroids: &[SphericalPoint],
75 half_angles: &[f64],
76 evr: f64,
77 config: &PipelineConfig,
78 ) -> Self {
79 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 ¢roids[i],
103 half_angles[i],
104 ¢roids[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 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 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 pub fn territorial_factor(&self, cat_a: usize, cat_b: usize) -> f64 {
152 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 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 pub fn voronoi_area(&self, cat: usize) -> f64 {
169 self.voronoi_cells.get(cat).map_or(0.0, |cell| cell.area)
170 }
171
172 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 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(¢roids, &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(¢roids, &half_angles, 0.19);
232 let sq_high = compute(¢roids, &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(¢roids, &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(¢roids, &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(¢roids, &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(¢roids, &half_angles, 0.5);
288 let configured = compute(¢roids, &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}