1use std::collections::HashMap;
13
14use sphereql_core::spatial::*;
15use sphereql_core::{SphericalPoint, angular_distance};
16
17use crate::category::{BridgeItem, CategoryLayer};
18
19#[derive(Debug, Clone)]
22pub struct AntipodalReport {
23 pub category_name: String,
24 pub centroid: SphericalPoint,
25 pub antipode_position: SphericalPoint,
26 pub antipodal_items: Vec<AntipodalItem>,
27 pub antipodal_coherence: f64,
29 pub dominant_antipodal_category: Option<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct AntipodalItem {
34 pub item_index: usize,
35 pub category: String,
36 pub distance_to_antipode: f64,
37}
38
39pub fn antipodal_analysis(
40 layer: &CategoryLayer,
41 all_positions: &[SphericalPoint],
42 all_categories: &[String],
43 radius: f64,
44) -> Vec<AntipodalReport> {
45 layer
46 .summaries
47 .iter()
48 .map(|summary| {
49 let ap = antipode(&summary.centroid_position);
50
51 let mut items: Vec<AntipodalItem> = all_positions
52 .iter()
53 .enumerate()
54 .filter_map(|(i, pos)| {
55 let d = angular_distance(&ap, pos);
56 if d <= radius {
57 Some(AntipodalItem {
58 item_index: i,
59 category: all_categories[i].clone(),
60 distance_to_antipode: d,
61 })
62 } else {
63 None
64 }
65 })
66 .collect();
67 items.sort_by(|a, b| {
68 a.distance_to_antipode
69 .partial_cmp(&b.distance_to_antipode)
70 .unwrap_or(std::cmp::Ordering::Equal)
71 });
72
73 let coherence = region_coherence(&ap, radius, all_positions);
74
75 let dominant = if items.is_empty() {
76 None
77 } else {
78 let mut counts: HashMap<&str, usize> = HashMap::new();
79 for item in &items {
80 *counts.entry(item.category.as_str()).or_default() += 1;
81 }
82 counts
83 .into_iter()
84 .max_by_key(|&(_, c)| c)
85 .map(|(name, _)| name.to_string())
86 };
87
88 AntipodalReport {
89 category_name: summary.name.clone(),
90 centroid: summary.centroid_position,
91 antipode_position: ap,
92 antipodal_items: items,
93 antipodal_coherence: coherence,
94 dominant_antipodal_category: dominant,
95 }
96 })
97 .collect()
98}
99
100#[derive(Debug, Clone)]
103pub struct KnowledgeCoverageReport {
104 pub coverage_fraction: f64,
105 pub covered_area: f64,
106 pub overlap_area: f64,
107 pub category_caps: Vec<CategoryCapInfo>,
108 pub void_samples: usize,
109 pub total_samples: usize,
110}
111
112#[derive(Debug, Clone)]
113pub struct CategoryCapInfo {
114 pub name: String,
115 pub centroid: SphericalPoint,
116 pub half_angle: f64,
117 pub solid_angle: f64,
118}
119
120pub fn knowledge_coverage(layer: &CategoryLayer, num_samples: usize) -> KnowledgeCoverageReport {
121 let centers: Vec<SphericalPoint> = layer
122 .summaries
123 .iter()
124 .map(|s| s.centroid_position)
125 .collect();
126 let half_angles: Vec<f64> = layer.summaries.iter().map(|s| s.angular_spread).collect();
127 let report = estimate_coverage(¢ers, &half_angles, num_samples);
128
129 let category_caps: Vec<CategoryCapInfo> = layer
130 .summaries
131 .iter()
132 .map(|s| CategoryCapInfo {
133 name: s.name.clone(),
134 centroid: s.centroid_position,
135 half_angle: s.angular_spread,
136 solid_angle: cap_solid_angle(s.angular_spread),
137 })
138 .collect();
139
140 KnowledgeCoverageReport {
141 coverage_fraction: report.coverage_fraction,
142 covered_area: report.covered_area,
143 overlap_area: report.overlap_area,
144 category_caps,
145 void_samples: report.void_count,
146 total_samples: report.total_samples,
147 }
148}
149
150#[must_use]
152pub fn gap_confidence(query: &SphericalPoint, layer: &CategoryLayer, sharpness: f64) -> f64 {
153 let centers: Vec<SphericalPoint> = layer
154 .summaries
155 .iter()
156 .map(|s| s.centroid_position)
157 .collect();
158 let half_angles: Vec<f64> = layer.summaries.iter().map(|s| s.angular_spread).collect();
159 let vd = void_distance(query, ¢ers, &half_angles);
160 1.0 / (1.0 + (sharpness * vd).exp())
161}
162
163#[derive(Debug, Clone)]
166pub struct GeodesicSweepReport {
167 pub start_name: String,
168 pub end_name: String,
169 pub arc_length: f64,
170 pub items: Vec<GeodesicSweepItem>,
171 pub density_profile: Vec<usize>,
172 pub gap_fraction: f64,
173}
174
175#[derive(Debug, Clone)]
176pub struct GeodesicSweepItem {
177 pub item_index: usize,
178 pub category: String,
179 pub distance_to_arc: f64,
180}
181
182pub fn category_geodesic_sweep(
183 layer: &CategoryLayer,
184 source_category: &str,
185 target_category: &str,
186 all_positions: &[SphericalPoint],
187 all_categories: &[String],
188 epsilon: f64,
189 density_bins: usize,
190) -> Option<GeodesicSweepReport> {
191 let src = layer.get_category(source_category)?;
192 let tgt = layer.get_category(target_category)?;
193
194 let hits = geodesic_sweep(
195 &src.centroid_position,
196 &tgt.centroid_position,
197 all_positions,
198 epsilon,
199 );
200
201 let items: Vec<GeodesicSweepItem> = hits
202 .iter()
203 .map(|&(idx, dist)| GeodesicSweepItem {
204 item_index: idx,
205 category: all_categories[idx].clone(),
206 distance_to_arc: dist,
207 })
208 .collect();
209
210 let profile = geodesic_density_profile(
211 &src.centroid_position,
212 &tgt.centroid_position,
213 all_positions,
214 epsilon,
215 density_bins,
216 );
217
218 let gap_fraction = if profile.is_empty() {
219 1.0
220 } else {
221 profile.iter().filter(|&&c| c == 0).count() as f64 / profile.len() as f64
222 };
223
224 Some(GeodesicSweepReport {
225 start_name: source_category.to_string(),
226 end_name: target_category.to_string(),
227 arc_length: angular_distance(&src.centroid_position, &tgt.centroid_position),
228 items,
229 density_profile: profile,
230 gap_fraction,
231 })
232}
233
234#[must_use]
235pub fn category_path_deviation(layer: &CategoryLayer, source: &str, target: &str) -> Option<f64> {
236 let path = layer.category_path(source, target)?;
237 if path.steps.len() < 2 {
238 return Some(0.0);
239 }
240 let waypoints: Vec<SphericalPoint> = path
241 .steps
242 .iter()
243 .map(|step| layer.summaries[step.category_index].centroid_position)
244 .collect();
245 Some(geodesic_deviation(&waypoints))
246}
247
248#[derive(Debug, Clone)]
251pub struct VoronoiReport {
252 pub cells: Vec<VoronoiCellReport>,
253 pub total_area: f64,
254}
255
256#[derive(Debug, Clone)]
257pub struct VoronoiCellReport {
258 pub category_name: String,
259 pub cell_area: f64,
260 pub voronoi_neighbors: Vec<String>,
261 pub item_count: usize,
262 pub territorial_efficiency: f64,
263 pub graph_neighbor_overlap: f64,
264}
265
266pub fn voronoi_analysis(layer: &CategoryLayer, num_samples: usize) -> VoronoiReport {
267 let centroids: Vec<SphericalPoint> = layer
268 .summaries
269 .iter()
270 .map(|s| s.centroid_position)
271 .collect();
272 let cells = spherical_voronoi(¢roids, num_samples);
273
274 let cell_reports: Vec<VoronoiCellReport> = cells
275 .iter()
276 .enumerate()
277 .map(|(i, cell)| {
278 let summary = &layer.summaries[i];
279 let voronoi_neighbors: Vec<String> = cell
280 .neighbor_indices
281 .iter()
282 .map(|&j| layer.summaries[j].name.clone())
283 .collect();
284
285 let efficiency = if cell.area > 1e-15 {
286 summary.member_count as f64 / cell.area
287 } else {
288 0.0
289 };
290
291 let graph_neighbors: Vec<usize> =
292 layer.graph.adjacency[i].iter().map(|e| e.target).collect();
293 let voronoi_set: std::collections::HashSet<usize> =
294 cell.neighbor_indices.iter().copied().collect();
295 let graph_set: std::collections::HashSet<usize> =
296 graph_neighbors.iter().copied().collect();
297 let intersection = voronoi_set.intersection(&graph_set).count();
298 let union_count = voronoi_set.union(&graph_set).count();
299 let overlap = if union_count > 0 {
300 intersection as f64 / union_count as f64
301 } else {
302 1.0
303 };
304
305 VoronoiCellReport {
306 category_name: summary.name.clone(),
307 cell_area: cell.area,
308 voronoi_neighbors,
309 item_count: summary.member_count,
310 territorial_efficiency: efficiency,
311 graph_neighbor_overlap: overlap,
312 }
313 })
314 .collect();
315
316 let total_area: f64 = cell_reports.iter().map(|c| c.cell_area).sum();
317 VoronoiReport {
318 cells: cell_reports,
319 total_area,
320 }
321}
322
323#[derive(Debug, Clone)]
326pub struct OverlapReport {
327 pub pairs: Vec<OverlapPair>,
328 pub exclusivities: Vec<CategoryExclusivity>,
329}
330
331#[derive(Debug, Clone)]
332pub struct OverlapPair {
333 pub category_a: String,
334 pub category_b: String,
335 pub intersection_area: f64,
336 pub bridge_count: usize,
337 pub overlap_bridge_ratio: f64,
338}
339
340#[derive(Debug, Clone)]
341pub struct CategoryExclusivity {
342 pub category_name: String,
343 pub cap_area: f64,
344 pub exclusivity: f64,
345}
346
347pub fn overlap_analysis(layer: &CategoryLayer, mc_samples_per_cap: usize) -> OverlapReport {
348 let centers: Vec<SphericalPoint> = layer
349 .summaries
350 .iter()
351 .map(|s| s.centroid_position)
352 .collect();
353 let half_angles: Vec<f64> = layer.summaries.iter().map(|s| s.angular_spread).collect();
354 let raw_overlaps = pairwise_overlaps(¢ers, &half_angles);
355
356 let pairs: Vec<OverlapPair> = raw_overlaps
357 .iter()
358 .map(|ov| {
359 let bridge_count = layer
360 .graph
361 .bridges
362 .get(&(ov.category_a, ov.category_b))
363 .map_or(0, |b| b.len())
364 + layer
365 .graph
366 .bridges
367 .get(&(ov.category_b, ov.category_a))
368 .map_or(0, |b| b.len());
369 let ratio = if bridge_count > 0 {
370 ov.intersection_area / bridge_count as f64
371 } else if ov.intersection_area > 1e-15 {
372 f64::INFINITY
373 } else {
374 0.0
375 };
376
377 OverlapPair {
378 category_a: layer.summaries[ov.category_a].name.clone(),
379 category_b: layer.summaries[ov.category_b].name.clone(),
380 intersection_area: ov.intersection_area,
381 bridge_count,
382 overlap_bridge_ratio: ratio,
383 }
384 })
385 .collect();
386
387 let exclusivities: Vec<CategoryExclusivity> = (0..layer.summaries.len())
388 .map(|i| {
389 let exc = cap_exclusivity(i, ¢ers, &half_angles, mc_samples_per_cap);
390 CategoryExclusivity {
391 category_name: layer.summaries[i].name.clone(),
392 cap_area: cap_solid_angle(half_angles[i]),
393 exclusivity: exc,
394 }
395 })
396 .collect();
397
398 OverlapReport {
399 pairs,
400 exclusivities,
401 }
402}
403
404#[derive(Debug, Clone)]
407pub struct CurvatureReport {
408 pub top_triples: Vec<CurvatureTriple>,
409 pub signatures: Vec<CategoryCurvatureSignature>,
410}
411
412#[derive(Debug, Clone)]
413pub struct CurvatureTriple {
414 pub categories: [String; 3],
415 pub excess: f64,
416}
417
418#[derive(Debug, Clone)]
419pub struct CategoryCurvatureSignature {
420 pub category_name: String,
421 pub mean_excess: f64,
422 pub max_excess: f64,
423 pub min_excess: f64,
424 pub mean_excess_z: f64,
432 pub relative_spread: f64,
436}
437
438pub fn curvature_analysis(layer: &CategoryLayer, top_n: usize) -> CurvatureReport {
439 let centroids: Vec<SphericalPoint> = layer
440 .summaries
441 .iter()
442 .map(|s| s.centroid_position)
443 .collect();
444 let n = centroids.len();
445
446 let mut triples: Vec<CurvatureTriple> = Vec::new();
447 for i in 0..n {
448 for j in (i + 1)..n {
449 for k in (j + 1)..n {
450 let excess = spherical_excess(¢roids[i], ¢roids[j], ¢roids[k]);
451 triples.push(CurvatureTriple {
452 categories: [
453 layer.summaries[i].name.clone(),
454 layer.summaries[j].name.clone(),
455 layer.summaries[k].name.clone(),
456 ],
457 excess,
458 });
459 }
460 }
461 }
462 triples.sort_by(|a, b| {
463 b.excess
464 .partial_cmp(&a.excess)
465 .unwrap_or(std::cmp::Ordering::Equal)
466 });
467
468 let raw: Vec<(f64, f64, f64)> = (0..n)
470 .map(|target| {
471 let sig = curvature_signature(target, ¢roids);
472 if sig.is_empty() {
473 (0.0, 0.0, 0.0)
474 } else {
475 let sum: f64 = sig.iter().sum();
476 (sum / sig.len() as f64, sig[0], sig[sig.len() - 1])
477 }
478 })
479 .collect();
480
481 let (corpus_mean, corpus_std) = mean_and_std(raw.iter().map(|t| t.0));
483 let global_max_excess = raw.iter().map(|t| t.2).fold(0.0_f64, f64::max);
484
485 let signatures: Vec<CategoryCurvatureSignature> = raw
486 .iter()
487 .enumerate()
488 .map(|(target, &(mean, min, max))| {
489 let mean_excess_z = if corpus_std > f64::EPSILON {
490 (mean - corpus_mean) / corpus_std
491 } else {
492 0.0
493 };
494 let relative_spread = if global_max_excess > f64::EPSILON {
495 (max - min) / global_max_excess
496 } else {
497 0.0
498 };
499 CategoryCurvatureSignature {
500 category_name: layer.summaries[target].name.clone(),
501 mean_excess: mean,
502 max_excess: max,
503 min_excess: min,
504 mean_excess_z,
505 relative_spread,
506 }
507 })
508 .collect();
509
510 CurvatureReport {
511 top_triples: triples.into_iter().take(top_n).collect(),
512 signatures,
513 }
514}
515
516fn mean_and_std<I: Iterator<Item = f64>>(values: I) -> (f64, f64) {
520 let collected: Vec<f64> = values.collect();
521 if collected.is_empty() {
522 return (0.0, 0.0);
523 }
524 let n = collected.len() as f64;
525 let mean = collected.iter().sum::<f64>() / n;
526 let var = collected.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
527 (mean, var.sqrt())
528}
529
530#[derive(Debug, Clone)]
533pub struct LuneReport {
534 pub category_a: String,
535 pub category_b: String,
536 pub a_leaning_count: usize,
537 pub b_leaning_count: usize,
538 pub on_bisector_count: usize,
539 pub asymmetry: f64,
540 pub bisector_voronoi_divergence: f64,
541}
542
543pub fn lune_analysis(layer: &CategoryLayer, _all_positions: &[SphericalPoint]) -> Vec<LuneReport> {
544 let n = layer.summaries.len();
545 let mut reports = Vec::new();
546
547 for i in 0..n {
548 for j in (i + 1)..n {
549 let bridges_ij = layer.graph.bridges.get(&(i, j));
550 let bridges_ji = layer.graph.bridges.get(&(j, i));
551
552 let ij_items: &[BridgeItem] = bridges_ij.map(|v| v.as_slice()).unwrap_or(&[]);
553 let ji_items: &[BridgeItem] = bridges_ji.map(|v| v.as_slice()).unwrap_or(&[]);
554
555 if ij_items.is_empty() && ji_items.is_empty() {
556 continue;
557 }
558
559 let ca = &layer.summaries[i].centroid_position;
560 let cb = &layer.summaries[j].centroid_position;
561
562 let (mut a_count, mut b_count, mut on_count) = (0usize, 0usize, 0usize);
563 for b in ij_items {
565 match b.affinity_to_source.partial_cmp(&b.affinity_to_target) {
566 Some(std::cmp::Ordering::Greater) => a_count += 1,
567 Some(std::cmp::Ordering::Less) => b_count += 1,
568 _ => on_count += 1,
569 }
570 }
571 for b in ji_items {
573 match b.affinity_to_source.partial_cmp(&b.affinity_to_target) {
574 Some(std::cmp::Ordering::Greater) => b_count += 1,
575 Some(std::cmp::Ordering::Less) => a_count += 1,
576 _ => on_count += 1,
577 }
578 }
579
580 let total = (a_count + b_count + on_count) as f64;
581 let asymmetry = if total > 0.0 {
582 (a_count as f64 - b_count as f64).abs() / total
583 } else {
584 0.0
585 };
586
587 let mid = sphereql_core::slerp(ca, cb, 0.5);
588
589 let mut min_dist = f64::INFINITY;
590 let mut closest_other = None;
591 for (k, summary) in layer.summaries.iter().enumerate() {
592 if k == i || k == j {
593 continue;
594 }
595 let d = angular_distance(&mid, &summary.centroid_position);
596 if d < min_dist {
597 min_dist = d;
598 closest_other = Some(k);
599 }
600 }
601
602 let divergence = if let Some(_k) = closest_other {
603 let d_i = angular_distance(&mid, ca);
604 let d_j = angular_distance(&mid, cb);
605 let d_expected = d_i.min(d_j);
606 if min_dist < d_expected {
607 (d_expected - min_dist).abs()
608 } else {
609 0.0
610 }
611 } else {
612 0.0
613 };
614
615 reports.push(LuneReport {
616 category_a: layer.summaries[i].name.clone(),
617 category_b: layer.summaries[j].name.clone(),
618 a_leaning_count: a_count,
619 b_leaning_count: b_count,
620 on_bisector_count: on_count,
621 asymmetry,
622 bisector_voronoi_divergence: divergence,
623 });
624 }
625 }
626 reports
627}
628
629pub struct NavigatorConfig {
632 pub antipodal_radius: f64,
633 pub coverage_samples: usize,
634 pub geodesic_epsilon: f64,
635 pub density_bins: usize,
636 pub voronoi_samples: usize,
637 pub exclusivity_samples: usize,
638 pub curvature_top_n: usize,
639 pub gap_sharpness: f64,
640}
641
642impl Default for NavigatorConfig {
643 fn default() -> Self {
644 Self {
645 antipodal_radius: 0.5,
646 coverage_samples: 200_000,
647 geodesic_epsilon: 0.3,
648 density_bins: 20,
649 voronoi_samples: 200_000,
650 exclusivity_samples: 50_000,
651 curvature_top_n: 20,
652 gap_sharpness: 5.0,
653 }
654 }
655}
656
657#[derive(Debug, Clone)]
658pub struct NavigatorReport {
659 pub antipodal: Vec<AntipodalReport>,
660 pub coverage: KnowledgeCoverageReport,
661 pub voronoi: VoronoiReport,
662 pub overlap: OverlapReport,
663 pub curvature: CurvatureReport,
664 pub lunes: Vec<LuneReport>,
665 pub num_categories: usize,
666 pub num_items: usize,
667 pub explained_variance_ratio: f64,
668}
669
670pub fn run_full_analysis(
671 layer: &CategoryLayer,
672 all_positions: &[SphericalPoint],
673 all_categories: &[String],
674 evr: f64,
675 config: &NavigatorConfig,
676) -> NavigatorReport {
677 let antipodal = antipodal_analysis(
678 layer,
679 all_positions,
680 all_categories,
681 config.antipodal_radius,
682 );
683 let coverage = knowledge_coverage(layer, config.coverage_samples);
684 let voronoi = voronoi_analysis(layer, config.voronoi_samples);
685 let overlap = overlap_analysis(layer, config.exclusivity_samples);
686 let curvature = curvature_analysis(layer, config.curvature_top_n);
687 let lunes = lune_analysis(layer, all_positions);
688
689 NavigatorReport {
690 antipodal,
691 coverage,
692 voronoi,
693 overlap,
694 curvature,
695 lunes,
696 num_categories: layer.summaries.len(),
697 num_items: all_positions.len(),
698 explained_variance_ratio: evr,
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use crate::pipeline::{PipelineInput, SphereQLPipeline};
706
707 fn make_test_pipeline() -> (SphereQLPipeline, Vec<String>) {
708 let mut embeddings = Vec::new();
709 let mut categories = Vec::new();
710 let dim = 10;
711
712 for i in 0..10 {
713 let mut v = vec![0.0; dim];
714 v[0] = 1.0 + i as f64 * 0.02;
715 v[1] = 0.1;
716 embeddings.push(v);
717 categories.push("alpha".to_string());
718 }
719 for i in 0..10 {
720 let mut v = vec![0.0; dim];
721 v[0] = 0.1;
722 v[1] = 1.0 + i as f64 * 0.02;
723 embeddings.push(v);
724 categories.push("beta".to_string());
725 }
726 for i in 0..10 {
727 let mut v = vec![0.0; dim];
728 v[2] = 1.0 + i as f64 * 0.02;
729 v[3] = 0.5;
730 embeddings.push(v);
731 categories.push("gamma".to_string());
732 }
733
734 let pipeline = SphereQLPipeline::new(PipelineInput {
735 categories: categories.clone(),
736 embeddings,
737 })
738 .unwrap();
739 (pipeline, categories)
740 }
741
742 fn get_positions(pipeline: &SphereQLPipeline) -> Vec<SphericalPoint> {
743 pipeline
744 .exported_points()
745 .iter()
746 .map(|p| SphericalPoint::new_unchecked(p.r, p.theta, p.phi))
747 .collect()
748 }
749
750 #[test]
751 fn antipodal_analysis_runs() {
752 let (pipeline, categories) = make_test_pipeline();
753 let positions = get_positions(&pipeline);
754 let reports = antipodal_analysis(pipeline.category_layer(), &positions, &categories, 0.5);
755 assert_eq!(reports.len(), 3);
756 for r in &reports {
757 assert!(!r.category_name.is_empty());
758 assert!(r.antipodal_coherence >= 0.0);
759 }
760 }
761
762 #[test]
763 fn coverage_report_valid() {
764 let (pipeline, _) = make_test_pipeline();
765 let report = knowledge_coverage(pipeline.category_layer(), 50_000);
766 assert!(report.coverage_fraction >= 0.0 && report.coverage_fraction <= 1.0);
767 assert_eq!(report.category_caps.len(), 3);
768 }
769
770 #[test]
771 fn gap_confidence_inside_vs_void() {
772 let (pipeline, _) = make_test_pipeline();
773 let layer = pipeline.category_layer();
774 let centroid = layer.summaries[0].centroid_position;
775 let ap = antipode(¢roid);
776 assert!(gap_confidence(¢roid, layer, 5.0) > gap_confidence(&ap, layer, 5.0));
777 }
778
779 #[test]
780 fn voronoi_report_valid() {
781 let (pipeline, _) = make_test_pipeline();
782 let report = voronoi_analysis(pipeline.category_layer(), 50_000);
783 assert_eq!(report.cells.len(), 3);
784 let total: f64 = report.cells.iter().map(|c| c.cell_area).sum();
785 assert!((total - 4.0 * std::f64::consts::PI).abs() < 1.0);
786 }
787
788 #[test]
789 fn overlap_report_valid() {
790 let (pipeline, _) = make_test_pipeline();
791 let report = overlap_analysis(pipeline.category_layer(), 20_000);
792 assert_eq!(report.exclusivities.len(), 3);
793 for e in &report.exclusivities {
794 assert!(e.exclusivity >= 0.0 && e.exclusivity <= 1.0);
795 }
796 }
797
798 #[test]
799 fn curvature_report_valid() {
800 let (pipeline, _) = make_test_pipeline();
801 let report = curvature_analysis(pipeline.category_layer(), 5);
802 assert_eq!(report.top_triples.len(), 1);
803 assert_eq!(report.signatures.len(), 3);
804 for sig in &report.signatures {
805 assert!(sig.mean_excess >= 0.0);
806 assert!(sig.relative_spread >= 0.0 && sig.relative_spread <= 1.0);
807 assert!(
808 sig.mean_excess_z.is_finite(),
809 "mean_excess_z must be finite, got {}",
810 sig.mean_excess_z
811 );
812 }
813 let z_sum: f64 = report.signatures.iter().map(|s| s.mean_excess_z).sum();
815 assert!(
816 z_sum.abs() < 1e-9,
817 "z-scores should sum to zero, got {z_sum}"
818 );
819 }
820
821 #[test]
822 fn lune_analysis_runs() {
823 let (pipeline, _) = make_test_pipeline();
824 let positions = get_positions(&pipeline);
825 let reports = lune_analysis(pipeline.category_layer(), &positions);
826 for r in &reports {
827 assert!(r.asymmetry >= 0.0 && r.asymmetry <= 1.0);
828 }
829 }
830
831 #[test]
832 fn full_analysis_runs() {
833 let (pipeline, categories) = make_test_pipeline();
834 let positions = get_positions(&pipeline);
835 let evr = pipeline.explained_variance_ratio();
836 let report = run_full_analysis(
837 pipeline.category_layer(),
838 &positions,
839 &categories,
840 evr,
841 &NavigatorConfig::default(),
842 );
843 assert_eq!(report.num_categories, 3);
844 assert_eq!(report.num_items, 30);
845 assert!(report.explained_variance_ratio > 0.0);
846 }
847}