Skip to main content

ries_rs/
stability.rs

1//! Stability analysis for impostor detection
2//!
3//! Runs search at multiple precision/tolerance levels and classifies
4//! candidates by their stability across runs.
5
6use crate::search::Match;
7use std::collections::HashMap;
8
9/// Stability classification for a match
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum StabilityClass {
12    /// Appears at all tolerance levels - very likely the true formula
13    Stable,
14    /// Appears at most levels but not all
15    ModeratelyStable,
16    /// Only appears at loose tolerance - likely an impostor
17    Fragile,
18    /// Appears at tight tolerance but not loose (rare, indicates numeric issues)
19    Anomalous,
20}
21
22impl StabilityClass {
23    /// Get display name
24    pub fn name(&self) -> &'static str {
25        match self {
26            StabilityClass::Stable => "stable",
27            StabilityClass::ModeratelyStable => "moderate",
28            StabilityClass::Fragile => "fragile",
29            StabilityClass::Anomalous => "anomalous",
30        }
31    }
32
33    /// Get description
34    pub fn description(&self) -> &'static str {
35        match self {
36            StabilityClass::Stable => "Persists at all precision levels",
37            StabilityClass::ModeratelyStable => "Persists at most precision levels",
38            StabilityClass::Fragile => "Only appears at low precision (impostor)",
39            StabilityClass::Anomalous => "Anomalous stability pattern",
40        }
41    }
42}
43
44/// Result of stability analysis
45#[derive(Debug, Clone)]
46pub struct StabilityResult {
47    /// The match being analyzed
48    pub match_: Match,
49    /// Stability classification
50    pub class: StabilityClass,
51    /// Number of levels where this match appeared
52    pub appearance_count: usize,
53    /// Total number of levels checked
54    pub total_levels: usize,
55    /// Stability score (0.0 - 1.0, higher is more stable)
56    pub score: f64,
57}
58
59/// Key for matching expressions across runs
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61struct ExprKey {
62    lhs: String,
63    rhs: String,
64}
65
66impl ExprKey {
67    fn from_match(m: &Match) -> Self {
68        Self {
69            lhs: m.lhs.expr.to_postfix(),
70            rhs: m.rhs.expr.to_postfix(),
71        }
72    }
73}
74
75/// Stability ladder configuration
76#[derive(Debug, Clone)]
77pub struct StabilityConfig {
78    /// Error tolerance multipliers for each level
79    /// Level 0 is loosest, higher levels are tighter
80    pub tolerance_factors: Vec<f64>,
81    /// Minimum appearance ratio for "stable" classification
82    pub stable_threshold: f64,
83    /// Minimum appearance ratio for "moderately stable" classification
84    pub moderate_threshold: f64,
85}
86
87impl Default for StabilityConfig {
88    fn default() -> Self {
89        Self {
90            // Run at 100%, 10%, 1%, 0.1%, 0.01% of base error tolerance
91            tolerance_factors: vec![1.0, 0.1, 0.01, 0.001, 0.0001],
92            stable_threshold: 0.8,   // Appear at 80%+ of levels
93            moderate_threshold: 0.5, // Appear at 50-80% of levels
94        }
95    }
96}
97
98impl StabilityConfig {
99    /// Create a quick stability config (fewer levels)
100    pub fn quick() -> Self {
101        Self {
102            tolerance_factors: vec![1.0, 0.01, 0.0001],
103            stable_threshold: 0.67,
104            moderate_threshold: 0.34,
105        }
106    }
107
108    /// Create a thorough stability config (more levels)
109    pub fn thorough() -> Self {
110        Self {
111            tolerance_factors: vec![1.0, 0.5, 0.1, 0.05, 0.01, 0.005, 0.001, 0.0001],
112            stable_threshold: 0.75,
113            moderate_threshold: 0.5,
114        }
115    }
116}
117
118/// Analyze stability of matches across multiple runs
119pub struct StabilityAnalyzer {
120    config: StabilityConfig,
121    /// Matches from each level, keyed by expression
122    levels: Vec<HashMap<ExprKey, Match>>,
123}
124
125impl StabilityAnalyzer {
126    /// Create a new stability analyzer
127    pub fn new(config: StabilityConfig) -> Self {
128        Self {
129            config,
130            levels: Vec::new(),
131        }
132    }
133
134    /// Add matches from a run at a specific tolerance level
135    pub fn add_level(&mut self, matches: Vec<Match>) {
136        let mut level_map = HashMap::new();
137        for m in matches {
138            let key = ExprKey::from_match(&m);
139            level_map.insert(key, m);
140        }
141        self.levels.push(level_map);
142    }
143
144    /// Get the number of levels analyzed
145    pub fn level_count(&self) -> usize {
146        self.levels.len()
147    }
148
149    /// Analyze all matches and return stability results
150    pub fn analyze(&self) -> Vec<StabilityResult> {
151        let total_levels = self.levels.len();
152        if total_levels == 0 {
153            return Vec::new();
154        }
155
156        // Track all unique expressions and their appearance count
157        let mut appearance_counts: HashMap<ExprKey, usize> = HashMap::new();
158        let mut best_matches: HashMap<ExprKey, Match> = HashMap::new();
159
160        for level in &self.levels {
161            for (key, m) in level {
162                *appearance_counts.entry(key.clone()).or_insert(0) += 1;
163                // Keep the match from the tightest tolerance (last occurrence)
164                best_matches.insert(key.clone(), m.clone());
165            }
166        }
167
168        // Convert to results
169        let mut results: Vec<StabilityResult> = appearance_counts
170            .into_iter()
171            .map(|(key, count)| {
172                let match_ = best_matches.remove(&key).unwrap();
173                let ratio = count as f64 / total_levels as f64;
174                let class = if ratio >= self.config.stable_threshold {
175                    StabilityClass::Stable
176                } else if ratio >= self.config.moderate_threshold {
177                    StabilityClass::ModeratelyStable
178                } else if count == 1 {
179                    // Only appeared once
180                    if self.is_from_loose_level(&key) {
181                        StabilityClass::Fragile
182                    } else {
183                        StabilityClass::Anomalous
184                    }
185                } else {
186                    StabilityClass::Fragile
187                };
188
189                StabilityResult {
190                    match_,
191                    class,
192                    appearance_count: count,
193                    total_levels,
194                    score: ratio,
195                }
196            })
197            .collect();
198
199        // Sort by stability score (descending), then by error (ascending)
200        results.sort_by(|a, b| {
201            b.score
202                .partial_cmp(&a.score)
203                .unwrap_or(std::cmp::Ordering::Equal)
204                .then_with(|| {
205                    a.match_
206                        .error
207                        .abs()
208                        .partial_cmp(&b.match_.error.abs())
209                        .unwrap_or(std::cmp::Ordering::Equal)
210                })
211        });
212
213        results
214    }
215
216    /// Check if a match only appeared in the loosest level
217    fn is_from_loose_level(&self, key: &ExprKey) -> bool {
218        if self.levels.is_empty() {
219            return false;
220        }
221        // Only in first level
222        self.levels[0].contains_key(key) && !self.levels.iter().skip(1).any(|l| l.contains_key(key))
223    }
224}
225
226/// Format stability results for display
227pub fn format_stability_report(results: &[StabilityResult], max_display: usize) -> String {
228    let mut output = String::new();
229
230    // Group by stability class
231    let stable: Vec<_> = results
232        .iter()
233        .filter(|r| r.class == StabilityClass::Stable)
234        .take(max_display)
235        .collect();
236    let moderate: Vec<_> = results
237        .iter()
238        .filter(|r| r.class == StabilityClass::ModeratelyStable)
239        .take(max_display)
240        .collect();
241    let fragile: Vec<_> = results
242        .iter()
243        .filter(|r| r.class == StabilityClass::Fragile)
244        .take(max_display)
245        .collect();
246
247    if !stable.is_empty() {
248        output.push_str("\n  -- Stable formulas (high confidence) --\n\n");
249        for r in &stable {
250            output.push_str(&format!(
251                "  {:<24} = {:<24}  [{}/{} levels] {{{}}}\n",
252                r.match_.lhs.expr.to_infix(),
253                r.match_.rhs.expr.to_infix(),
254                r.appearance_count,
255                r.total_levels,
256                r.match_.complexity
257            ));
258        }
259    }
260
261    if !moderate.is_empty() {
262        output.push_str("\n  -- Moderately stable (medium confidence) --\n\n");
263        for r in &moderate {
264            output.push_str(&format!(
265                "  {:<24} = {:<24}  [{}/{} levels] {{{}}}\n",
266                r.match_.lhs.expr.to_infix(),
267                r.match_.rhs.expr.to_infix(),
268                r.appearance_count,
269                r.total_levels,
270                r.match_.complexity
271            ));
272        }
273    }
274
275    if !fragile.is_empty() {
276        output.push_str("\n  -- Fragile (likely impostors) --\n\n");
277        for r in &fragile {
278            output.push_str(&format!(
279                "  {:<24} = {:<24}  [{}/{} levels] {{{}}}\n",
280                r.match_.lhs.expr.to_infix(),
281                r.match_.rhs.expr.to_infix(),
282                r.appearance_count,
283                r.total_levels,
284                r.match_.complexity
285            ));
286        }
287    }
288
289    output
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::expr::{EvaluatedExpr, Expression};
296    use crate::symbol::NumType;
297
298    fn make_test_match(lhs: &str, rhs: &str, error: f64) -> Match {
299        let lhs_expr = Expression::parse(lhs).unwrap();
300        let rhs_expr = Expression::parse(rhs).unwrap();
301        Match {
302            lhs: EvaluatedExpr::new(lhs_expr.clone(), 0.0, 1.0, NumType::Integer),
303            rhs: EvaluatedExpr::new(rhs_expr.clone(), 0.0, 0.0, NumType::Integer),
304            x_value: 2.5,
305            error,
306            complexity: lhs_expr.complexity() + rhs_expr.complexity(),
307        }
308    }
309
310    #[test]
311    fn test_stability_classification() {
312        let mut analyzer = StabilityAnalyzer::new(StabilityConfig::default());
313
314        // Level 0: Both matches appear
315        analyzer.add_level(vec![
316            make_test_match("x", "5", 0.01),
317            make_test_match("2x*", "5", 0.001),
318        ]);
319
320        // Level 1: Only first match appears
321        analyzer.add_level(vec![make_test_match("x", "5", 0.001)]);
322
323        // Level 2: Only first match appears
324        analyzer.add_level(vec![make_test_match("x", "5", 0.0001)]);
325
326        let results = analyzer.analyze();
327        assert_eq!(results.len(), 2);
328
329        // First match is stable (appears at 3/3 levels)
330        let stable = results
331            .iter()
332            .find(|r| r.match_.lhs.expr.to_postfix() == "x");
333        assert!(stable.is_some());
334        assert_eq!(stable.unwrap().class, StabilityClass::Stable);
335        assert_eq!(stable.unwrap().appearance_count, 3);
336
337        // Second match is fragile (appears at 1/3 levels)
338        let fragile = results
339            .iter()
340            .find(|r| r.match_.lhs.expr.to_postfix() == "2x*");
341        assert!(fragile.is_some());
342        assert_eq!(fragile.unwrap().class, StabilityClass::Fragile);
343        assert_eq!(fragile.unwrap().appearance_count, 1);
344    }
345
346    #[test]
347    fn test_empty_analyzer() {
348        let analyzer = StabilityAnalyzer::new(StabilityConfig::default());
349        assert_eq!(analyzer.analyze().len(), 0);
350    }
351}