1use sphereql_core::SphericalPoint;
9
10#[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 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 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
104pub 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#[derive(Debug, Clone)]
119pub struct QualityConfig {
120 pub min_certainty: f64,
122 pub min_combined: f64,
124 pub gap_sharpness: f64,
126 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#[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}