Skip to main content

sphereql_embed/
configured_projection.rs

1//! [`ConfiguredProjection`] — a single concrete type over all supported
2//! outer-sphere projection families.
3//!
4//! The pipeline, the spatial index, and the category layer all want one
5//! concrete `Projection` type — not a trait object. This enum unifies
6//! [`PcaProjection`], [`KernelPcaProjection`], and
7//! [`LaplacianEigenmapProjection`] so the pipeline can dispatch uniformly
8//! while each trial of the auto-tuner can swap in a different family
9//! without touching generics.
10//!
11//! Adding a new projection family = one variant here + one match arm in
12//! the `Projection` impl and the inherent helpers.
13
14use sphereql_core::SphericalPoint;
15
16use crate::config::ProjectionKind;
17use crate::kernel_pca::KernelPcaProjection;
18use crate::laplacian::LaplacianEigenmapProjection;
19use crate::projection::{PcaProjection, Projection};
20use crate::types::{Embedding, ProjectedPoint};
21
22/// A projection chosen at pipeline build time.
23///
24/// Implements [`Projection`] directly so `EmbeddingIndex<ConfiguredProjection>`,
25/// `CategoryLayer::build_with_config`, and every other `Projection`-generic
26/// API continues to work without changes.
27#[derive(Clone)]
28pub enum ConfiguredProjection {
29    Pca(PcaProjection),
30    KernelPca(KernelPcaProjection),
31    Laplacian(LaplacianEigenmapProjection),
32}
33
34impl std::fmt::Debug for ConfiguredProjection {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Pca(_) => write!(f, "ConfiguredProjection::Pca"),
38            Self::KernelPca(_) => write!(f, "ConfiguredProjection::KernelPca"),
39            Self::Laplacian(_) => write!(f, "ConfiguredProjection::Laplacian"),
40        }
41    }
42}
43
44impl Projection for ConfiguredProjection {
45    fn project(&self, embedding: &Embedding) -> SphericalPoint {
46        match self {
47            Self::Pca(p) => p.project(embedding),
48            Self::KernelPca(p) => p.project(embedding),
49            Self::Laplacian(p) => p.project(embedding),
50        }
51    }
52
53    fn project_rich(&self, embedding: &Embedding) -> ProjectedPoint {
54        match self {
55            Self::Pca(p) => p.project_rich(embedding),
56            Self::KernelPca(p) => p.project_rich(embedding),
57            Self::Laplacian(p) => p.project_rich(embedding),
58        }
59    }
60
61    fn dimensionality(&self) -> usize {
62        match self {
63            Self::Pca(p) => p.dimensionality(),
64            Self::KernelPca(p) => p.dimensionality(),
65            Self::Laplacian(p) => p.dimensionality(),
66        }
67    }
68}
69
70impl ConfiguredProjection {
71    /// Which projection family is active.
72    pub fn kind(&self) -> ProjectionKind {
73        match self {
74            Self::Pca(_) => ProjectionKind::Pca,
75            Self::KernelPca(_) => ProjectionKind::KernelPca,
76            Self::Laplacian(_) => ProjectionKind::LaplacianEigenmap,
77        }
78    }
79
80    /// Scalar projection-quality proxy, analogous to PCA's explained
81    /// variance ratio. For non-PCA variants this returns the kind's
82    /// native quality metric (Kernel PCA EVR, Laplacian connectivity
83    /// ratio) — all bounded in `[0, 1]` so downstream EVR-adaptive
84    /// thresholds stay well-defined.
85    pub fn explained_variance_ratio(&self) -> f64 {
86        match self {
87            Self::Pca(p) => p.explained_variance_ratio(),
88            Self::KernelPca(p) => p.explained_variance_ratio(),
89            Self::Laplacian(p) => p.explained_variance_ratio(),
90        }
91    }
92
93    /// Borrow the inner [`PcaProjection`] if that is the active variant.
94    pub fn as_pca(&self) -> Option<&PcaProjection> {
95        match self {
96            Self::Pca(p) => Some(p),
97            _ => None,
98        }
99    }
100
101    /// Borrow the inner [`KernelPcaProjection`] if that is the active variant.
102    pub fn as_kernel_pca(&self) -> Option<&KernelPcaProjection> {
103        match self {
104            Self::KernelPca(p) => Some(p),
105            _ => None,
106        }
107    }
108
109    /// Borrow the inner [`LaplacianEigenmapProjection`] if that is the
110    /// active variant.
111    pub fn as_laplacian(&self) -> Option<&LaplacianEigenmapProjection> {
112        match self {
113            Self::Laplacian(p) => Some(p),
114            _ => None,
115        }
116    }
117}
118
119impl From<PcaProjection> for ConfiguredProjection {
120    fn from(p: PcaProjection) -> Self {
121        Self::Pca(p)
122    }
123}
124
125impl From<KernelPcaProjection> for ConfiguredProjection {
126    fn from(p: KernelPcaProjection) -> Self {
127        Self::KernelPca(p)
128    }
129}
130
131impl From<LaplacianEigenmapProjection> for ConfiguredProjection {
132    fn from(p: LaplacianEigenmapProjection) -> Self {
133        Self::Laplacian(p)
134    }
135}
136
137// ── Tests ──────────────────────────────────────────────────────────────
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::types::RadialStrategy;
143
144    fn emb(vals: &[f64]) -> Embedding {
145        Embedding::new(vals.to_vec())
146    }
147
148    fn toy_corpus() -> Vec<Embedding> {
149        (0..8)
150            .map(|i| {
151                let t = i as f64;
152                emb(&[1.0 + t * 0.01, 0.5 - t * 0.01, 0.2, 0.05, 0.03])
153            })
154            .collect()
155    }
156
157    #[test]
158    fn pca_variant_dispatches() {
159        let corpus = toy_corpus();
160        let pca = PcaProjection::fit(&corpus, RadialStrategy::Fixed(1.0)).unwrap();
161        let cp: ConfiguredProjection = pca.into();
162        assert_eq!(cp.kind(), ProjectionKind::Pca);
163        assert_eq!(cp.dimensionality(), 5);
164        let sp = cp.project(&corpus[0]);
165        assert!((sp.r - 1.0).abs() < 1e-9);
166        assert!(cp.as_pca().is_some());
167        assert!(cp.as_kernel_pca().is_none());
168        assert!(cp.as_laplacian().is_none());
169    }
170
171    #[test]
172    fn kernel_pca_variant_dispatches() {
173        let corpus = toy_corpus();
174        let kpca = KernelPcaProjection::fit(&corpus, RadialStrategy::Fixed(1.0)).unwrap();
175        let cp: ConfiguredProjection = kpca.into();
176        assert_eq!(cp.kind(), ProjectionKind::KernelPca);
177        assert_eq!(cp.dimensionality(), 5);
178        assert!(cp.as_kernel_pca().is_some());
179        assert!(cp.as_pca().is_none());
180    }
181
182    #[test]
183    fn laplacian_variant_dispatches() {
184        // Laplacian needs ≥4 embeddings and at least one active axis per
185        // point — the toy corpus above qualifies.
186        let corpus = toy_corpus();
187        let lap = LaplacianEigenmapProjection::fit(&corpus, RadialStrategy::Fixed(1.0)).unwrap();
188        let cp: ConfiguredProjection = lap.into();
189        assert_eq!(cp.kind(), ProjectionKind::LaplacianEigenmap);
190        assert_eq!(cp.dimensionality(), 5);
191        assert!(cp.as_laplacian().is_some());
192        assert!(cp.as_pca().is_none());
193    }
194
195    #[test]
196    fn explained_variance_ratio_in_range_for_every_variant() {
197        let corpus = toy_corpus();
198        let pca: ConfiguredProjection = PcaProjection::fit(&corpus, RadialStrategy::Fixed(1.0))
199            .unwrap()
200            .into();
201        let kpca: ConfiguredProjection =
202            KernelPcaProjection::fit(&corpus, RadialStrategy::Fixed(1.0))
203                .unwrap()
204                .into();
205        let lap: ConfiguredProjection =
206            LaplacianEigenmapProjection::fit(&corpus, RadialStrategy::Fixed(1.0))
207                .unwrap()
208                .into();
209        for cp in &[pca, kpca, lap] {
210            let r = cp.explained_variance_ratio();
211            assert!((0.0..=1.0).contains(&r), "{:?}: {r}", cp);
212        }
213    }
214
215    #[test]
216    fn debug_formats_kind_not_inner() {
217        let corpus = toy_corpus();
218        let pca: ConfiguredProjection = PcaProjection::fit(&corpus, RadialStrategy::Fixed(1.0))
219            .unwrap()
220            .into();
221        assert_eq!(format!("{:?}", pca), "ConfiguredProjection::Pca");
222    }
223}