1use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ScoringDimension {
27 pub name: String,
29 pub weight: f32,
31 pub hard_threshold: f32,
33 pub description: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DimensionScore {
40 pub dimension: String,
42 pub score: f32,
44 pub hard_threshold: f32,
46 pub below_threshold: bool,
48 pub notes: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EvaluationResult {
55 pub scores: Vec<DimensionScore>,
57 pub overall_score: f32,
59 pub overall_pass: bool,
61 pub feedback: String,
63 pub issues: Vec<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct EvaluationRubric {
70 pub dimensions: Vec<ScoringDimension>,
72}
73
74impl EvaluationRubric {
75 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 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
166pub 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
210pub 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
228pub 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
238pub 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
265pub 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 ]);
348 assert!(!result.overall_pass);
349 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}