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/// Count-based heuristic for hypothesis-revision behavior, not proof of revision.
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.
246/// Rereads alone can score fully; acceptance requires an observed mismatch,
247/// an evidence-supported corrective action, and an independent outcome check.
248pub fn score_hypothesis_revision(
249    stubborn_retries: u32,
250    evidence_rereads: u32,
251    hypothesis_updates: u32,
252) -> (f32, &'static str) {
253    let productive = evidence_rereads.saturating_add(hypothesis_updates);
254    let total = productive.saturating_add(stubborn_retries);
255    if total == 0 {
256        return (1.0, "no mismatches observed");
257    }
258    let score = productive as f32 / total as f32;
259    let notes = if score >= 0.7 {
260        "inspection/revision count heuristic meets threshold; corrective action unverified"
261    } else {
262        "inspection/revision count heuristic below threshold; corrective action unverified"
263    };
264    (score.clamp(0.0, 1.0), notes)
265}
266
267/// Render an EvaluationResult as a markdown report suitable for harness artifacts.
268pub fn evaluation_to_markdown(result: &EvaluationResult) -> String {
269    let mut out = String::new();
270    out.push_str("# Evaluation Report\n\n");
271    out.push_str(&format!("**Overall:** {}\n\n", result.feedback));
272
273    out.push_str("## Scores\n\n");
274    out.push_str("| Dimension | Score | Threshold | Status | Notes |\n");
275    out.push_str("|-----------|-------|-----------|--------|-------|\n");
276    for score in &result.scores {
277        let status = if score.below_threshold { "BELOW" } else { "OK" };
278        out.push_str(&format!(
279            "| {} | {:.0}% | {:.0}% | {} | {} |\n",
280            score.dimension,
281            score.score * 100.0,
282            score.hard_threshold * 100.0,
283            status,
284            score.notes,
285        ));
286    }
287
288    if !result.issues.is_empty() {
289        out.push_str("\n## Issues (must fix)\n\n");
290        for issue in &result.issues {
291            out.push_str(&format!("- {issue}\n"));
292        }
293    }
294
295    out
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn all_dimensions_pass() {
304        let rubric = default_code_rubric();
305        let result = rubric.evaluate(&[
306            ("correctness", 0.95, "handles all edge cases"),
307            ("functionality", 1.0, "all tests pass"),
308            ("code_quality", 0.85, "clean and follows conventions"),
309            ("test_coverage", 0.7, "good coverage"),
310        ]);
311        assert!(result.overall_pass);
312        assert!(result.overall_score > 0.8);
313        assert!(result.issues.is_empty());
314    }
315
316    #[test]
317    fn one_dimension_below_threshold_fails_sprint() {
318        let rubric = default_code_rubric();
319        let result = rubric.evaluate(&[
320            ("correctness", 0.5, "misses edge cases"),
321            ("functionality", 1.0, "all tests pass"),
322            ("code_quality", 0.85, "clean"),
323            ("test_coverage", 0.7, "good"),
324        ]);
325        assert!(!result.overall_pass);
326        assert_eq!(result.issues.len(), 1);
327        assert!(result.issues[0].contains("correctness"));
328    }
329
330    #[test]
331    fn multiple_failures_reported() {
332        let rubric = default_code_rubric();
333        let result = rubric.evaluate(&[
334            ("correctness", 0.5, "wrong behavior"),
335            ("functionality", 0.3, "tests fail"),
336            ("code_quality", 0.85, "clean"),
337            ("test_coverage", 0.7, "good"),
338        ]);
339        assert!(!result.overall_pass);
340        assert_eq!(result.issues.len(), 2);
341    }
342
343    #[test]
344    fn missing_dimension_scores_zero() {
345        let rubric = default_code_rubric();
346        let result = rubric.evaluate(&[
347            ("correctness", 0.95, "good"),
348            // functionality, code_quality, test_coverage missing
349        ]);
350        assert!(!result.overall_pass);
351        // Missing dimensions score 0.0, which is below all hard thresholds
352        assert!(result.issues.len() >= 3);
353    }
354
355    #[test]
356    fn scores_clamped_to_unit_range() {
357        let rubric = EvaluationRubric::new(vec![ScoringDimension {
358            name: "test".to_string(),
359            weight: 1.0,
360            hard_threshold: 0.5,
361            description: "test".to_string(),
362        }]);
363        let result = rubric.evaluate(&[("test", 1.5, "over max")]);
364        assert!((result.scores[0].score - 1.0).abs() < f32::EPSILON);
365        assert!(result.overall_pass);
366
367        let result2 = rubric.evaluate(&[("test", -0.5, "under min")]);
368        assert!((result2.scores[0].score - 0.0).abs() < f32::EPSILON);
369        assert!(!result2.overall_pass);
370    }
371
372    #[test]
373    fn evaluation_markdown_contains_issues() {
374        let rubric = default_code_rubric();
375        let result = rubric.evaluate(&[
376            ("correctness", 0.5, "wrong"),
377            ("functionality", 1.0, "ok"),
378            ("code_quality", 0.85, "ok"),
379            ("test_coverage", 0.7, "ok"),
380        ]);
381        let md = evaluation_to_markdown(&result);
382        assert!(md.contains("# Evaluation Report"));
383        assert!(md.contains("BELOW"));
384        assert!(md.contains("Issues (must fix)"));
385    }
386
387    #[test]
388    fn no_mismatches_scores_vacuous_pass() {
389        let (score, notes) = score_hypothesis_revision(0, 0, 0);
390        assert!((score - 1.0).abs() < f32::EPSILON);
391        assert!(notes.contains("no mismatches"));
392    }
393
394    #[test]
395    fn stubborn_only_scores_zero() {
396        let (score, notes) = score_hypothesis_revision(3, 0, 0);
397        assert!((score - 0.0).abs() < f32::EPSILON);
398        assert!(notes.contains("heuristic below threshold"));
399    }
400
401    #[test]
402    fn revision_only_scores_one() {
403        let (score, _) = score_hypothesis_revision(0, 2, 1);
404        assert!((score - 1.0).abs() < f32::EPSILON);
405    }
406
407    #[test]
408    fn rereads_alone_remain_a_heuristic_not_proven_revision() {
409        let (score, notes) = score_hypothesis_revision(0, 3, 0);
410        assert!((score - 1.0).abs() < f32::EPSILON);
411        assert!(notes.contains("corrective action unverified"));
412    }
413
414    #[test]
415    fn mixed_counts_score_proportionally() {
416        let (half, _) = score_hypothesis_revision(1, 1, 0);
417        assert!((half - 0.5).abs() < f32::EPSILON);
418        let (three_quarters, notes) = score_hypothesis_revision(1, 2, 1);
419        assert!((three_quarters - 0.75).abs() < f32::EPSILON);
420        assert!(notes.contains("corrective action unverified"));
421    }
422
423    #[test]
424    fn extended_rubric_keeps_default_scores_and_adds_revision() {
425        let base = default_code_rubric();
426        let extended = default_code_rubric_with_hypothesis_revision();
427        assert_eq!(extended.dimensions.len(), base.dimensions.len() + 1);
428        let revision = extended.dimensions.iter().find(|dim| dim.name == "hypothesis_revision");
429        assert!(revision.is_some());
430        let result = extended.evaluate(&[
431            ("correctness", 0.95, "good"),
432            ("functionality", 1.0, "all pass"),
433            ("code_quality", 0.85, "clean"),
434            ("test_coverage", 0.7, "covered"),
435            ("hypothesis_revision", 1.0, "revised on mismatch"),
436        ]);
437        assert!(result.overall_pass);
438    }
439}