Skip to main content

brainwires_eval/
fault_report.rs

1//! Fault classification for eval-driven autonomous self-improvement.
2//!
3//! [`analyze_suite_for_faults`] inspects a completed [`SuiteResult`] and
4//! classifies per-case issues into [`FaultReport`]s sorted by priority.
5//! The `suggested_task_description` on each report is ready to pass directly
6//! to a `SelfImprovementController` (from `brainwires-autonomy`) as a task
7//! description.
8//!
9//! # Classification precedence (first match wins per case)
10//!
11//! 1. **Regression** — a baseline exists and `current < baseline − 0.03`.
12//! 2. **ConsistentFailure** — `success_rate < consistent_failure_threshold`.
13//! 3. **Flaky** — CI width > `flaky_ci_threshold`.
14//! 4. **NewCapability** — no baseline recorded yet and `success_rate ≥ 0.8`
15//!    (capture it before a future regression goes unnoticed).
16
17use super::regression::RegressionSuite;
18use super::suite::SuiteResult;
19
20// ── FaultKind ─────────────────────────────────────────────────────────────────
21
22/// The classification of a detected eval fault.
23#[derive(Debug, Clone)]
24pub enum FaultKind {
25    /// A previously-passing case dropped in success rate below the regression
26    /// tolerance.
27    Regression {
28        /// The baseline success rate before the regression.
29        previous_rate: f64,
30        /// The current success rate after the regression.
31        current_rate: f64,
32        /// The absolute drop in success rate (`previous - current`).
33        drop: f64,
34    },
35    /// A new case (no baseline exists) showing stable high success — worth
36    /// capturing as a baseline before future regressions sneak in.
37    NewCapability {
38        /// Human-readable description of the new capability.
39        description: String,
40    },
41    /// Success rate is below the minimum acceptable threshold regardless of
42    /// any stored baseline.
43    ConsistentFailure {
44        /// The observed success rate.
45        success_rate: f64,
46    },
47    /// Wide confidence interval — the case result is highly non-deterministic.
48    Flaky {
49        /// Mean success rate across trials.
50        mean_rate: f64,
51        /// Width of the 95% confidence interval.
52        ci_width: f64,
53    },
54}
55
56impl FaultKind {
57    /// Scheduling priority (higher = more urgent, max 10).
58    ///
59    /// | Variant | Priority |
60    /// |---------|----------|
61    /// | `Regression` | 1–10 scaled by drop % (1 pp → 1 pt, capped at 10) |
62    /// | `ConsistentFailure` | 8 |
63    /// | `NewCapability` | 5 |
64    /// | `Flaky` | 4 |
65    pub fn priority(&self) -> u8 {
66        match self {
67            FaultKind::Regression { drop, .. } => {
68                let scaled = (*drop * 100.0).round() as u8;
69                scaled.clamp(1, 10)
70            }
71            FaultKind::ConsistentFailure { .. } => 8,
72            FaultKind::NewCapability { .. } => 5,
73            FaultKind::Flaky { .. } => 4,
74        }
75    }
76
77    /// Short label for use in reports and task IDs.
78    pub fn label(&self) -> &'static str {
79        match self {
80            FaultKind::Regression { .. } => "regression",
81            FaultKind::NewCapability { .. } => "new_capability",
82            FaultKind::ConsistentFailure { .. } => "consistent_failure",
83            FaultKind::Flaky { .. } => "flaky",
84        }
85    }
86}
87
88// ── FaultReport ───────────────────────────────────────────────────────────────
89
90/// A classified eval fault ready for self-improvement task generation.
91#[derive(Debug, Clone)]
92pub struct FaultReport {
93    /// Eval case name (matches [`EvaluationCase::name`](crate::case::EvaluationCase::name)).
94    pub case_name: String,
95    /// Category from [`EvaluationCase::category`](crate::case::EvaluationCase::category), or the case name if unknown.
96    pub category: String,
97    /// Classification and relevant rates.
98    pub fault_kind: FaultKind,
99    /// Up to 3 sample error strings from failed trials.
100    pub sample_errors: Vec<String>,
101    /// Number of failed trials in the current run.
102    pub n_failures: usize,
103    /// Total trials in the current run.
104    pub n_trials: usize,
105    /// Human-readable task description for the self-improvement controller.
106    pub suggested_task_description: String,
107}
108
109impl FaultReport {
110    /// Construct a regression fault.
111    pub fn regression(
112        case_name: impl Into<String>,
113        category: impl Into<String>,
114        previous_rate: f64,
115        current_rate: f64,
116        sample_errors: Vec<String>,
117        n_failures: usize,
118        n_trials: usize,
119    ) -> Self {
120        let drop = (previous_rate - current_rate).max(0.0);
121        let cn = case_name.into();
122        let suggested = format!(
123            "Fix regression in eval case '{}': success rate dropped from {:.0}% to {:.0}% \
124             (drop: {:.0}%). Investigate recent changes and restore reliability.",
125            cn,
126            previous_rate * 100.0,
127            current_rate * 100.0,
128            drop * 100.0,
129        );
130        Self {
131            case_name: cn,
132            category: category.into(),
133            fault_kind: FaultKind::Regression {
134                previous_rate,
135                current_rate,
136                drop,
137            },
138            sample_errors,
139            n_failures,
140            n_trials,
141            suggested_task_description: suggested,
142        }
143    }
144
145    /// Construct a new-capability fault (no prior baseline recorded).
146    pub fn new_capability(
147        case_name: impl Into<String>,
148        category: impl Into<String>,
149        description: impl Into<String>,
150        success_rate: f64,
151        n_failures: usize,
152        n_trials: usize,
153    ) -> Self {
154        let cn = case_name.into();
155        let desc = description.into();
156        let suggested = format!(
157            "Record baseline for newly-observed eval case '{}' ({:.0}% success rate). \
158             Add documentation and verify the capability is tested consistently.",
159            cn,
160            success_rate * 100.0,
161        );
162        Self {
163            case_name: cn,
164            category: category.into(),
165            fault_kind: FaultKind::NewCapability { description: desc },
166            sample_errors: Vec::new(),
167            n_failures,
168            n_trials,
169            suggested_task_description: suggested,
170        }
171    }
172
173    /// Priority derived from the fault kind (delegates to [`FaultKind::priority`]).
174    pub fn priority(&self) -> u8 {
175        self.fault_kind.priority()
176    }
177}
178
179// ── analyze_suite_for_faults ──────────────────────────────────────────────────
180
181/// Inspect a [`SuiteResult`] and return classified [`FaultReport`]s.
182///
183/// **Classification precedence** (first match wins per case):
184///
185/// 1. **Regression** — baseline exists _and_ `current < baseline − 0.03`.
186/// 2. **ConsistentFailure** — `success_rate < consistent_failure_threshold`.
187/// 3. **Flaky** — CI width > `flaky_ci_threshold`.
188/// 4. **NewCapability** — no baseline recorded yet, `success_rate ≥ 0.8`.
189///
190/// The returned `Vec` is sorted by [`FaultReport::priority`] descending.
191///
192/// # Parameters
193///
194/// | Parameter | Default | Meaning |
195/// |-----------|---------|---------|
196/// | `consistent_failure_threshold` | 0.2 | Success rates below this are always a fault |
197/// | `flaky_ci_threshold` | 0.25 | CI widths above this indicate high variance |
198pub fn analyze_suite_for_faults(
199    suite_result: &SuiteResult,
200    regression_suite: Option<&RegressionSuite>,
201    consistent_failure_threshold: f64,
202    flaky_ci_threshold: f64,
203) -> Vec<FaultReport> {
204    let mut reports: Vec<FaultReport> = Vec::new();
205
206    for (case_name, stats) in &suite_result.stats {
207        let n_trials = stats.n_trials;
208        let n_failures = n_trials - stats.successes;
209        let success_rate = stats.success_rate;
210        let ci_width = stats.confidence_interval_95.upper - stats.confidence_interval_95.lower;
211
212        // Sample up to 3 error messages from failed trials.
213        let sample_errors: Vec<String> = suite_result
214            .case_results
215            .get(case_name)
216            .map(|trials| {
217                trials
218                    .iter()
219                    .filter_map(|t| t.error.clone())
220                    .take(3)
221                    .collect()
222            })
223            .unwrap_or_default();
224
225        // Look up any stored baseline for this case.
226        let baseline = regression_suite.and_then(|rs| rs.get_baseline(case_name));
227
228        // 1. Regression.
229        if let Some(b) = baseline {
230            let drop = b.baseline_success_rate - success_rate;
231            if drop > 0.03 {
232                reports.push(FaultReport::regression(
233                    case_name,
234                    case_name,
235                    b.baseline_success_rate,
236                    success_rate,
237                    sample_errors,
238                    n_failures,
239                    n_trials,
240                ));
241                continue;
242            }
243        }
244
245        // 2. Consistent failure.
246        if success_rate < consistent_failure_threshold {
247            let suggested = format!(
248                "Fix consistently failing eval case '{}' (success rate: {:.0}%). \
249                 Review the implementation and ensure the evaluated functionality \
250                 works correctly.",
251                case_name,
252                success_rate * 100.0,
253            );
254            reports.push(FaultReport {
255                case_name: case_name.clone(),
256                category: case_name.clone(),
257                fault_kind: FaultKind::ConsistentFailure { success_rate },
258                sample_errors,
259                n_failures,
260                n_trials,
261                suggested_task_description: suggested,
262            });
263            continue;
264        }
265
266        // 3. Flaky (wide CI → high variance).
267        // Require at least one failure: zero-failure runs can't be flaky by
268        // definition, and small-N all-pass runs would otherwise produce
269        // spuriously wide Wilson CIs.
270        if n_failures > 0 && ci_width > flaky_ci_threshold {
271            let suggested = format!(
272                "Stabilize flaky eval case '{}' (mean success: {:.0}%, CI width: {:.2}). \
273                 Investigate sources of non-determinism and improve consistency.",
274                case_name,
275                success_rate * 100.0,
276                ci_width,
277            );
278            reports.push(FaultReport {
279                case_name: case_name.clone(),
280                category: case_name.clone(),
281                fault_kind: FaultKind::Flaky {
282                    mean_rate: success_rate,
283                    ci_width,
284                },
285                sample_errors,
286                n_failures,
287                n_trials,
288                suggested_task_description: suggested,
289            });
290            continue;
291        }
292
293        // 4. New capability (regression suite present but no baseline for this case).
294        if baseline.is_none() && regression_suite.is_some() && success_rate >= 0.8 {
295            reports.push(FaultReport::new_capability(
296                case_name,
297                case_name,
298                format!(
299                    "New eval case '{}' achieving {:.0}% success — baseline not yet recorded",
300                    case_name,
301                    success_rate * 100.0,
302                ),
303                success_rate,
304                n_failures,
305                n_trials,
306            ));
307        }
308    }
309
310    // Sort by priority descending.
311    reports.sort_by_key(|b| std::cmp::Reverse(b.priority()));
312    reports
313}
314
315// ── Tests ─────────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::regression::RegressionSuite;
321    use crate::suite::SuiteResult;
322    use crate::trial::{EvaluationStats, TrialResult};
323    use std::collections::HashMap;
324
325    fn make_suite_result(case_name: &str, successes: usize, total: usize) -> SuiteResult {
326        let trials: Vec<TrialResult> = (0..total)
327            .map(|i| {
328                if i < successes {
329                    TrialResult::success(i, 1)
330                } else {
331                    TrialResult::failure(i, 1, format!("error_{i}"))
332                }
333            })
334            .collect();
335        let stats = EvaluationStats::from_trials(&trials).unwrap();
336        SuiteResult {
337            case_results: HashMap::from([(case_name.to_string(), trials)]),
338            stats: HashMap::from([(case_name.to_string(), stats)]),
339        }
340    }
341
342    // ── FaultKind priority ─────────────────────────────────────────────────
343
344    #[test]
345    fn test_priority_regression_scaled_by_drop() {
346        // 5 pp drop → priority 5
347        let fk = FaultKind::Regression {
348            previous_rate: 0.9,
349            current_rate: 0.85,
350            drop: 0.05,
351        };
352        assert_eq!(fk.priority(), 5);
353    }
354
355    #[test]
356    fn test_priority_regression_capped_at_10() {
357        // 25 pp drop → capped at 10
358        let fk = FaultKind::Regression {
359            previous_rate: 1.0,
360            current_rate: 0.75,
361            drop: 0.25,
362        };
363        assert_eq!(fk.priority(), 10);
364    }
365
366    #[test]
367    fn test_priority_consistent_failure() {
368        assert_eq!(
369            FaultKind::ConsistentFailure { success_rate: 0.1 }.priority(),
370            8
371        );
372    }
373
374    #[test]
375    fn test_priority_new_capability() {
376        assert_eq!(
377            FaultKind::NewCapability {
378                description: "x".into()
379            }
380            .priority(),
381            5
382        );
383    }
384
385    #[test]
386    fn test_priority_flaky() {
387        assert_eq!(
388            FaultKind::Flaky {
389                mean_rate: 0.5,
390                ci_width: 0.3
391            }
392            .priority(),
393            4
394        );
395    }
396
397    // ── FaultReport constructors ───────────────────────────────────────────
398
399    #[test]
400    fn test_regression_constructor_sets_fields() {
401        let report =
402            FaultReport::regression("my_case", "smoke", 0.9, 0.7, vec!["err1".into()], 3, 10);
403        assert_eq!(report.case_name, "my_case");
404        assert_eq!(report.category, "smoke");
405        assert_eq!(report.n_failures, 3);
406        assert_eq!(report.n_trials, 10);
407        assert!(report.suggested_task_description.contains("my_case"));
408        assert!(report.suggested_task_description.contains("regression"));
409        match &report.fault_kind {
410            FaultKind::Regression {
411                drop,
412                previous_rate,
413                current_rate,
414            } => {
415                assert!((*drop - 0.2).abs() < 1e-9);
416                assert!((*previous_rate - 0.9).abs() < 1e-9);
417                assert!((*current_rate - 0.7).abs() < 1e-9);
418            }
419            _ => panic!("expected Regression variant"),
420        }
421    }
422
423    #[test]
424    fn test_new_capability_constructor() {
425        let report = FaultReport::new_capability("new_case", "cat", "desc", 0.85, 1, 10);
426        assert_eq!(report.case_name, "new_case");
427        assert!(matches!(report.fault_kind, FaultKind::NewCapability { .. }));
428        assert!(report.sample_errors.is_empty());
429    }
430
431    // ── analyze_suite_for_faults ───────────────────────────────────────────
432
433    #[test]
434    fn test_consistent_failure_detected() {
435        let result = make_suite_result("bad_case", 1, 20); // 5% success
436        let reports = analyze_suite_for_faults(&result, None, 0.2, 0.25);
437        assert_eq!(reports.len(), 1);
438        assert!(
439            matches!(reports[0].fault_kind, FaultKind::ConsistentFailure { .. }),
440            "expected ConsistentFailure"
441        );
442        assert_eq!(reports[0].case_name, "bad_case");
443    }
444
445    #[test]
446    fn test_regression_detected_when_drop_exceeds_tolerance() {
447        let result = make_suite_result("my_case", 7, 10); // 70%
448        let mut reg = RegressionSuite::new();
449        // Baseline was 90% → 20 pp drop, well above 3 pp tolerance.
450        let baseline_trials: Vec<TrialResult> = (0..10)
451            .map(|i| {
452                if i < 9 {
453                    TrialResult::success(i, 1)
454                } else {
455                    TrialResult::failure(i, 1, "e")
456                }
457            })
458            .collect();
459        let baseline_stats = EvaluationStats::from_trials(&baseline_trials).unwrap();
460        reg.add_baseline("my_case", &baseline_stats);
461
462        let reports = analyze_suite_for_faults(&result, Some(&reg), 0.2, 0.25);
463        assert!(
464            reports
465                .iter()
466                .any(|r| matches!(r.fault_kind, FaultKind::Regression { .. })),
467            "expected Regression fault"
468        );
469    }
470
471    #[test]
472    fn test_no_fault_when_within_tolerance() {
473        // 88% success, baseline 90% → drop 2 pp ≤ 3 pp tolerance.
474        let result = make_suite_result("ok_case", 88, 100);
475        let mut reg = RegressionSuite::new();
476        let baseline_trials: Vec<TrialResult> = (0..100)
477            .map(|i| {
478                if i < 90 {
479                    TrialResult::success(i, 1)
480                } else {
481                    TrialResult::failure(i, 1, "e")
482                }
483            })
484            .collect();
485        let baseline_stats = EvaluationStats::from_trials(&baseline_trials).unwrap();
486        reg.add_baseline("ok_case", &baseline_stats);
487
488        let reports = analyze_suite_for_faults(&result, Some(&reg), 0.2, 0.25);
489        assert!(
490            reports.is_empty(),
491            "2 pp drop within 3 pp tolerance should produce no fault"
492        );
493    }
494
495    #[test]
496    fn test_no_fault_for_passing_case_without_regression_suite() {
497        // Use 50 trials at 90% to get a narrow Wilson CI (width ~0.17 < 0.25).
498        let result = make_suite_result("good_case", 45, 50);
499        let reports = analyze_suite_for_faults(&result, None, 0.2, 0.25);
500        assert!(reports.is_empty());
501    }
502
503    #[test]
504    fn test_new_capability_when_regression_suite_provided_but_no_matching_baseline() {
505        // 50 trials at 90% → CI width ~0.17 < 0.25 → not Flaky → reaches NewCapability check.
506        let result = make_suite_result("new_case", 45, 50);
507        let reg = RegressionSuite::new(); // empty — no baseline for "new_case"
508        let reports = analyze_suite_for_faults(&result, Some(&reg), 0.2, 0.25);
509        assert!(
510            reports
511                .iter()
512                .any(|r| matches!(r.fault_kind, FaultKind::NewCapability { .. })),
513            "should report NewCapability for high-success case with no baseline"
514        );
515    }
516
517    #[test]
518    fn test_results_sorted_by_priority_descending() {
519        let mut case_results = HashMap::new();
520        let mut stats_map = HashMap::new();
521
522        // consistent_failure case: 10% → priority 8
523        let bad: Vec<TrialResult> = (0..10)
524            .map(|i| {
525                if i < 1 {
526                    TrialResult::success(i, 1)
527                } else {
528                    TrialResult::failure(i, 1, "e")
529                }
530            })
531            .collect();
532        stats_map.insert(
533            "bad".to_string(),
534            EvaluationStats::from_trials(&bad).unwrap(),
535        );
536        case_results.insert("bad".to_string(), bad);
537
538        // flaky case: 50% with 10 trials — CI width ~ 0.52 > 0.25 → priority 4
539        let flaky: Vec<TrialResult> = (0..10)
540            .map(|i| {
541                if i < 5 {
542                    TrialResult::success(i, 1)
543                } else {
544                    TrialResult::failure(i, 1, "e")
545                }
546            })
547            .collect();
548        stats_map.insert(
549            "flaky".to_string(),
550            EvaluationStats::from_trials(&flaky).unwrap(),
551        );
552        case_results.insert("flaky".to_string(), flaky);
553
554        let result = SuiteResult {
555            case_results,
556            stats: stats_map,
557        };
558        let reports = analyze_suite_for_faults(&result, None, 0.2, 0.25);
559
560        assert!(reports.len() >= 2);
561        for i in 0..reports.len() - 1 {
562            assert!(
563                reports[i].priority() >= reports[i + 1].priority(),
564                "reports should be sorted by priority desc"
565            );
566        }
567    }
568
569    #[test]
570    fn test_sample_errors_collected() {
571        let result = make_suite_result("broken", 0, 5); // all fail
572        let reports = analyze_suite_for_faults(&result, None, 0.2, 0.25);
573        assert!(!reports.is_empty());
574        // Up to 3 sample errors should be present
575        assert!(!reports[0].sample_errors.is_empty());
576        assert!(reports[0].sample_errors.len() <= 3);
577    }
578}