use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerTestCase {
pub query: String,
pub expected_agent: String,
pub should_trigger: bool,
#[serde(default = "default_runs")]
pub runs_per_query: usize,
}
fn default_runs() -> usize {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerAccuracy {
pub total: usize,
pub true_positives: usize,
pub false_positives: usize,
pub true_negatives: usize,
pub false_negatives: usize,
pub precision: f64,
pub recall: f64,
pub f1: f64,
#[serde(default)]
pub trigger_rates: Vec<f64>,
}
impl TriggerAccuracy {
pub fn evaluate(
cases: &[TriggerTestCase],
actual_triggers: &[(String, Option<String>)],
) -> Self {
let mut tp = 0usize; let mut fp = 0usize; let mut tn = 0usize; let mut fn_count = 0usize;
for (case, (_query, actual_agent)) in cases.iter().zip(actual_triggers.iter()) {
let did_trigger = actual_agent.is_some();
let correct_agent = actual_agent.as_deref() == Some(&case.expected_agent);
match (case.should_trigger, did_trigger, correct_agent) {
(true, true, true) => tp += 1,
(true, true, false) => {
tp += 1;
fp += 1;
}
(true, false, _) => fn_count += 1,
(false, true, _) => fp += 1,
(false, false, _) => tn += 1,
}
}
let total = cases.len();
let precision = if tp + fp > 0 {
tp as f64 / (tp + fp) as f64
} else {
0.0
};
let recall = if tp + fn_count > 0 {
tp as f64 / (tp + fn_count) as f64
} else {
0.0
};
let f1 = if precision + recall > 0.0 {
2.0 * precision * recall / (precision + recall)
} else {
0.0
};
Self {
total,
true_positives: tp,
false_positives: fp,
true_negatives: tn,
false_negatives: fn_count,
precision,
recall,
f1,
trigger_rates: Vec::new(),
}
}
pub fn evaluate_multi_run(
cases: &[TriggerTestCase],
multi_results: &[Vec<(String, Option<String>)>],
) -> Self {
let flat_actuals: Vec<(String, Option<String>)> = multi_results
.iter()
.map(|runs| runs.first().cloned().unwrap_or_default())
.collect();
let mut result = Self::evaluate(cases, &flat_actuals);
result.trigger_rates = cases
.iter()
.zip(multi_results.iter())
.map(|(case, runs)| {
if case.runs_per_query <= 1 {
return if runs.first().is_some_and(|(_, a)| a.is_some()) {
1.0
} else {
0.0
};
}
let triggered = runs.iter().filter(|(_, a)| a.is_some()).count();
triggered as f64 / runs.len() as f64
})
.collect();
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trigger_accuracy_perfect() {
let cases = vec![
TriggerTestCase {
query: "read src/main.rs".into(),
expected_agent: "code-explorer".into(),
should_trigger: true,
runs_per_query: 1,
},
TriggerTestCase {
query: "what is 2+2".into(),
expected_agent: "".into(),
should_trigger: false,
runs_per_query: 1,
},
];
let actuals = vec![
("read src/main.rs".into(), Some("code-explorer".into())),
("what is 2+2".into(), None),
];
let acc = TriggerAccuracy::evaluate(&cases, &actuals);
assert_eq!(acc.precision, 1.0);
assert_eq!(acc.recall, 1.0);
assert_eq!(acc.f1, 1.0);
}
#[test]
fn test_trigger_accuracy_false_positive() {
let cases = vec![TriggerTestCase {
query: "what is 2+2".into(),
expected_agent: "".into(),
should_trigger: false,
runs_per_query: 1,
}];
let actuals = vec![("what is 2+2".into(), Some("code-explorer".into()))];
let acc = TriggerAccuracy::evaluate(&cases, &actuals);
assert_eq!(acc.precision, 0.0); }
}