|
| 1 | +//! Verdict Engine for Terraphim codebase evaluation. |
| 2 | +//! |
| 3 | +//! Compares baseline and candidate metrics captured by `scripts/evaluate-agent.sh` |
| 4 | +//! and classifies the outcome as [`Verdict::Improved`], [`Verdict::Degraded`], or |
| 5 | +//! [`Verdict::Neutral`]. |
| 6 | +
|
| 7 | +use serde::{Deserialize, Serialize}; |
| 8 | + |
| 9 | +/// Metrics captured for one codebase snapshot (baseline or candidate). |
| 10 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] |
| 11 | +pub struct Metrics { |
| 12 | + /// Number of `cargo test` failures (0 = all passing). |
| 13 | + pub test_failures: u32, |
| 14 | + /// Number of `cargo clippy` warnings emitted. |
| 15 | + pub clippy_warnings: u32, |
| 16 | + /// Number of `cargo clippy` errors emitted. |
| 17 | + pub clippy_errors: u32, |
| 18 | + /// Total tests executed. |
| 19 | + pub test_count: u32, |
| 20 | +} |
| 21 | + |
| 22 | +/// Per-metric delta between baseline and candidate. |
| 23 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] |
| 24 | +pub struct Delta { |
| 25 | + pub test_failures: i64, |
| 26 | + pub clippy_warnings: i64, |
| 27 | + pub clippy_errors: i64, |
| 28 | + pub test_count: i64, |
| 29 | +} |
| 30 | + |
| 31 | +impl Delta { |
| 32 | + pub fn compute(baseline: &Metrics, candidate: &Metrics) -> Self { |
| 33 | + Self { |
| 34 | + test_failures: candidate.test_failures as i64 - baseline.test_failures as i64, |
| 35 | + clippy_warnings: candidate.clippy_warnings as i64 - baseline.clippy_warnings as i64, |
| 36 | + clippy_errors: candidate.clippy_errors as i64 - baseline.clippy_errors as i64, |
| 37 | + test_count: candidate.test_count as i64 - baseline.test_count as i64, |
| 38 | + } |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +/// Classification of the AI-agent's net effect on the codebase. |
| 43 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 44 | +#[serde(rename_all = "PascalCase")] |
| 45 | +pub enum Verdict { |
| 46 | + /// The candidate is strictly better: no new failures, fewer warnings/errors. |
| 47 | + Improved, |
| 48 | + /// The candidate introduced regressions (new test failures or errors). |
| 49 | + Degraded, |
| 50 | + /// The change is a wash — neither clearly better nor worse. |
| 51 | + Neutral, |
| 52 | +} |
| 53 | + |
| 54 | +impl std::fmt::Display for Verdict { |
| 55 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 56 | + match self { |
| 57 | + Verdict::Improved => write!(f, "Improved"), |
| 58 | + Verdict::Degraded => write!(f, "Degraded"), |
| 59 | + Verdict::Neutral => write!(f, "Neutral"), |
| 60 | + } |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +/// Full evaluation report: inputs, deltas, and final verdict. |
| 65 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 66 | +pub struct EvalReport { |
| 67 | + pub baseline: Metrics, |
| 68 | + pub candidate: Metrics, |
| 69 | + pub delta: Delta, |
| 70 | + pub verdict: Verdict, |
| 71 | + pub rationale: String, |
| 72 | +} |
| 73 | + |
| 74 | +/// Evaluate baseline vs candidate metrics and produce a report. |
| 75 | +/// |
| 76 | +/// Rules (applied in order): |
| 77 | +/// 1. New test failures or new clippy **errors** → **Degraded**. |
| 78 | +/// 2. Fewer failures, fewer warnings, and no new errors → **Improved**. |
| 79 | +/// 3. Otherwise → **Neutral**. |
| 80 | +pub fn evaluate(baseline: &Metrics, candidate: &Metrics) -> EvalReport { |
| 81 | + let delta = Delta::compute(baseline, candidate); |
| 82 | + |
| 83 | + let (verdict, rationale) = classify(&delta); |
| 84 | + |
| 85 | + EvalReport { |
| 86 | + baseline: baseline.clone(), |
| 87 | + candidate: candidate.clone(), |
| 88 | + delta, |
| 89 | + verdict, |
| 90 | + rationale, |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +fn classify(delta: &Delta) -> (Verdict, String) { |
| 95 | + // Rule 1: hard regressions. |
| 96 | + if delta.test_failures > 0 { |
| 97 | + return ( |
| 98 | + Verdict::Degraded, |
| 99 | + format!( |
| 100 | + "Candidate introduced {} new test failure(s).", |
| 101 | + delta.test_failures |
| 102 | + ), |
| 103 | + ); |
| 104 | + } |
| 105 | + if delta.clippy_errors > 0 { |
| 106 | + return ( |
| 107 | + Verdict::Degraded, |
| 108 | + format!( |
| 109 | + "Candidate introduced {} new clippy error(s).", |
| 110 | + delta.clippy_errors |
| 111 | + ), |
| 112 | + ); |
| 113 | + } |
| 114 | + |
| 115 | + // Rule 2: clear improvement. |
| 116 | + let fewer_failures = delta.test_failures <= 0; |
| 117 | + let fewer_warnings = delta.clippy_warnings <= 0; |
| 118 | + let no_new_errors = delta.clippy_errors <= 0; |
| 119 | + let more_tests = delta.test_count >= 0; |
| 120 | + |
| 121 | + if fewer_failures && fewer_warnings && no_new_errors && more_tests { |
| 122 | + // At least one metric must actually improve to call it Improved. |
| 123 | + if delta.test_failures < 0 || delta.clippy_warnings < 0 || delta.test_count > 0 { |
| 124 | + return ( |
| 125 | + Verdict::Improved, |
| 126 | + format!( |
| 127 | + "Candidate reduced failures by {}, warnings by {}, added {} test(s).", |
| 128 | + -delta.test_failures, -delta.clippy_warnings, delta.test_count |
| 129 | + ), |
| 130 | + ); |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + // Rule 3: neutral. |
| 135 | + ( |
| 136 | + Verdict::Neutral, |
| 137 | + format!( |
| 138 | + "Mixed signals: failures {:+}, warnings {:+}, errors {:+}.", |
| 139 | + delta.test_failures, delta.clippy_warnings, delta.clippy_errors |
| 140 | + ), |
| 141 | + ) |
| 142 | +} |
| 143 | + |
| 144 | +#[cfg(test)] |
| 145 | +mod tests { |
| 146 | + use super::*; |
| 147 | + |
| 148 | + fn baseline() -> Metrics { |
| 149 | + Metrics { |
| 150 | + test_failures: 0, |
| 151 | + clippy_warnings: 5, |
| 152 | + clippy_errors: 0, |
| 153 | + test_count: 100, |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + #[test] |
| 158 | + fn new_test_failure_is_degraded() { |
| 159 | + let candidate = Metrics { |
| 160 | + test_failures: 2, |
| 161 | + ..baseline() |
| 162 | + }; |
| 163 | + let report = evaluate(&baseline(), &candidate); |
| 164 | + assert_eq!(report.verdict, Verdict::Degraded); |
| 165 | + assert!(report.rationale.contains("test failure")); |
| 166 | + } |
| 167 | + |
| 168 | + #[test] |
| 169 | + fn new_clippy_error_is_degraded() { |
| 170 | + let candidate = Metrics { |
| 171 | + clippy_errors: 1, |
| 172 | + ..baseline() |
| 173 | + }; |
| 174 | + let report = evaluate(&baseline(), &candidate); |
| 175 | + assert_eq!(report.verdict, Verdict::Degraded); |
| 176 | + assert!(report.rationale.contains("clippy error")); |
| 177 | + } |
| 178 | + |
| 179 | + #[test] |
| 180 | + fn fewer_warnings_no_regressions_is_improved() { |
| 181 | + let candidate = Metrics { |
| 182 | + clippy_warnings: 2, |
| 183 | + test_count: 105, |
| 184 | + ..baseline() |
| 185 | + }; |
| 186 | + let report = evaluate(&baseline(), &candidate); |
| 187 | + assert_eq!(report.verdict, Verdict::Improved); |
| 188 | + } |
| 189 | + |
| 190 | + #[test] |
| 191 | + fn identical_metrics_is_neutral() { |
| 192 | + let report = evaluate(&baseline(), &baseline()); |
| 193 | + assert_eq!(report.verdict, Verdict::Neutral); |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn more_warnings_but_no_failures_is_neutral() { |
| 198 | + let candidate = Metrics { |
| 199 | + clippy_warnings: 8, |
| 200 | + ..baseline() |
| 201 | + }; |
| 202 | + let report = evaluate(&baseline(), &candidate); |
| 203 | + assert_eq!(report.verdict, Verdict::Neutral); |
| 204 | + } |
| 205 | + |
| 206 | + #[test] |
| 207 | + fn delta_compute() { |
| 208 | + let b = Metrics { |
| 209 | + test_failures: 0, |
| 210 | + clippy_warnings: 5, |
| 211 | + clippy_errors: 0, |
| 212 | + test_count: 100, |
| 213 | + }; |
| 214 | + let c = Metrics { |
| 215 | + test_failures: 1, |
| 216 | + clippy_warnings: 3, |
| 217 | + clippy_errors: 1, |
| 218 | + test_count: 102, |
| 219 | + }; |
| 220 | + let d = Delta::compute(&b, &c); |
| 221 | + assert_eq!(d.test_failures, 1); |
| 222 | + assert_eq!(d.clippy_warnings, -2); |
| 223 | + assert_eq!(d.clippy_errors, 1); |
| 224 | + assert_eq!(d.test_count, 2); |
| 225 | + } |
| 226 | + |
| 227 | + #[test] |
| 228 | + fn verdict_display() { |
| 229 | + assert_eq!(Verdict::Improved.to_string(), "Improved"); |
| 230 | + assert_eq!(Verdict::Degraded.to_string(), "Degraded"); |
| 231 | + assert_eq!(Verdict::Neutral.to_string(), "Neutral"); |
| 232 | + } |
| 233 | + |
| 234 | + #[test] |
| 235 | + fn eval_report_serialises_to_json() { |
| 236 | + let report = evaluate( |
| 237 | + &baseline(), |
| 238 | + &Metrics { |
| 239 | + clippy_warnings: 2, |
| 240 | + test_count: 103, |
| 241 | + ..baseline() |
| 242 | + }, |
| 243 | + ); |
| 244 | + let json = serde_json::to_string(&report).expect("serialise"); |
| 245 | + assert!(json.contains("\"Improved\"")); |
| 246 | + } |
| 247 | +} |
0 commit comments