use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoringDimension {
pub name: String,
pub weight: f32,
pub hard_threshold: f32,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DimensionScore {
pub dimension: String,
pub score: f32,
pub hard_threshold: f32,
pub below_threshold: bool,
pub notes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationResult {
pub scores: Vec<DimensionScore>,
pub overall_score: f32,
pub overall_pass: bool,
pub feedback: String,
pub issues: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationRubric {
pub dimensions: Vec<ScoringDimension>,
}
impl EvaluationRubric {
pub fn new(dimensions: Vec<ScoringDimension>) -> Self {
let dimensions = dimensions
.into_iter()
.map(|mut d| {
d.weight = d.weight.clamp(0.0, 1.0);
d.hard_threshold = d.hard_threshold.clamp(0.0, 1.0);
d
})
.collect();
Self { dimensions }
}
pub fn evaluate(&self, raw_scores: &[(&str, f32, &str)]) -> EvaluationResult {
let mut scores = Vec::with_capacity(self.dimensions.len());
let mut weighted_sum = 0.0f32;
let mut weight_total = 0.0f32;
let mut overall_pass = true;
let mut issues = Vec::new();
for dim in &self.dimensions {
let (score, notes) = raw_scores
.iter()
.find(|(name, _, _)| *name == dim.name)
.map(|(_, s, n)| (*s, *n))
.unwrap_or((0.0, "not evaluated"));
let clamped = score.clamp(0.0, 1.0);
let below_threshold = clamped < dim.hard_threshold;
if below_threshold {
overall_pass = false;
issues.push(format!(
"{}: {:.0}% below hard threshold {:.0}% -- {}",
dim.name,
clamped * 100.0,
dim.hard_threshold * 100.0,
notes,
));
}
weighted_sum += clamped * dim.weight;
weight_total += dim.weight;
scores.push(DimensionScore {
dimension: dim.name.clone(),
score: clamped,
below_threshold,
notes: notes.to_string(),
hard_threshold: dim.hard_threshold,
});
}
let overall_score = if weight_total > 0.0 {
weighted_sum / weight_total
} else {
0.0
};
let feedback = if overall_pass {
format!("Sprint PASSED. Overall score: {:.0}%. All dimensions meet hard thresholds.", overall_score * 100.0,)
} else {
format!(
"Sprint FAILED. Overall score: {:.0}%. {} dimension(s) below hard threshold: {}",
overall_score * 100.0,
issues.len(),
issues
.iter()
.map(|i| i.split(':').next().unwrap_or("?"))
.collect::<Vec<_>>()
.join(", "),
)
};
EvaluationResult {
scores,
overall_score,
overall_pass,
feedback,
issues,
}
}
}
pub fn default_code_rubric() -> EvaluationRubric {
EvaluationRubric::new(vec![
ScoringDimension {
name: "correctness".to_string(),
weight: 0.4,
hard_threshold: 0.9,
description: "The code implements what was asked, not just something \
that looks right. Edge cases are handled. Behavior matches the spec."
.to_string(),
},
ScoringDimension {
name: "functionality".to_string(),
weight: 0.3,
hard_threshold: 0.8,
description: "Tests pass. The application runs. Commands produce expected \
output. No regressions in existing functionality."
.to_string(),
},
ScoringDimension {
name: "code_quality".to_string(),
weight: 0.2,
hard_threshold: 0.7,
description: "Code follows project conventions. No unwrap() in production. \
Proper error handling. Clear naming. Files under 500 lines."
.to_string(),
},
ScoringDimension {
name: "test_coverage".to_string(),
weight: 0.1,
hard_threshold: 0.6,
description: "New code has tests. Edge cases are covered. Tests are \
deterministic and fast."
.to_string(),
},
])
}
pub fn evaluation_to_markdown(result: &EvaluationResult) -> String {
let mut out = String::new();
out.push_str("# Evaluation Report\n\n");
out.push_str(&format!("**Overall:** {}\n\n", result.feedback));
out.push_str("## Scores\n\n");
out.push_str("| Dimension | Score | Threshold | Status | Notes |\n");
out.push_str("|-----------|-------|-----------|--------|-------|\n");
for score in &result.scores {
let status = if score.below_threshold { "BELOW" } else { "OK" };
out.push_str(&format!(
"| {} | {:.0}% | {:.0}% | {} | {} |\n",
score.dimension,
score.score * 100.0,
score.hard_threshold * 100.0,
status,
score.notes,
));
}
if !result.issues.is_empty() {
out.push_str("\n## Issues (must fix)\n\n");
for issue in &result.issues {
out.push_str(&format!("- {issue}\n"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_dimensions_pass() {
let rubric = default_code_rubric();
let result = rubric.evaluate(&[
("correctness", 0.95, "handles all edge cases"),
("functionality", 1.0, "all tests pass"),
("code_quality", 0.85, "clean and follows conventions"),
("test_coverage", 0.7, "good coverage"),
]);
assert!(result.overall_pass);
assert!(result.overall_score > 0.8);
assert!(result.issues.is_empty());
}
#[test]
fn one_dimension_below_threshold_fails_sprint() {
let rubric = default_code_rubric();
let result = rubric.evaluate(&[
("correctness", 0.5, "misses edge cases"),
("functionality", 1.0, "all tests pass"),
("code_quality", 0.85, "clean"),
("test_coverage", 0.7, "good"),
]);
assert!(!result.overall_pass);
assert_eq!(result.issues.len(), 1);
assert!(result.issues[0].contains("correctness"));
}
#[test]
fn multiple_failures_reported() {
let rubric = default_code_rubric();
let result = rubric.evaluate(&[
("correctness", 0.5, "wrong behavior"),
("functionality", 0.3, "tests fail"),
("code_quality", 0.85, "clean"),
("test_coverage", 0.7, "good"),
]);
assert!(!result.overall_pass);
assert_eq!(result.issues.len(), 2);
}
#[test]
fn missing_dimension_scores_zero() {
let rubric = default_code_rubric();
let result = rubric.evaluate(&[
("correctness", 0.95, "good"),
]);
assert!(!result.overall_pass);
assert!(result.issues.len() >= 3);
}
#[test]
fn scores_clamped_to_unit_range() {
let rubric = EvaluationRubric::new(vec![ScoringDimension {
name: "test".to_string(),
weight: 1.0,
hard_threshold: 0.5,
description: "test".to_string(),
}]);
let result = rubric.evaluate(&[("test", 1.5, "over max")]);
assert!((result.scores[0].score - 1.0).abs() < f32::EPSILON);
assert!(result.overall_pass);
let result2 = rubric.evaluate(&[("test", -0.5, "under min")]);
assert!((result2.scores[0].score - 0.0).abs() < f32::EPSILON);
assert!(!result2.overall_pass);
}
#[test]
fn evaluation_markdown_contains_issues() {
let rubric = default_code_rubric();
let result = rubric.evaluate(&[
("correctness", 0.5, "wrong"),
("functionality", 1.0, "ok"),
("code_quality", 0.85, "ok"),
("test_coverage", 0.7, "ok"),
]);
let md = evaluation_to_markdown(&result);
assert!(md.contains("# Evaluation Report"));
assert!(md.contains("BELOW"));
assert!(md.contains("Issues (must fix)"));
}
}