Skip to main content

sphereql_embed/
navigator.rs

1//! AI Knowledge Navigator — semantic spatial queries on S².
2//!
3//! Wires the pure geometry primitives from `sphereql_core::spatial` to the
4//! [`CategoryLayer`] and [`crate::SphereQLPipeline`], giving every geometric query
5//! its semantic meaning.
6//!
7//! Each public struct/function maps to one of the 7 research areas:
8//! §1 Antipodal analysis, §2 Coverage & knowledge gaps, §3 Geodesic sweeps,
9//! §4 Voronoi tessellation, §5 Overlap & exclusivity, §6 Curvature signatures,
10//! §7 Lune decomposition.
11
12use std::collections::HashMap;
13
14use sphereql_core::spatial::*;
15use sphereql_core::{SphericalPoint, angular_distance};
16
17use crate::category::{BridgeItem, CategoryLayer};
18
19// ── §1: Antipodal Analysis ───────────────────────────────────────────
20
21#[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    /// > 1.0 = denser than chance (structured). < 1.0 = sparser (noise).
28    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// ── §2: Coverage & Knowledge Gap Cartography ───────────────────────────
101
102#[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(&centers, &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/// Gap-aware confidence: sigmoid falloff based on void_distance.
151#[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, &centers, &half_angles);
160    1.0 / (1.0 + (sharpness * vd).exp())
161}
162
163// ── §3: Geodesic Sweep Queries ───────────────────────────────────────
164
165#[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// ── §4: Voronoi Tessellation ───────────────────────────────────────
249
250#[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(&centroids, 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// ── §5: Overlap & Exclusivity Analysis ───────────────────────────────
324
325#[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(&centers, &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, &centers, &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// ── §6: Curvature Signatures ───────────────────────────────────────
405
406#[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    /// Z-score of `mean_excess` against the corpus-wide distribution of
425    /// per-category mean excesses. Positive means this category sits in
426    /// triangles that bow more than average; negative, less than average.
427    ///
428    /// Raw `mean_excess` values cluster tightly when centroids span the
429    /// sphere (everything has area ≈ 2π/3), making them poor for
430    /// comparison. The z-score amplifies the residual signal.
431    pub mean_excess_z: f64,
432    /// `(max_excess − min_excess)` divided by the global max excess. A
433    /// dimensionless measure of how varied this category's triangle
434    /// excesses are, robust to overall sphere scale.
435    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(&centroids[i], &centroids[j], &centroids[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    // First pass: raw stats per category.
469    let raw: Vec<(f64, f64, f64)> = (0..n)
470        .map(|target| {
471            let sig = curvature_signature(target, &centroids);
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    // Corpus-wide stats of per-category means → z-score basis.
482    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
516/// Population mean and standard deviation of an `f64` stream. Returns
517/// `(0.0, 0.0)` on empty input. Used by `curvature_analysis` to
518/// z-normalize per-category mean excesses against the corpus.
519fn 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// ── §7: Lune Decomposition ─────────────────────────────────────────
531
532#[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            // (i,j) bridges: source=i=A, target=j=B
564            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            // (j,i) bridges: source=j=B, target=i=A — home wins when source > target
572            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
629// ── Full Navigator Report ──────────────────────────────────────────
630
631pub 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(&centroid);
776        assert!(gap_confidence(&centroid, 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        // Population z-scores sum to zero by construction.
814        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}