Skip to main content

sphereql_embed/
confidence.rs

1//! Unified confidence scoring for query results.
2//!
3//! Combines three independent quality signals into a single score:
4//! - **EVR**: global projection quality
5//! - **Certainty**: per-point projection fidelity
6//! - **Gap confidence**: proximity to category caps (void = low confidence)
7
8use sphereql_core::SphericalPoint;
9
10/// Confidence assessment for a single query result or point on S².
11///
12/// `void_distance` is signed: negative = inside category coverage (high
13/// `gap_confidence`), positive = in a void (low `gap_confidence`). The
14/// sigmoid `gap_confidence = 1 / (1 + exp(sharpness · void_distance))`
15/// in [`Self::compute`] maps that sign convention onto [0, 1].
16#[derive(Debug, Clone, Copy)]
17pub struct QualitySignal {
18    pub evr: f64,
19    pub certainty: f64,
20    pub void_distance: f64,
21    pub gap_confidence: f64,
22    /// evr × certainty × gap_confidence
23    pub combined: f64,
24    pub level: ConfidenceLevel,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
28pub enum ConfidenceLevel {
29    Unreliable,
30    Low,
31    Moderate,
32    High,
33}
34
35impl std::fmt::Display for ConfidenceLevel {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::High => write!(f, "HIGH"),
39            Self::Moderate => write!(f, "MODERATE"),
40            Self::Low => write!(f, "LOW"),
41            Self::Unreliable => write!(f, "UNRELIABLE"),
42        }
43    }
44}
45
46impl QualitySignal {
47    pub fn compute(evr: f64, certainty: f64, void_dist: f64, sharpness: f64) -> Self {
48        let gap_confidence = 1.0 / (1.0 + (sharpness * void_dist).exp());
49        let combined = evr * certainty * gap_confidence;
50        let level = classify(combined);
51        Self {
52            evr,
53            certainty,
54            void_distance: void_dist,
55            gap_confidence,
56            combined,
57            level,
58        }
59    }
60
61    /// Simplified: no void distance available (e.g., raw k-NN results).
62    ///
63    /// With no void-distance signal, certainty stands in for gap
64    /// proximity: `√certainty` is a soft penalty (gentler than the
65    /// certainty factor itself), making `combined` effectively
66    /// `evr × certainty^1.5`. The 0.01 floor keeps a zero-certainty
67    /// point from zeroing out `combined` entirely, so EVR and
68    /// certainty differences still order results below the floor.
69    pub fn from_certainty(evr: f64, certainty: f64) -> Self {
70        let gap_confidence = certainty.sqrt().max(0.01);
71        let combined = evr * certainty * gap_confidence;
72        let level = classify(combined);
73        Self {
74            evr,
75            certainty,
76            void_distance: 0.0,
77            gap_confidence,
78            combined,
79            level,
80        }
81    }
82
83    pub fn passes_threshold(&self, min_combined: f64) -> bool {
84        self.combined >= min_combined
85    }
86}
87
88const CONFIDENCE_HIGH: f64 = 0.10;
89const CONFIDENCE_MODERATE: f64 = 0.03;
90const CONFIDENCE_LOW: f64 = 0.005;
91
92fn classify(combined: f64) -> ConfidenceLevel {
93    if combined > CONFIDENCE_HIGH {
94        ConfidenceLevel::High
95    } else if combined > CONFIDENCE_MODERATE {
96        ConfidenceLevel::Moderate
97    } else if combined > CONFIDENCE_LOW {
98        ConfidenceLevel::Low
99    } else {
100        ConfidenceLevel::Unreliable
101    }
102}
103
104/// Full quality signal using centroids and half-angles for void distance.
105pub fn point_quality(
106    evr: f64,
107    certainty: f64,
108    position: &SphericalPoint,
109    centroids: &[SphericalPoint],
110    half_angles: &[f64],
111    sharpness: f64,
112) -> QualitySignal {
113    let void_dist = sphereql_core::spatial::void_distance(position, centroids, half_angles);
114    QualitySignal::compute(evr, certainty, void_dist, sharpness)
115}
116
117/// Configuration for quality-based filtering.
118#[derive(Debug, Clone)]
119pub struct QualityConfig {
120    /// Minimum per-point certainty. Default: 0.0 (no filtering).
121    pub min_certainty: f64,
122    /// Minimum combined confidence. Default: 0.0 (no filtering).
123    pub min_combined: f64,
124    /// Sigmoid sharpness for gap confidence. Default: 5.0.
125    pub gap_sharpness: f64,
126    /// EVR threshold for projection warnings. Default: 0.35.
127    pub warn_below_evr: f64,
128}
129
130impl Default for QualityConfig {
131    fn default() -> Self {
132        Self {
133            min_certainty: 0.0,
134            min_combined: 0.0,
135            gap_sharpness: 5.0,
136            warn_below_evr: 0.35,
137        }
138    }
139}
140
141/// A structured warning about projection quality.
142#[derive(Debug, Clone)]
143pub struct ProjectionWarning {
144    pub message: String,
145    pub evr: f64,
146    pub severity: WarningSeverity,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum WarningSeverity {
151    Info,
152    Warning,
153    Critical,
154}
155
156impl ProjectionWarning {
157    pub fn from_evr(evr: f64, threshold: f64) -> Option<Self> {
158        if evr >= threshold {
159            return None;
160        }
161        let (message, severity) = if evr < 0.15 {
162            (
163                format!(
164                    "EVR={:.1}% \u{2014} projection captures very little variance. \
165                 Category routing and bridges are unreliable. Use inner spheres.",
166                    evr * 100.0
167                ),
168                WarningSeverity::Critical,
169            )
170        } else if evr < 0.25 {
171            (
172                format!(
173                    "EVR={:.1}% \u{2014} projection is lossy. Bridge counts may be inflated. \
174                 Certainty-weighted results recommended.",
175                    evr * 100.0
176                ),
177                WarningSeverity::Warning,
178            )
179        } else {
180            (
181                format!(
182                    "EVR={:.1}% \u{2014} below recommended {:.0}%. Results usable with caution.",
183                    evr * 100.0,
184                    threshold * 100.0
185                ),
186                WarningSeverity::Info,
187            )
188        };
189        Some(Self {
190            message,
191            evr,
192            severity,
193        })
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn high_confidence_all_good() {
203        let sig = QualitySignal::compute(0.6, 0.8, -0.5, 5.0);
204        assert_eq!(sig.level, ConfidenceLevel::High);
205        assert!(sig.combined > 0.10);
206    }
207
208    #[test]
209    fn low_certainty_kills_confidence() {
210        let sig = QualitySignal::compute(0.6, 0.007, -0.5, 5.0);
211        assert!(sig.combined < 0.01);
212    }
213
214    #[test]
215    fn void_kills_confidence() {
216        let sig = QualitySignal::compute(0.6, 0.8, 1.0, 5.0);
217        assert!(sig.gap_confidence < 0.01);
218        assert_eq!(sig.level, ConfidenceLevel::Unreliable);
219    }
220
221    #[test]
222    fn low_evr_reduces_confidence() {
223        let good = QualitySignal::compute(0.6, 0.5, -0.3, 5.0);
224        let bad = QualitySignal::compute(0.19, 0.5, -0.3, 5.0);
225        assert!(good.combined > bad.combined);
226    }
227
228    #[test]
229    fn from_certainty_fallback() {
230        let sig = QualitySignal::from_certainty(0.5, 0.3);
231        assert!(sig.combined > 0.0);
232        assert_eq!(sig.void_distance, 0.0);
233    }
234
235    #[test]
236    fn threshold_filtering() {
237        let sig = QualitySignal::compute(0.19, 0.26, -0.2, 5.0);
238        assert!(sig.passes_threshold(0.0));
239        assert!(sig.passes_threshold(0.01));
240    }
241
242    #[test]
243    fn warning_at_low_evr() {
244        let w = ProjectionWarning::from_evr(0.19, 0.35).unwrap();
245        assert_eq!(w.severity, WarningSeverity::Warning);
246    }
247
248    #[test]
249    fn no_warning_at_high_evr() {
250        assert!(ProjectionWarning::from_evr(0.60, 0.35).is_none());
251    }
252
253    #[test]
254    fn critical_at_very_low_evr() {
255        let w = ProjectionWarning::from_evr(0.10, 0.35).unwrap();
256        assert_eq!(w.severity, WarningSeverity::Critical);
257    }
258
259    #[test]
260    fn confidence_levels_ordered() {
261        assert!(ConfidenceLevel::High > ConfidenceLevel::Moderate);
262        assert!(ConfidenceLevel::Moderate > ConfidenceLevel::Low);
263        assert!(ConfidenceLevel::Low > ConfidenceLevel::Unreliable);
264    }
265}