use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Criterion {
pub id: String,
pub description: String,
#[serde(default = "default_weight")]
pub weight: f64,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub scale_max: Option<f64>,
#[serde(default)]
pub examples: Vec<CriterionExample>,
}
fn default_weight() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CriterionExample {
pub content: String,
pub expected_score: f64,
#[serde(default)]
pub explanation: Option<String>,
}
impl Criterion {
pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
Self {
id: id.into(),
description: description.into(),
weight: 1.0,
required: false,
scale_max: None,
examples: Vec::new(),
}
}
pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight;
self
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn validate(&self) -> Result<(), String> {
if self.id.is_empty() {
return Err("Criterion ID cannot be empty".to_string());
}
if self.weight <= 0.0 {
return Err(format!(
"Criterion '{}' weight must be positive, got {}",
self.id, self.weight
));
}
if let Some(scale_max) = self.scale_max {
if scale_max <= 0.0 {
return Err(format!(
"Criterion '{}' scale_max must be positive, got {}",
self.id, scale_max
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rubric {
pub version: String,
#[serde(default)]
pub goal_text: Option<String>,
#[serde(default)]
pub criteria: Vec<Criterion>,
#[serde(default = "default_aggregation")]
pub aggregation: String,
#[serde(default)]
pub metadata: std::collections::HashMap<String, serde_json::Value>,
}
fn default_aggregation() -> String {
"weighted_sum".to_string()
}
impl Rubric {
pub fn new(version: impl Into<String>) -> Self {
Self {
version: version.into(),
goal_text: None,
criteria: Vec::new(),
aggregation: "weighted_sum".to_string(),
metadata: std::collections::HashMap::new(),
}
}
pub fn with_goal(mut self, goal: impl Into<String>) -> Self {
self.goal_text = Some(goal.into());
self
}
pub fn with_criterion(mut self, criterion: Criterion) -> Self {
self.criteria.push(criterion);
self
}
pub fn with_aggregation(mut self, aggregation: impl Into<String>) -> Self {
self.aggregation = aggregation.into();
self
}
pub fn validate(&self) -> Result<(), String> {
const VALID_AGGREGATIONS: &[&str] = &[
"sum",
"weighted_sum",
"mean",
"weighted_mean",
"custom",
"inherit",
];
if !VALID_AGGREGATIONS.contains(&self.aggregation.as_str()) {
return Err(format!(
"Invalid aggregation '{}'. Valid options: {:?}",
self.aggregation, VALID_AGGREGATIONS
));
}
let mut seen = HashSet::new();
for criterion in &self.criteria {
if !seen.insert(&criterion.id) {
return Err(format!("Duplicate criterion ID: {}", criterion.id));
}
criterion.validate()?;
}
if self.criteria.is_empty() && self.aggregation != "inherit" {
return Err("Rubric must have at least one criterion".to_string());
}
Ok(())
}
pub fn total_weight(&self) -> f64 {
self.criteria.iter().map(|c| c.weight).sum()
}
pub fn get_criterion(&self, id: &str) -> Option<&Criterion> {
self.criteria.iter().find(|c| c.id == id)
}
}
impl Default for Rubric {
fn default() -> Self {
Self::new("1.0")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_criterion_creation() {
let criterion = Criterion::new("accuracy", "Response is factually correct")
.with_weight(2.0)
.required();
assert_eq!(criterion.id, "accuracy");
assert_eq!(criterion.weight, 2.0);
assert!(criterion.required);
assert!(criterion.validate().is_ok());
}
#[test]
fn test_criterion_validation() {
let invalid = Criterion::new("test", "desc").with_weight(-1.0);
assert!(invalid.validate().is_err());
}
#[test]
fn test_rubric_creation() {
let rubric = Rubric::new("1.0")
.with_goal("Evaluate response quality")
.with_criterion(Criterion::new("clarity", "Response is clear"))
.with_criterion(Criterion::new("accuracy", "Response is accurate"));
assert_eq!(rubric.criteria.len(), 2);
assert!(rubric.validate().is_ok());
}
#[test]
fn test_rubric_duplicate_ids() {
let rubric = Rubric::new("1.0")
.with_criterion(Criterion::new("test", "First"))
.with_criterion(Criterion::new("test", "Duplicate"));
assert!(rubric.validate().is_err());
}
#[test]
fn test_rubric_serde() {
let rubric = Rubric::new("1.0").with_criterion(Criterion::new("test", "Test criterion"));
let json = serde_json::to_string(&rubric).unwrap();
let parsed: Rubric = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, rubric.version);
assert_eq!(parsed.criteria.len(), 1);
}
}