Skip to main content

vtcode_core/core/agent/
evaluator.rs

1//! Structured evaluator with scoring dimensions and hard thresholds.
2//!
3//! Following the long-running harness pattern from Anthropic's research:
4//! "When using a judge, we cannot ask abstractly whether this is 'good.' We need
5//! to break 'good' into multiple checkable dimensions."
6//!
7//! The evaluator scores a sprint across multiple dimensions, each with a hard
8//! threshold. If any dimension falls below its threshold, the sprint fails and
9//! the generator must revise based on concrete feedback. This prevents the
10//! common failure mode where an agent sees a button render and declares the
11//! feature complete.
12//!
13//! Key principles:
14//! - Evaluate outcomes, not claims (check actual test results, not agent assertions)
15//! - Every dimension has a hard threshold (below = sprint fails)
16//! - Feedback is specific and actionable (not "looks good")
17
18use serde::{Deserialize, Serialize};
19
20/// A scoring dimension with a hard threshold.
21///
22/// Each dimension represents one aspect of quality that must be evaluated
23/// independently. The hard_threshold is the minimum acceptable score --
24/// falling below it means the sprint fails regardless of other scores.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ScoringDimension {
27    /// Short identifier (e.g. "correctness", "functionality").
28    pub name: String,
29    /// Weight for overall score computation (0.0..=1.0).
30    pub weight: f32,
31    /// Minimum acceptable score. Below this = sprint fails.
32    pub hard_threshold: f32,
33    /// Human-readable description of what this dimension measures.
34    pub description: String,
35}
36
37/// Score for a single dimension in an evaluation result.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DimensionScore {
40    /// Which dimension was scored.
41    pub dimension: String,
42    /// Score achieved (0.0..=1.0).
43    pub score: f32,
44    /// Hard threshold for this dimension (0.0..=1.0).
45    pub hard_threshold: f32,
46    /// Whether this score is below the hard threshold.
47    pub below_threshold: bool,
48    /// Specific, actionable notes about this score.
49    pub notes: String,
50}
51
52/// The complete evaluation result for a sprint.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EvaluationResult {
55    /// Per-dimension scores.
56    pub scores: Vec<DimensionScore>,
57    /// Weighted overall score (0.0..=1.0).
58    pub overall_score: f32,
59    /// Whether all dimensions meet their hard thresholds.
60    pub overall_pass: bool,
61    /// High-level feedback summary.
62    pub feedback: String,
63    /// Specific issues that must be fixed.
64    pub issues: Vec<String>,
65}
66
67/// The rubric that defines how a sprint is evaluated.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct EvaluationRubric {
70    /// Scoring dimensions with hard thresholds.
71    pub dimensions: Vec<ScoringDimension>,
72}
73
74impl EvaluationRubric {
75    /// Create a new rubric with the given dimensions.
76    ///
77    /// Clamps all weights and hard thresholds to [0.0, 1.0] to prevent
78    /// invalid rubrics from producing confusing results.
79    pub fn new(dimensions: Vec<ScoringDimension>) -> Self {
80        let dimensions = dimensions
81            .into_iter()
82            .map(|mut d| {
83                d.weight = d.weight.clamp(0.0, 1.0);
84                d.hard_threshold = d.hard_threshold.clamp(0.0, 1.0);
85                d
86            })
87            .collect();
88        Self { dimensions }
89    }
90
91    /// Evaluate scores against this rubric.
92    ///
93    /// Takes a map of dimension name -> (score, notes) and produces a full
94    /// EvaluationResult with weighted overall score and pass/fail determination.
95    pub fn evaluate(&self, raw_scores: &[(&str, f32, &str)]) -> EvaluationResult {
96        let mut scores = Vec::with_capacity(self.dimensions.len());
97        let mut weighted_sum = 0.0f32;
98        let mut weight_total = 0.0f32;
99        let mut overall_pass = true;
100        let mut issues = Vec::new();
101
102        for dim in &self.dimensions {
103            let (score, notes) = raw_scores
104                .iter()
105                .find(|(name, _, _)| *name == dim.name)
106                .map(|(_, s, n)| (*s, *n))
107                .unwrap_or((0.0, "not evaluated"));
108
109            let clamped = score.clamp(0.0, 1.0);
110            let below_threshold = clamped < dim.hard_threshold;
111
112            if below_threshold {
113                overall_pass = false;
114                issues.push(format!(
115                    "{}: {:.0}% below hard threshold {:.0}% -- {}",
116                    dim.name,
117                    clamped * 100.0,
118                    dim.hard_threshold * 100.0,
119                    notes,
120                ));
121            }
122
123            weighted_sum += clamped * dim.weight;
124            weight_total += dim.weight;
125
126            scores.push(DimensionScore {
127                dimension: dim.name.clone(),
128                score: clamped,
129                below_threshold,
130                notes: notes.to_string(),
131                hard_threshold: dim.hard_threshold,
132            });
133        }
134
135        let overall_score = if weight_total > 0.0 {
136            weighted_sum / weight_total
137        } else {
138            0.0
139        };
140
141        let feedback = if overall_pass {
142            format!("Sprint PASSED. Overall score: {:.0}%. All dimensions meet hard thresholds.", overall_score * 100.0,)
143        } else {
144            format!(
145                "Sprint FAILED. Overall score: {:.0}%. {} dimension(s) below hard threshold: {}",
146                overall_score * 100.0,
147                issues.len(),
148                issues
149                    .iter()
150                    .map(|i| i.split(':').next().unwrap_or("?"))
151                    .collect::<Vec<_>>()
152                    .join(", "),
153            )
154        };
155
156        EvaluationResult {
157            scores,
158            overall_score,
159            overall_pass,
160            feedback,
161            issues,
162        }
163    }
164}
165
166/// Default rubric for code tasks.
167///
168/// Dimensions:
169/// - correctness (weight 0.4, hard 0.9): Does the code do what was asked?
170/// - functionality (weight 0.3, hard 0.8): Do tests pass? Does it run?
171/// - code_quality (weight 0.2, hard 0.7): Is the code clean and maintainable?
172/// - test_coverage (weight 0.1, hard 0.6): Are there adequate tests?
173pub fn default_code_rubric() -> EvaluationRubric {
174    EvaluationRubric::new(vec![
175        ScoringDimension {
176            name: "correctness".to_string(),
177            weight: 0.4,
178            hard_threshold: 0.9,
179            description: "The code implements what was asked, not just something \
180                that looks right. Edge cases are handled. Behavior matches the spec."
181                .to_string(),
182        },
183        ScoringDimension {
184            name: "functionality".to_string(),
185            weight: 0.3,
186            hard_threshold: 0.8,
187            description: "Tests pass. The application runs. Commands produce expected \
188                output. No regressions in existing functionality."
189                .to_string(),
190        },
191        ScoringDimension {
192            name: "code_quality".to_string(),
193            weight: 0.2,
194            hard_threshold: 0.7,
195            description: "Code follows project conventions. No unwrap() in production. \
196                Proper error handling. Clear naming. Files under 500 lines."
197                .to_string(),
198        },
199        ScoringDimension {
200            name: "test_coverage".to_string(),
201            weight: 0.1,
202            hard_threshold: 0.6,
203            description: "New code has tests. Edge cases are covered. Tests are \
204                deterministic and fast."
205                .to_string(),
206        },
207    ])
208}
209
210/// Scoring dimension for the hypothesis loop (additive; [`default_code_rubric`]
211/// is unchanged).
212///
213/// - hypothesis_revision (weight 0.2, hard 0.7): on mismatch the agent
214///   inspects evidence and revises its hypothesis instead of retrying
215///   unchanged.
216pub fn hypothesis_revision_dimension() -> ScoringDimension {
217    ScoringDimension {
218        name: "hypothesis_revision".to_string(),
219        weight: 0.2,
220        hard_threshold: 0.7,
221        description: "On mismatch the agent inspects evidence and revises its \
222            hypothesis instead of retrying unchanged. Stubborn identical retries \
223            score low; targeted re-reads and revised approaches score high."
224            .to_string(),
225    }
226}
227
228/// Default code rubric plus the hypothesis-revision dimension.
229///
230/// Existing [`default_code_rubric`] scores are preserved; the extra dimension
231/// only adds signal for loop behavior.
232pub fn default_code_rubric_with_hypothesis_revision() -> EvaluationRubric {
233    let mut rubric = default_code_rubric();
234    rubric.dimensions.push(hypothesis_revision_dimension());
235    rubric
236}
237
238/// Score hypothesis-revision behavior from harness-observed counts.
239///
240/// - `stubborn_retries`: retries with unchanged arguments and no new evidence.
241/// - `evidence_rereads`: targeted re-reads or spool pages after a mismatch.
242/// - `hypothesis_updates`: explicit hypothesis revisions before retrying.
243///
244/// Returns `(score 0.0..=1.0, notes)`. A run with no mismatches scores `1.0`
245/// (vacuous pass): clean runs are not punished for having nothing to revise.
246pub fn score_hypothesis_revision(
247    stubborn_retries: u32,
248    evidence_rereads: u32,
249    hypothesis_updates: u32,
250) -> (f32, &'static str) {
251    let productive = evidence_rereads.saturating_add(hypothesis_updates);
252    let total = productive.saturating_add(stubborn_retries);
253    if total == 0 {
254        return (1.0, "no mismatches observed");
255    }
256    let score = productive as f32 / total as f32;
257    let notes = if score >= 0.7 {
258        "revises the hypothesis from evidence on mismatch"
259    } else {
260        "retries without revising the hypothesis"
261    };
262    (score.clamp(0.0, 1.0), notes)
263}
264
265/// Render an EvaluationResult as a markdown report suitable for harness artifacts.
266pub fn evaluation_to_markdown(result: &EvaluationResult) -> String {
267    let mut out = String::new();
268    out.push_str("# Evaluation Report\n\n");
269    out.push_str(&format!("**Overall:** {}\n\n", result.feedback));
270
271    out.push_str("## Scores\n\n");
272    out.push_str("| Dimension | Score | Threshold | Status | Notes |\n");
273    out.push_str("|-----------|-------|-----------|--------|-------|\n");
274    for score in &result.scores {
275        let status = if score.below_threshold { "BELOW" } else { "OK" };
276        out.push_str(&format!(
277            "| {} | {:.0}% | {:.0}% | {} | {} |\n",
278            score.dimension,
279            score.score * 100.0,
280            score.hard_threshold * 100.0,
281            status,
282            score.notes,
283        ));
284    }
285
286    if !result.issues.is_empty() {
287        out.push_str("\n## Issues (must fix)\n\n");
288        for issue in &result.issues {
289            out.push_str(&format!("- {issue}\n"));
290        }
291    }
292
293    out
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn all_dimensions_pass() {
302        let rubric = default_code_rubric();
303        let result = rubric.evaluate(&[
304            ("correctness", 0.95, "handles all edge cases"),
305            ("functionality", 1.0, "all tests pass"),
306            ("code_quality", 0.85, "clean and follows conventions"),
307            ("test_coverage", 0.7, "good coverage"),
308        ]);
309        assert!(result.overall_pass);
310        assert!(result.overall_score > 0.8);
311        assert!(result.issues.is_empty());
312    }
313
314    #[test]
315    fn one_dimension_below_threshold_fails_sprint() {
316        let rubric = default_code_rubric();
317        let result = rubric.evaluate(&[
318            ("correctness", 0.5, "misses edge cases"),
319            ("functionality", 1.0, "all tests pass"),
320            ("code_quality", 0.85, "clean"),
321            ("test_coverage", 0.7, "good"),
322        ]);
323        assert!(!result.overall_pass);
324        assert_eq!(result.issues.len(), 1);
325        assert!(result.issues[0].contains("correctness"));
326    }
327
328    #[test]
329    fn multiple_failures_reported() {
330        let rubric = default_code_rubric();
331        let result = rubric.evaluate(&[
332            ("correctness", 0.5, "wrong behavior"),
333            ("functionality", 0.3, "tests fail"),
334            ("code_quality", 0.85, "clean"),
335            ("test_coverage", 0.7, "good"),
336        ]);
337        assert!(!result.overall_pass);
338        assert_eq!(result.issues.len(), 2);
339    }
340
341    #[test]
342    fn missing_dimension_scores_zero() {
343        let rubric = default_code_rubric();
344        let result = rubric.evaluate(&[
345            ("correctness", 0.95, "good"),
346            // functionality, code_quality, test_coverage missing
347        ]);
348        assert!(!result.overall_pass);
349        // Missing dimensions score 0.0, which is below all hard thresholds
350        assert!(result.issues.len() >= 3);
351    }
352
353    #[test]
354    fn scores_clamped_to_unit_range() {
355        let rubric = EvaluationRubric::new(vec![ScoringDimension {
356            name: "test".to_string(),
357            weight: 1.0,
358            hard_threshold: 0.5,
359            description: "test".to_string(),
360        }]);
361        let result = rubric.evaluate(&[("test", 1.5, "over max")]);
362        assert!((result.scores[0].score - 1.0).abs() < f32::EPSILON);
363        assert!(result.overall_pass);
364
365        let result2 = rubric.evaluate(&[("test", -0.5, "under min")]);
366        assert!((result2.scores[0].score - 0.0).abs() < f32::EPSILON);
367        assert!(!result2.overall_pass);
368    }
369
370    #[test]
371    fn evaluation_markdown_contains_issues() {
372        let rubric = default_code_rubric();
373        let result = rubric.evaluate(&[
374            ("correctness", 0.5, "wrong"),
375            ("functionality", 1.0, "ok"),
376            ("code_quality", 0.85, "ok"),
377            ("test_coverage", 0.7, "ok"),
378        ]);
379        let md = evaluation_to_markdown(&result);
380        assert!(md.contains("# Evaluation Report"));
381        assert!(md.contains("BELOW"));
382        assert!(md.contains("Issues (must fix)"));
383    }
384
385    #[test]
386    fn no_mismatches_scores_vacuous_pass() {
387        let (score, notes) = score_hypothesis_revision(0, 0, 0);
388        assert!((score - 1.0).abs() < f32::EPSILON);
389        assert!(notes.contains("no mismatches"));
390    }
391
392    #[test]
393    fn stubborn_only_scores_zero() {
394        let (score, notes) = score_hypothesis_revision(3, 0, 0);
395        assert!((score - 0.0).abs() < f32::EPSILON);
396        assert!(notes.contains("without revising"));
397    }
398
399    #[test]
400    fn revision_only_scores_one() {
401        let (score, _) = score_hypothesis_revision(0, 2, 1);
402        assert!((score - 1.0).abs() < f32::EPSILON);
403    }
404
405    #[test]
406    fn mixed_counts_score_proportionally() {
407        let (half, _) = score_hypothesis_revision(1, 1, 0);
408        assert!((half - 0.5).abs() < f32::EPSILON);
409        let (three_quarters, notes) = score_hypothesis_revision(1, 2, 1);
410        assert!((three_quarters - 0.75).abs() < f32::EPSILON);
411        assert!(notes.contains("revises the hypothesis"));
412    }
413
414    #[test]
415    fn extended_rubric_keeps_default_scores_and_adds_revision() {
416        let base = default_code_rubric();
417        let extended = default_code_rubric_with_hypothesis_revision();
418        assert_eq!(extended.dimensions.len(), base.dimensions.len() + 1);
419        let revision = extended.dimensions.iter().find(|dim| dim.name == "hypothesis_revision");
420        assert!(revision.is_some());
421        let result = extended.evaluate(&[
422            ("correctness", 0.95, "good"),
423            ("functionality", 1.0, "all pass"),
424            ("code_quality", 0.85, "clean"),
425            ("test_coverage", 0.7, "covered"),
426            ("hypothesis_revision", 1.0, "revised on mismatch"),
427        ]);
428        assert!(result.overall_pass);
429    }
430}