use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelScore {
pub accuracy: f64,
pub detail_scores: Vec<(String, f64)>,
pub response_tokens: u64,
pub latency_ms: u64,
pub rating: Rating,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Rating {
Bloom,
Grow,
Wilt,
Frost,
}
impl std::fmt::Display for Rating {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bloom => write!(f, "BLOOM"),
Self::Grow => write!(f, "GROW"),
Self::Wilt => write!(f, "WILT"),
Self::Frost => write!(f, "FROST"),
}
}
}
impl Rating {
pub fn from_accuracy(accuracy: f64, pass_threshold: f64) -> Self {
if accuracy >= pass_threshold {
Self::Bloom
} else if accuracy >= pass_threshold * 0.75 {
Self::Grow
} else if accuracy >= pass_threshold * 0.5 {
Self::Wilt
} else {
Self::Frost
}
}
}
pub fn keyword_accuracy(response: &str, expected: &[String]) -> f64 {
if expected.is_empty() {
return 1.0;
}
let response_lower = response.to_lowercase();
let matched = expected
.iter()
.filter(|kw| response_lower.contains(&kw.to_lowercase()))
.count();
matched as f64 / expected.len() as f64
}
pub fn json_field_accuracy(
response: &str,
expected: &serde_json::Value,
) -> (f64, Vec<(String, f64)>) {
let parsed = extract_json_from_response(response);
let expected_obj = match expected.as_object() {
Some(obj) => obj,
None => return (0.0, vec![]),
};
let parsed_obj = match parsed.as_ref().and_then(|v| v.as_object()) {
Some(obj) => obj,
None => {
let details: Vec<(String, f64)> =
expected_obj.keys().map(|k| (k.clone(), 0.0)).collect();
return (0.0, details);
}
};
let mut details = Vec::new();
let mut total = 0.0;
for (key, expected_val) in expected_obj {
let score = match parsed_obj.get(key) {
Some(actual_val) => {
if actual_val == expected_val {
1.0
} else if let (Some(e), Some(a)) = (expected_val.as_str(), actual_val.as_str()) {
if e.to_lowercase() == a.to_lowercase() {
1.0
} else {
0.0
}
} else {
0.0
}
}
None => 0.0,
};
details.push((key.clone(), score));
total += score;
}
let accuracy = if expected_obj.is_empty() {
1.0
} else {
total / expected_obj.len() as f64
};
(accuracy, details)
}
pub fn keyword_overlap_score(response: &str, reference: &str) -> f64 {
let response_tokens = tokenize(response);
let reference_tokens = tokenize(reference);
if reference_tokens.is_empty() {
return if response_tokens.is_empty() { 1.0 } else { 0.0 };
}
let matched = reference_tokens
.iter()
.filter(|t| response_tokens.contains(t))
.count();
matched as f64 / reference_tokens.len() as f64
}
pub fn pearson_correlation(predicted: &[f64], actual: &[f64]) -> f64 {
if predicted.len() != actual.len() || predicted.len() < 2 {
return 0.0;
}
let n = predicted.len() as f64;
let mean_p = predicted.iter().sum::<f64>() / n;
let mean_a = actual.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var_p = 0.0;
let mut var_a = 0.0;
for (p, a) in predicted.iter().zip(actual.iter()) {
let dp = p - mean_p;
let da = a - mean_a;
cov += dp * da;
var_p += dp * dp;
var_a += da * da;
}
let denom = (var_p * var_a).sqrt();
if denom < f64::EPSILON {
return 0.0;
}
cov / denom
}
fn extract_json_from_response(response: &str) -> Option<serde_json::Value> {
let trimmed = response.trim();
if let Ok(v) = serde_json::from_str(trimmed) {
return Some(v);
}
let start = trimmed.find('{')?;
let end = trimmed.rfind('}')?;
if start >= end {
return None;
}
serde_json::from_str(&trimmed[start..=end]).ok()
}
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
.filter(|s| !s.is_empty() && s.len() > 1)
.map(String::from)
.collect()
}
#[cfg(test)]
#[path = "../../tests/unit/vlm_bench/scoring/scoring_test.rs"]
mod tests;