1use std::collections::HashSet;
15
16use sphereql_core::{SphericalPoint, angular_distance, spherical_to_cartesian};
17
18use crate::ann::{AnnConfig, AnnIndex};
19use crate::category::BridgeClassification;
20use crate::pipeline::SphereQLPipeline;
21
22pub trait QualityMetric: Send + Sync {
34 fn name(&self) -> &str;
36 fn score(&self, pipeline: &SphereQLPipeline) -> f64;
38
39 fn score_with_components(&self, pipeline: &SphereQLPipeline) -> (f64, Vec<(String, f64, f64)>) {
51 (self.score(pipeline), Vec::new())
52 }
53}
54
55pub struct TerritorialHealth;
63
64impl QualityMetric for TerritorialHealth {
65 fn name(&self) -> &str {
66 "territorial_health"
67 }
68
69 fn score(&self, pipeline: &SphereQLPipeline) -> f64 {
70 let layer = pipeline.category_layer();
71 let n = layer.num_categories();
72 if n < 2 {
73 return 1.0;
74 }
75 let sq = &layer.spatial_quality;
76 let mut sum = 0.0;
77 let mut count = 0usize;
78 for i in 0..n {
79 for j in (i + 1)..n {
80 sum += sq.territorial_factor(i, j);
81 count += 1;
82 }
83 }
84 if count == 0 {
85 1.0
86 } else {
87 (sum / count as f64).clamp(0.0, 1.0)
88 }
89 }
90}
91
92pub const BRIDGE_COHERENCE_NEUTRAL: f64 = 0.5;
100
101pub struct BridgeCoherence;
108
109impl QualityMetric for BridgeCoherence {
110 fn name(&self) -> &str {
111 "bridge_coherence"
112 }
113
114 fn score(&self, pipeline: &SphereQLPipeline) -> f64 {
115 let layer = pipeline.category_layer();
116 let mut genuine = 0usize;
117 let mut total = 0usize;
118 for bridges in layer.graph.bridges.values() {
119 for b in bridges {
120 total += 1;
121 if b.classification == BridgeClassification::Genuine {
122 genuine += 1;
123 }
124 }
125 }
126 if total == 0 {
127 1.0
129 } else if genuine == 0 {
130 BRIDGE_COHERENCE_NEUTRAL
133 } else {
134 genuine as f64 / total as f64
135 }
136 }
137}
138
139pub struct BridgeDiversity;
156
157impl QualityMetric for BridgeDiversity {
158 fn name(&self) -> &str {
159 "bridge_diversity"
160 }
161
162 fn score(&self, pipeline: &SphereQLPipeline) -> f64 {
163 let layer = pipeline.category_layer();
164 let n_cats = layer.num_categories();
165 if n_cats < 2 {
166 return 1.0;
167 }
168
169 let total_pairs = n_cats * (n_cats - 1) / 2;
170
171 let mut pairs_with_genuine: HashSet<(usize, usize)> = HashSet::new();
172 for (&(src, tgt), bridges) in &layer.graph.bridges {
173 if bridges
174 .iter()
175 .any(|b| b.classification == BridgeClassification::Genuine)
176 {
177 let pair = if src < tgt { (src, tgt) } else { (tgt, src) };
178 pairs_with_genuine.insert(pair);
179 }
180 }
181
182 (pairs_with_genuine.len() as f64 / total_pairs as f64).clamp(0.0, 1.0)
183 }
184}
185
186pub struct ClusterSilhouette;
204
205impl QualityMetric for ClusterSilhouette {
206 fn name(&self) -> &str {
207 "cluster_silhouette"
208 }
209
210 fn score(&self, pipeline: &SphereQLPipeline) -> f64 {
211 let layer = pipeline.category_layer();
212 let n_cats = layer.num_categories();
213 if n_cats < 2 {
214 return 1.0;
215 }
216
217 let points = pipeline.metric_points();
218 if points.positions.len() < 2 {
219 return 1.0;
220 }
221
222 let centroids: Vec<SphericalPoint> = layer
225 .summaries
226 .iter()
227 .map(|s| s.centroid_position)
228 .collect();
229
230 let mut silhouette_sum = 0.0;
231 let mut scored_points = 0usize;
232
233 for (sp, cat) in points.positions.iter().zip(&points.category_indices) {
234 let Some(ci) = *cat else {
235 continue;
236 };
237
238 let a = angular_distance(sp, ¢roids[ci]);
240 if a < 1e-12 {
243 continue;
244 }
245
246 let mut b = f64::INFINITY;
248 for (j, centroid) in centroids.iter().enumerate() {
249 if j == ci {
250 continue;
251 }
252 let d = angular_distance(sp, centroid);
253 if d < b {
254 b = d;
255 }
256 }
257 if b.is_infinite() {
258 continue;
259 }
260
261 let s = (b - a) / a.max(b).max(f64::EPSILON);
262 silhouette_sum += s;
263 scored_points += 1;
264 }
265
266 if scored_points == 0 {
267 return 1.0;
268 }
269 let mean_s = silhouette_sum / scored_points as f64;
270 ((mean_s + 1.0) / 2.0).clamp(0.0, 1.0)
271 }
272}
273
274const MODULARITY_ANN_THRESHOLD: usize = 2000;
280
281fn knn_edges(positions: &[SphericalPoint], k: usize, exact: bool) -> HashSet<(usize, usize)> {
294 let n = positions.len();
295 let mut edges: HashSet<(usize, usize)> = HashSet::new();
296 if exact {
297 for i in 0..n {
298 let mut dists: Vec<(usize, f64)> = (0..n)
299 .filter(|&j| j != i)
300 .map(|j| (j, angular_distance(&positions[i], &positions[j])))
301 .collect();
302 dists.sort_by(|a, b| a.1.total_cmp(&b.1));
303 for &(j, _) in dists.iter().take(k) {
304 let e = if i < j { (i, j) } else { (j, i) };
305 edges.insert(e);
306 }
307 }
308 } else {
309 let coords: Vec<Vec<f64>> = positions
310 .iter()
311 .map(|p| {
312 let c = spherical_to_cartesian(p);
313 vec![c.x, c.y, c.z]
314 })
315 .collect();
316 let index = AnnIndex::build(&coords, &AnnConfig::default());
317 for i in 0..n {
318 for (j, _) in index.query_by_index(i, k) {
319 let e = if i < j { (i, j) } else { (j, i) };
320 edges.insert(e);
321 }
322 }
323 }
324 edges
325}
326
327pub struct GraphModularity {
357 pub k: usize,
363}
364
365impl Default for GraphModularity {
366 fn default() -> Self {
367 Self { k: 15 }
368 }
369}
370
371impl GraphModularity {
372 pub fn new(k: usize) -> Self {
373 Self { k }
374 }
375}
376
377impl QualityMetric for GraphModularity {
378 fn name(&self) -> &str {
379 "graph_modularity"
380 }
381
382 fn score(&self, pipeline: &SphereQLPipeline) -> f64 {
383 let layer = pipeline.category_layer();
384 let n_cats = layer.num_categories();
385 if n_cats < 2 {
386 return 1.0;
387 }
388
389 let points = pipeline.metric_points();
390 let n = points.positions.len();
391 if n < 2 {
392 return 1.0;
393 }
394 let item_cats = &points.category_indices;
395
396 let k = self.k.min(n - 1).max(1);
397 let edges = knn_edges(&points.positions, k, n < MODULARITY_ANN_THRESHOLD);
398
399 let m = edges.len() as f64;
400 if m < 1.0 {
401 return 0.0;
402 }
403
404 let mut degree = vec![0usize; n];
406 for &(a, b) in &edges {
407 degree[a] += 1;
408 degree[b] += 1;
409 }
410
411 let mut intra_edges = vec![0.0f64; n_cats];
413 let mut degree_sum = vec![0.0f64; n_cats];
414 for (i, &d) in degree.iter().enumerate() {
415 if let Some(c) = item_cats[i] {
416 degree_sum[c] += d as f64;
417 }
418 }
419 for &(a, b) in &edges {
420 if let (Some(ca), Some(cb)) = (item_cats[a], item_cats[b])
421 && ca == cb
422 {
423 intra_edges[ca] += 1.0;
424 }
425 }
426
427 let two_m = 2.0 * m;
428 let q: f64 = (0..n_cats)
429 .map(|c| (intra_edges[c] / m) - (degree_sum[c] / two_m).powi(2))
430 .sum();
431
432 q.clamp(0.0, 1.0)
436 }
437}
438
439pub struct CompositeMetric {
446 label: String,
447 components: Vec<(Box<dyn QualityMetric>, f64)>,
448}
449
450impl CompositeMetric {
451 pub fn new(label: impl Into<String>, components: Vec<(Box<dyn QualityMetric>, f64)>) -> Self {
455 let filtered: Vec<(Box<dyn QualityMetric>, f64)> =
456 components.into_iter().filter(|(_, w)| *w > 0.0).collect();
457 let sum: f64 = filtered.iter().map(|(_, w)| *w).sum();
458 let components: Vec<(Box<dyn QualityMetric>, f64)> = if sum > 0.0 {
459 filtered.into_iter().map(|(m, w)| (m, w / sum)).collect()
460 } else {
461 Vec::new()
462 };
463 Self {
464 label: label.into(),
465 components,
466 }
467 }
468
469 pub fn default_composite() -> Self {
477 Self::new(
478 "default_composite",
479 vec![
480 (Box::new(BridgeDiversity) as Box<dyn QualityMetric>, 0.30),
481 (Box::new(TerritorialHealth) as Box<dyn QualityMetric>, 0.25),
482 (Box::new(ClusterSilhouette) as Box<dyn QualityMetric>, 0.25),
483 (
484 Box::new(GraphModularity::default()) as Box<dyn QualityMetric>,
485 0.20,
486 ),
487 ],
488 )
489 }
490
491 pub fn connectivity_composite() -> Self {
499 Self::new(
500 "connectivity_composite",
501 vec![
502 (
503 Box::new(GraphModularity::default()) as Box<dyn QualityMetric>,
504 0.40,
505 ),
506 (Box::new(BridgeDiversity) as Box<dyn QualityMetric>, 0.35),
507 (Box::new(TerritorialHealth) as Box<dyn QualityMetric>, 0.25),
508 ],
509 )
510 }
511
512 pub fn score_components(&self, pipeline: &SphereQLPipeline) -> Vec<(String, f64, f64)> {
515 self.components
516 .iter()
517 .map(|(m, w)| (m.name().to_string(), *w, m.score(pipeline)))
518 .collect()
519 }
520}
521
522impl QualityMetric for CompositeMetric {
523 fn name(&self) -> &str {
524 &self.label
525 }
526
527 fn score(&self, pipeline: &SphereQLPipeline) -> f64 {
528 if self.components.is_empty() {
529 return 0.0;
530 }
531 let total: f64 = self
532 .components
533 .iter()
534 .map(|(m, w)| w * m.score(pipeline))
535 .sum();
536 total.clamp(0.0, 1.0)
537 }
538
539 fn score_with_components(&self, pipeline: &SphereQLPipeline) -> (f64, Vec<(String, f64, f64)>) {
540 if self.components.is_empty() {
541 return (0.0, Vec::new());
542 }
543 let breakdown = self.score_components(pipeline);
546 let total: f64 = breakdown.iter().map(|(_, w, s)| w * s).sum();
547 (total.clamp(0.0, 1.0), breakdown)
548 }
549}
550
551#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::pipeline::{PipelineInput, SphereQLPipeline};
557
558 fn make_pipeline() -> SphereQLPipeline {
559 let n = 30;
560 let dim = 10;
561 let mut embeddings = Vec::new();
562 let mut categories = Vec::new();
563 for i in 0..n {
564 let mut v = vec![0.0; dim];
565 if i < n / 2 {
566 v[0] = 1.0 + (i as f64 * 0.01);
567 v[1] = 0.1;
568 categories.push("alpha".to_string());
569 } else {
570 v[0] = 0.1;
571 v[1] = 1.0 + (i as f64 * 0.01);
572 categories.push("beta".to_string());
573 }
574 v[2] = 0.05 * i as f64;
575 embeddings.push(v);
576 }
577 SphereQLPipeline::new(PipelineInput {
578 categories,
579 embeddings,
580 })
581 .unwrap()
582 }
583
584 #[test]
585 fn territorial_health_in_range() {
586 let p = make_pipeline();
587 let s = TerritorialHealth.score(&p);
588 assert!((0.0..=1.0).contains(&s), "got {s}");
589 }
590
591 #[test]
592 fn bridge_coherence_in_range() {
593 let p = make_pipeline();
594 let s = BridgeCoherence.score(&p);
595 assert!((0.0..=1.0).contains(&s), "got {s}");
596 }
597
598 #[test]
599 fn bridge_coherence_returns_neutral_when_no_genuine() {
600 use crate::config::PipelineConfig;
605 let n = 30;
606 let dim = 10;
607 let mut embeddings = Vec::new();
608 let mut categories = Vec::new();
609 for i in 0..n {
610 let mut v = vec![0.0; dim];
611 if i < n / 2 {
612 v[0] = 1.0 + (i as f64 * 0.01);
613 v[1] = 0.1;
614 categories.push("alpha".to_string());
615 } else {
616 v[0] = 0.1;
617 v[1] = 1.0 + (i as f64 * 0.01);
618 categories.push("beta".to_string());
619 }
620 v[2] = 0.05 * i as f64;
621 embeddings.push(v);
622 }
623 let mut config = PipelineConfig::default();
624 config.bridges.min_evr_for_classification = 1.5;
625 let p = SphereQLPipeline::new_with_config(
626 PipelineInput {
627 categories,
628 embeddings,
629 },
630 config,
631 )
632 .expect("pipeline build failed");
633
634 let has_bridges = p
635 .category_layer()
636 .graph
637 .bridges
638 .values()
639 .any(|v| !v.is_empty());
640
641 let score = BridgeCoherence.score(&p);
642 if has_bridges {
643 assert!(
644 (score - BRIDGE_COHERENCE_NEUTRAL).abs() < 1e-12,
645 "expected neutral {BRIDGE_COHERENCE_NEUTRAL} when bridges exist but none Genuine, got {score}"
646 );
647 } else {
648 assert!((score - 1.0).abs() < 1e-12);
651 }
652 }
653
654 #[test]
655 fn cluster_silhouette_in_range() {
656 let p = make_pipeline();
657 let s = ClusterSilhouette.score(&p);
658 assert!((0.0..=1.0).contains(&s), "got {s}");
659 }
660
661 #[test]
662 fn composite_in_range() {
663 let p = make_pipeline();
664 let m = CompositeMetric::default_composite();
665 let s = m.score(&p);
666 assert!((0.0..=1.0).contains(&s));
667 }
668
669 #[test]
670 fn composite_with_zero_weight_drops_component() {
671 let m = CompositeMetric::new(
672 "test",
673 vec![
674 (Box::new(TerritorialHealth) as Box<dyn QualityMetric>, 1.0),
675 (Box::new(BridgeCoherence) as Box<dyn QualityMetric>, 0.0),
676 ],
677 );
678 let p = make_pipeline();
679 let combined = m.score(&p);
680 let solo = TerritorialHealth.score(&p);
681 assert!((combined - solo).abs() < 1e-12);
683 }
684
685 #[test]
686 fn composite_renormalizes_weights() {
687 let m = CompositeMetric::new(
689 "test",
690 vec![
691 (Box::new(TerritorialHealth) as Box<dyn QualityMetric>, 2.0),
692 (Box::new(BridgeCoherence) as Box<dyn QualityMetric>, 3.0),
693 ],
694 );
695 let p = make_pipeline();
696 let combined = m.score(&p);
697 let expected = 0.4 * TerritorialHealth.score(&p) + 0.6 * BridgeCoherence.score(&p);
698 assert!((combined - expected).abs() < 1e-12);
699 }
700
701 #[test]
702 fn composite_component_breakdown_sums_to_score() {
703 let p = make_pipeline();
704 let m = CompositeMetric::default_composite();
705 let breakdown = m.score_components(&p);
706 let weighted: f64 = breakdown.iter().map(|(_, w, s)| w * s).sum();
707 let total = m.score(&p);
708 assert!((weighted - total).abs() < 1e-12);
709 }
710
711 #[test]
712 fn score_with_components_matches_score() {
713 let p = make_pipeline();
714 let m = CompositeMetric::default_composite();
715 let (total, components) = m.score_with_components(&p);
716 assert!((total - m.score(&p)).abs() < 1e-12);
717 assert_eq!(components.len(), 4);
718 let recomposed: f64 = components.iter().map(|(_, w, s)| w * s).sum();
719 assert!((total - recomposed).abs() < 1e-12);
720 }
721
722 #[test]
723 fn score_with_components_default_is_empty_for_leaf_metrics() {
724 let p = make_pipeline();
725 let (total, components) = TerritorialHealth.score_with_components(&p);
726 assert!((total - TerritorialHealth.score(&p)).abs() < 1e-12);
727 assert!(components.is_empty());
728 }
729
730 #[test]
731 fn metric_names_stable() {
732 assert_eq!(TerritorialHealth.name(), "territorial_health");
733 assert_eq!(BridgeCoherence.name(), "bridge_coherence");
734 assert_eq!(BridgeDiversity.name(), "bridge_diversity");
735 assert_eq!(ClusterSilhouette.name(), "cluster_silhouette");
736 assert_eq!(GraphModularity::default().name(), "graph_modularity");
737 assert_eq!(
738 CompositeMetric::default_composite().name(),
739 "default_composite"
740 );
741 assert_eq!(
742 CompositeMetric::connectivity_composite().name(),
743 "connectivity_composite"
744 );
745 }
746
747 #[test]
748 fn bridge_diversity_in_range() {
749 let p = make_pipeline();
750 let s = BridgeDiversity.score(&p);
751 assert!((0.0..=1.0).contains(&s), "got {s}");
752 }
753
754 #[test]
755 fn bridge_diversity_is_deterministic() {
756 let p = make_pipeline();
757 let a = BridgeDiversity.score(&p);
758 let b = BridgeDiversity.score(&p);
759 assert_eq!(a, b);
760 }
761
762 #[test]
763 fn default_composite_includes_bridge_diversity_not_coherence() {
764 let p = make_pipeline();
765 let m = CompositeMetric::default_composite();
766 let breakdown = m.score_components(&p);
767 let names: Vec<&str> = breakdown.iter().map(|(n, _, _)| n.as_str()).collect();
768 assert!(
769 names.contains(&"bridge_diversity"),
770 "default composite should include bridge_diversity, got {names:?}"
771 );
772 assert!(
773 !names.contains(&"bridge_coherence"),
774 "default composite should NOT include bridge_coherence, got {names:?}"
775 );
776 assert!(
777 names.contains(&"graph_modularity"),
778 "default composite should include graph_modularity, got {names:?}"
779 );
780 }
781
782 #[test]
783 fn default_composite_has_four_components() {
784 let p = make_pipeline();
785 let m = CompositeMetric::default_composite();
786 let breakdown = m.score_components(&p);
787 assert_eq!(
788 breakdown.len(),
789 4,
790 "default composite should have 4 components"
791 );
792 }
793
794 #[test]
795 fn silhouette_is_deterministic() {
796 let p = make_pipeline();
797 let a = ClusterSilhouette.score(&p);
798 let b = ClusterSilhouette.score(&p);
799 assert_eq!(a, b);
800 }
801
802 #[test]
803 fn cluster_silhouette_separates_well_structured_from_random() {
804 let p = make_pipeline();
810 let s = ClusterSilhouette.score(&p);
811 assert!(
812 s > 0.55,
813 "well-separated clusters should score above random baseline, got {s}"
814 );
815 }
816
817 #[test]
818 fn graph_modularity_in_range() {
819 let p = make_pipeline();
820 let s = GraphModularity::default().score(&p);
821 assert!((0.0..=1.0).contains(&s), "got {s}");
822 }
823
824 #[test]
825 fn graph_modularity_is_deterministic() {
826 let p = make_pipeline();
827 let m = GraphModularity::default();
828 let a = m.score(&p);
829 let b = m.score(&p);
830 assert_eq!(a, b);
831 }
832
833 #[test]
834 fn graph_modularity_detects_real_structure() {
835 let p = make_pipeline();
840 let s = GraphModularity::default().score(&p);
841 assert!(
845 s > 0.1,
846 "well-separated category assignment should have Q > 0.1, got {s}"
847 );
848 }
849
850 #[test]
851 fn graph_modularity_k_parameter_respected() {
852 let p = make_pipeline();
853 let s_small = GraphModularity::new(3).score(&p);
854 let s_large = GraphModularity::new(15).score(&p);
855 assert!((0.0..=1.0).contains(&s_small));
858 assert!((0.0..=1.0).contains(&s_large));
859 }
860
861 #[test]
862 fn knn_edges_ann_path_respects_cluster_structure() {
863 let mut positions = Vec::new();
868 for i in 0..60 {
869 let t = i as f64;
870 positions.push(SphericalPoint::new_unchecked(
871 1.0,
872 0.3 + t * 0.001,
873 1.2 + t * 0.0005,
874 ));
875 }
876 for i in 0..60 {
877 let t = i as f64;
878 positions.push(SphericalPoint::new_unchecked(
879 1.0,
880 3.5 + t * 0.001,
881 1.8 + t * 0.0005,
882 ));
883 }
884
885 for exact in [true, false] {
886 let edges = knn_edges(&positions, 5, exact);
887 assert!(!edges.is_empty(), "edge set empty (exact={exact})");
888 for &(a, b) in &edges {
889 assert_eq!(
890 a < 60,
891 b < 60,
892 "edge ({a}, {b}) crosses caps (exact={exact})"
893 );
894 }
895 }
896 }
897
898 #[test]
899 fn connectivity_composite_weights_modularity_most() {
900 let p = make_pipeline();
904 let cc = CompositeMetric::connectivity_composite();
905 let breakdown = cc.score_components(&p);
906 let heaviest = breakdown
908 .iter()
909 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
910 .unwrap();
911 assert_eq!(heaviest.0, "graph_modularity");
912 assert!((heaviest.1 - 0.40).abs() < 1e-12);
913 }
914}