use anyhow::Result;
use async_trait::async_trait;
use futures::{StreamExt, stream};
use crate::task::EvalTask;
use crate::{
EvalReport, EvalRunResult, EvalSuite, SuiteReport, aggregate_metrics, build_task_report, compute_metric_with_k,
};
#[derive(Debug, Clone, Copy)]
pub struct EvalRunOptions {
pub concurrency: usize,
pub metric_k: Option<u32>,
}
impl Default for EvalRunOptions {
fn default() -> Self {
Self { concurrency: 2, metric_k: None }
}
}
#[async_trait]
pub trait EvalExecutor: Send + Sync {
async fn execute_task(&self, task: &EvalTask) -> Result<EvalRunResult>;
async fn execute_task_attempt(&self, task: &EvalTask, attempt: u32) -> Result<EvalRunResult> {
let mut result = self.execute_task(task).await?;
result.attempt = attempt;
Ok(result)
}
}
pub async fn run_suite(executor: &dyn EvalExecutor, suite: &EvalSuite) -> Result<EvalReport> {
run_suite_with_options(executor, suite, EvalRunOptions::default()).await
}
pub async fn run_suite_with_options(
executor: &dyn EvalExecutor,
suite: &EvalSuite,
options: EvalRunOptions,
) -> Result<EvalReport> {
suite.validate()?;
let metric_k = options.metric_k.unwrap_or(suite.attempts);
anyhow::ensure!(metric_k >= 1 && metric_k <= suite.attempts, "evaluation metric k must be between 1 and attempts");
let concurrency = options.concurrency.max(1);
let jobs =
suite.tasks.iter().enumerate().flat_map(|(task_index, task)| {
(1..=suite.attempts).map(move |attempt| (task_index, task.clone(), attempt))
});
let mut results = stream::iter(jobs)
.map(|(task_index, task, attempt)| async move {
let result = executor.execute_task_attempt(&task, attempt).await?;
Ok::<_, anyhow::Error>((task_index, attempt, result))
})
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
for (task_index, expected_attempt, result) in &results {
let expected_task = suite
.tasks
.get(*task_index)
.ok_or_else(|| anyhow::anyhow!("executor returned an unknown task index {task_index}"))?;
anyhow::ensure!(
result.task_id == expected_task.id,
"executor returned task id '{}' for task '{}' attempt {}",
result.task_id,
expected_task.id,
expected_attempt
);
anyhow::ensure!(
result.attempt == *expected_attempt,
"executor returned attempt {} for task '{}' attempt {}",
result.attempt,
expected_task.id,
expected_attempt
);
}
results.sort_by_key(|(task_index, attempt, _)| (*task_index, *attempt));
let mut all_task_reports = Vec::new();
let mut all_cost_usd = 0.0;
let mut known_cost_runs = 0u32;
let mut unpriced_runs = 0u32;
let mut duration_secs = 0.0;
let mut trace_summary: Option<crate::trace_analyzer::HarnessTraceSummary> = None;
for (task_index, task) in suite.tasks.iter().enumerate() {
let run_results: Vec<EvalRunResult> = results
.iter()
.filter(|(index, _, _)| *index == task_index)
.map(|(_, _, result)| result.clone())
.collect();
for result in &run_results {
duration_secs += result.duration_secs;
if let Some(cost) = crate::report::priced_cost(result.cost_usd) {
all_cost_usd += cost;
known_cost_runs = known_cost_runs.saturating_add(1);
} else {
unpriced_runs = unpriced_runs.saturating_add(1);
}
if let Some(summary) = &result.trace_summary {
trace_summary.get_or_insert_with(Default::default).merge(summary);
}
}
let metric = compute_metric_with_k(&task.id, &run_results, metric_k)?;
all_task_reports.push(build_task_report(&task.id, &task.name, task.category, metric));
}
let all_metrics: Vec<_> = all_task_reports.iter().map(|r| r.metric.clone()).collect();
let cap_metrics: Vec<_> = all_task_reports
.iter()
.filter(|r| r.category == "Capability")
.map(|r| r.metric.clone())
.collect();
let reg_metrics: Vec<_> = all_task_reports
.iter()
.filter(|r| r.category == "Regression")
.map(|r| r.metric.clone())
.collect();
let efficiency = crate::report::CostEfficiency::from_runs(results.iter().map(|(_, _, result)| result));
Ok(EvalReport {
generated_at: chrono::Utc::now().to_rfc3339(),
suites: vec![SuiteReport {
suite_id: suite.id.clone(),
suite_name: suite.name.clone(),
task_reports: all_task_reports,
aggregate: aggregate_metrics(&all_metrics),
capability_metrics: aggregate_metrics(&cap_metrics),
regression_metrics: aggregate_metrics(®_metrics),
cost_usd: (known_cost_runs > 0).then_some(all_cost_usd),
unpriced_runs,
duration_secs,
trace_summary,
mean_cost_per_attempt: efficiency.mean_cost_per_attempt,
cost_per_solve: efficiency.cost_per_solve,
mean_tokens_per_attempt: efficiency.mean_tokens_per_attempt,
mean_turns_per_attempt: efficiency.mean_turns_per_attempt,
}],
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task::{EvalCategory, EvalRunResult, EvalTask, RunOutcome};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::time::{Duration, sleep};
struct FakeExecutor {
outcomes: Vec<RunOutcome>,
calls: AtomicUsize,
}
#[async_trait]
impl EvalExecutor for FakeExecutor {
async fn execute_task(&self, _task: &EvalTask) -> Result<EvalRunResult> {
let i = self.calls.fetch_add(1, Ordering::SeqCst);
let outcome = self.outcomes[i % self.outcomes.len()];
Ok(EvalRunResult {
task_id: _task.id.clone(),
outcome,
error_message: None,
duration_secs: 0.0,
attempt: (i + 1) as u32,
cost_usd: None,
transcript_path: None,
trace_summary: None,
})
}
}
fn suite(attempts: u32) -> EvalSuite {
EvalSuite {
id: "s1".into(),
name: "demo".into(),
tasks: vec![
EvalTask {
id: "t1".into(),
name: "t1".into(),
category: EvalCategory::Capability,
prompt: "p".into(),
verify_commands: vec![],
timeout_secs: None,
},
EvalTask {
id: "t2".into(),
name: "t2".into(),
category: EvalCategory::Regression,
prompt: "p".into(),
verify_commands: vec![],
timeout_secs: None,
},
],
attempts,
}
}
#[tokio::test]
async fn run_suite_aggregates_capability_and_regression() {
let exec = FakeExecutor {
outcomes: vec![RunOutcome::Pass, RunOutcome::Fail, RunOutcome::Pass, RunOutcome::Pass],
calls: AtomicUsize::new(0),
};
let report = run_suite(&exec, &suite(2)).await.unwrap();
let s = &report.suites[0];
assert_eq!(s.aggregate.passed_runs, 3);
assert_eq!(s.aggregate.total_runs, 4);
assert!((s.capability_metrics.pass_at_k - 1.0).abs() < 1e-9);
assert!((s.regression_metrics.pass_at_k - 1.0).abs() < 1e-9);
assert!(s.capability_metrics.pass_all_k < 1.0);
assert_eq!(s.regression_metrics.pass_all_k, 1.0);
}
struct BoundedExecutor {
active: AtomicUsize,
max_active: AtomicUsize,
}
#[async_trait]
impl EvalExecutor for BoundedExecutor {
async fn execute_task(&self, task: &EvalTask) -> Result<EvalRunResult> {
self.execute_task_attempt(task, 1).await
}
async fn execute_task_attempt(&self, task: &EvalTask, attempt: u32) -> Result<EvalRunResult> {
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.max_active.fetch_max(active, Ordering::SeqCst);
sleep(Duration::from_millis(5)).await;
self.active.fetch_sub(1, Ordering::SeqCst);
Ok(EvalRunResult {
task_id: task.id.clone(),
outcome: RunOutcome::Pass,
error_message: None,
duration_secs: 0.005,
attempt,
cost_usd: Some(0.0),
transcript_path: None,
trace_summary: None,
})
}
}
#[tokio::test]
async fn run_suite_bounds_attempts_and_keeps_report_order() {
let executor = Arc::new(BoundedExecutor {
active: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
});
let report =
run_suite_with_options(executor.as_ref(), &suite(4), EvalRunOptions { concurrency: 2, metric_k: Some(2) })
.await
.expect("bounded suite should complete");
assert!(executor.max_active.load(Ordering::SeqCst) <= 2);
let task_reports = &report.suites[0].task_reports;
assert_eq!(
task_reports
.iter()
.map(|report| report.metric.task_id.as_str())
.collect::<Vec<_>>(),
["t1", "t2"]
);
assert_eq!(report.suites[0].unpriced_runs, 0);
assert_eq!(report.suites[0].cost_usd, Some(0.0));
}
struct IdentityViolatingExecutor;
#[async_trait]
impl EvalExecutor for IdentityViolatingExecutor {
async fn execute_task(&self, task: &EvalTask) -> Result<EvalRunResult> {
Ok(EvalRunResult {
task_id: task.id.clone(),
outcome: RunOutcome::Pass,
error_message: None,
duration_secs: 0.0,
attempt: 1,
cost_usd: None,
transcript_path: None,
trace_summary: None,
})
}
async fn execute_task_attempt(&self, task: &EvalTask, _attempt: u32) -> Result<EvalRunResult> {
let mut result = self.execute_task(task).await?;
result.task_id = "wrong-task".into();
result.attempt = 99;
Ok(result)
}
}
#[tokio::test]
async fn run_suite_rejects_executor_identity_mismatch() {
let error = run_suite(&IdentityViolatingExecutor, &suite(1))
.await
.expect_err("executor identity mismatch must fail the suite");
assert!(error.to_string().contains("executor returned task id"));
}
}