use crate::content::{Message, Part, Role};
use crate::eval::{Eval, Responder};
use crate::target::Target;
use crate::{Params, RunCx, Sample, Score, Transcript, Trial, case_key, trial_suffix};
pub async fn execute_case(
eval: &Eval,
sample: &Sample,
target: &Target,
params: &Params,
trial: Trial,
) -> Transcript {
let mut cx = RunCx {
target: target.clone(),
max_turns: eval.max_turns,
params: params.clone(),
trial,
conversation: Vec::new(),
};
let mut transcript = match &eval.responder {
None => eval.subject.run(sample, &cx).await,
Some(responder) => drive_interactive(eval, sample, &mut cx, responder.as_ref()).await,
};
transcript.project_trajectory();
transcript
}
async fn drive_interactive(
eval: &Eval,
sample: &Sample,
cx: &mut RunCx,
responder: &Responder,
) -> Transcript {
let mut convo: Vec<Message> = vec![Message::new(Role::User, sample.prompt_parts())];
let mut combined = Transcript::default();
let cap = eval.max_turns.max(1);
for turn in 1..=cap {
cx.conversation = convo.clone();
let mut t = eval.subject.run(sample, cx).await;
t.project_trajectory();
merge_turn(&mut combined, &t);
combined.iterations = turn;
convo.push(Message::new(Role::Assistant, assistant_parts(&t)));
if t.error.is_some() {
combined.error = t.error.clone();
combined.error_kind = t.error_kind;
break;
}
if turn == cap {
break;
}
match responder(&convo) {
Some(parts) if !parts.is_empty() => convo.push(Message::new(Role::User, parts)),
_ => break,
}
}
combined.tool_calls_count = combined.tool_calls.len();
combined
}
fn merge_turn(combined: &mut Transcript, t: &Transcript) {
combined.final_response = t.final_response.clone();
combined.usage.add(&t.usage);
combined.timing.duration_ms += t.timing.duration_ms;
if combined.timing.time_to_first_token_ms.is_none() {
combined.timing.time_to_first_token_ms = t.timing.time_to_first_token_ms;
}
combined.tool_calls.extend(t.tool_calls.iter().cloned());
combined.events.extend(t.events.iter().cloned());
for (k, v) in &t.files {
combined.files.insert(k.clone(), v.clone());
}
for (k, v) in &t.metrics {
combined.metrics.insert(k.clone(), *v);
}
for (k, v) in &t.metadata {
combined.metadata.insert(k.clone(), v.clone());
}
combined.output = t.output.clone();
}
fn assistant_parts(t: &Transcript) -> Vec<Part> {
if !t.output.is_empty() {
return t.output.clone();
}
vec![Part::text(t.final_response.clone())]
}
pub async fn score_transcript(eval: &Eval, sample: &Sample, transcript: &Transcript) -> Vec<Score> {
if transcript.errored_infra() {
let reason = transcript
.error
.clone()
.unwrap_or_else(|| "infra error".into());
return vec![Score::na("infra", reason)];
}
let mut scores = Vec::with_capacity(eval.scorers.len());
for scorer in &eval.scorers {
scores.push(scorer.score(sample, transcript).await);
}
scores
}
pub fn verdict(scores: &[Score]) -> bool {
scores.iter().any(|s| !s.na) && scores.iter().filter(|s| !s.na).all(|s| s.pass)
}
pub async fn run_case(
eval: &Eval,
sample: &Sample,
target: &Target,
params: &Params,
trial: Trial,
) -> CaseOutcome {
let transcript = execute_case(eval, sample, target, params, trial).await;
let scores = score_transcript(eval, sample, &transcript).await;
let passed = verdict(&scores);
let aggregate = aggregate_value(&scores);
CaseOutcome {
eval: eval.name.clone(),
sample_id: sample.id.clone(),
target: target.label.clone(),
params: params.clone(),
trial,
scores,
passed,
aggregate,
transcript,
}
}
pub fn aggregate_value(scores: &[Score]) -> f64 {
let mut sum = 0.0;
let mut count = 0usize;
for s in scores {
if !s.na {
sum += s.value;
count += 1;
}
}
if count == 0 { 0.0 } else { sum / count as f64 }
}
#[derive(Clone, Debug)]
pub struct CaseOutcome {
pub eval: String,
pub sample_id: String,
pub target: String,
pub params: Params,
pub trial: Trial,
pub scores: Vec<Score>,
pub passed: bool,
pub aggregate: f64,
pub transcript: Transcript,
}
impl CaseOutcome {
pub fn key(&self) -> String {
format!(
"{}{}",
self.logical_key(),
trial_suffix(self.trial.index, self.trial.count)
)
}
pub fn logical_key(&self) -> String {
case_key(&self.eval, &self.sample_id, &self.target, &self.params)
}
}
#[derive(Clone, Debug, Default)]
pub struct RunReport {
pub outcomes: Vec<CaseOutcome>,
pub skipped: Vec<String>,
}
impl RunReport {
pub fn total(&self) -> usize {
self.outcomes.len()
}
pub fn passed(&self) -> usize {
self.outcomes.iter().filter(|o| o.passed).count()
}
pub fn failed(&self) -> usize {
self.total() - self.passed()
}
pub fn all_passed(&self) -> bool {
self.failed() == 0
}
}
#[derive(Default)]
pub struct Runner {
evals: Vec<Eval>,
filter: Option<String>,
tag: Option<String>,
targets: Option<Vec<String>>,
samples: Option<Vec<String>>,
}
impl Runner {
pub fn new() -> Self {
Self::default()
}
#[allow(clippy::should_implement_trait)]
pub fn add(mut self, eval: Eval) -> Self {
self.evals.push(eval);
self
}
pub fn extend(mut self, evals: impl IntoIterator<Item = Eval>) -> Self {
self.evals.extend(evals);
self
}
pub fn filter(mut self, filter: Option<String>) -> Self {
self.filter = filter;
self
}
pub fn tag(mut self, tag: Option<String>) -> Self {
self.tag = tag;
self
}
pub fn targets(mut self, targets: Option<Vec<String>>) -> Self {
self.targets = targets;
self
}
pub fn samples(mut self, samples: Option<Vec<String>>) -> Self {
self.samples = samples;
self
}
fn selected(&self, key: &str, sample: &Sample, target: &Target) -> bool {
if let Some(f) = &self.filter
&& !key.contains(f.as_str())
{
return false;
}
if let Some(tag) = &self.tag
&& !sample.tags.iter().any(|t| t == tag)
{
return false;
}
if let Some(allow) = &self.samples
&& !allow.iter().any(|p| crate::glob_match(p, &sample.id))
{
return false;
}
if let Some(allow) = &self.targets
&& !allow.iter().any(|p| crate::glob_match(p, &target.label))
{
return false;
}
true
}
pub async fn run(&self) -> RunReport {
let mut report = RunReport::default();
for eval in &self.evals {
let combos = eval.axis_combinations();
let trials = eval.trials.max(1);
for target in &eval.targets {
for sample in &eval.dataset.samples {
for params in &combos {
let key = case_key(&eval.name, &sample.id, &target.label, params);
if !self.selected(&key, sample, target) {
continue;
}
if !target.available {
report.skipped.push(format!("{key} (unavailable)"));
continue;
}
for index in 0..trials {
let trial = Trial {
index,
count: trials,
seed: eval.seed.map(|s| s.wrapping_add(index as u64)),
};
report
.outcomes
.push(run_case(eval, sample, target, params, trial).await);
}
}
}
}
}
report
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scorer::contains;
use crate::subject::subject_fn;
fn echo_eval(name: &str) -> Eval {
Eval::new(name)
.add_sample(Sample::new("hi", "say hi").tag("smoke"))
.add_sample(Sample::new("bye", "say bye"))
.subject(subject_fn(|s, _| async move {
Transcript::response(s.input.join(" "))
}))
.scorer(contains("say"))
.build()
}
#[tokio::test]
async fn runs_all_cases() {
let report = Runner::new().add(echo_eval("greet")).run().await;
assert_eq!(report.total(), 2);
assert!(report.all_passed());
}
#[tokio::test]
async fn filter_selects_by_key() {
let report = Runner::new()
.add(echo_eval("greet"))
.filter(Some("hi".into()))
.run()
.await;
assert_eq!(report.total(), 1);
assert_eq!(report.outcomes[0].sample_id, "hi");
}
#[tokio::test]
async fn samples_select_by_glob() {
let report = Runner::new()
.add(echo_eval("greet"))
.samples(Some(vec!["b*".into()]))
.run()
.await;
assert_eq!(report.total(), 1);
assert_eq!(report.outcomes[0].sample_id, "bye");
}
#[tokio::test]
async fn tag_narrows() {
let report = Runner::new()
.add(echo_eval("greet"))
.tag(Some("smoke".into()))
.run()
.await;
assert_eq!(report.total(), 1);
}
#[tokio::test]
async fn unavailable_model_is_skipped_not_failed() {
let eval = Eval::new("e")
.sample("a", "x")
.targets([Target::sim().available(false)])
.subject(subject_fn(|_, _| async { Transcript::response("x") }))
.scorer(contains("x"))
.build();
let report = Runner::new().add(eval).run().await;
assert_eq!(report.total(), 0);
assert_eq!(report.skipped.len(), 1);
assert!(report.all_passed()); }
#[tokio::test]
async fn na_scores_are_excluded_from_verdict_and_aggregate() {
use crate::scorer::scorer;
let eval = Eval::new("e")
.sample("a", "x")
.subject(subject_fn(|_, _| async { Transcript::response("x") }))
.scorer(contains("x"))
.scorer(scorer("judge", |_, _| Score::na("judge", "unreachable")))
.build();
let report = Runner::new().add(eval).run().await;
let out = &report.outcomes[0];
assert!(out.passed);
assert_eq!(out.aggregate, 1.0);
let all_na = Eval::new("e2")
.sample("a", "x")
.subject(subject_fn(|_, _| async { Transcript::response("x") }))
.scorer(scorer("judge", |_, _| Score::na("judge", "unreachable")))
.build();
let report = Runner::new().add(all_na).run().await;
assert!(!report.outcomes[0].passed);
assert_eq!(report.outcomes[0].aggregate, 0.0);
}
#[tokio::test]
async fn infra_error_short_circuits_scoring_to_na() {
let eval = Eval::new("e")
.sample("a", "x")
.subject(subject_fn(|_, _| async {
Transcript::infra_error("provider 503: service unavailable")
}))
.scorer(contains("x"))
.build();
let report = Runner::new().add(eval).run().await;
let out = &report.outcomes[0];
assert_eq!(out.scores.len(), 1);
assert!(out.scores[0].na); assert_eq!(out.scores[0].scorer, "infra");
assert!(!out.passed); assert_eq!(out.aggregate, 0.0); }
#[tokio::test]
async fn trials_repeat_the_case_with_seeded_reproducibility() {
let eval = Eval::new("e")
.sample("a", "x")
.trials(3)
.seed(100)
.subject(subject_fn(|_, cx| async move {
Transcript::response(format!("seed={:?}", cx.seed()))
}))
.scorer(contains("seed="))
.build();
let report = Runner::new().add(eval).run().await;
assert_eq!(report.total(), 3, "one outcome per trial");
let mut keys: Vec<String> = report.outcomes.iter().map(|o| o.key()).collect();
keys.sort();
assert_eq!(keys, vec!["e/a@sim#0", "e/a@sim#1", "e/a@sim#2"]);
for o in &report.outcomes {
assert_eq!(o.logical_key(), "e/a@sim");
let expected = 100 + o.trial.index as u64;
assert_eq!(o.trial.seed, Some(expected));
assert!(o.transcript.final_response.contains(&expected.to_string()));
}
}
#[tokio::test]
async fn single_trial_keeps_plain_key() {
let eval = Eval::new("e")
.sample("a", "x")
.subject(subject_fn(|_, cx| async move {
assert_eq!(cx.seed(), None);
Transcript::response("x")
}))
.scorer(contains("x"))
.build();
let report = Runner::new().add(eval).run().await;
assert_eq!(report.total(), 1);
assert_eq!(report.outcomes[0].key(), "e/a@sim");
}
#[tokio::test]
async fn interactive_eval_exchanges_turns() {
use crate::scorer::{succeeded, turns_within};
let eval = Eval::new("chat")
.sample("open", "hello")
.max_turns(3)
.subject(subject_fn(|_, cx| async move {
Transcript::response(format!("reply to {} msgs", cx.conversation.len()))
.with_metric("turn_len", cx.conversation.len() as f64)
}))
.responder(|convo: &[Message]| {
Some(vec![Part::text(format!("more ({})", convo.len()))])
})
.scorer(succeeded())
.scorer(turns_within(3))
.build();
let report = Runner::new().add(eval).run().await;
let out = &report.outcomes[0];
assert!(out.passed);
assert_eq!(out.transcript.iterations, 3);
assert!(out.transcript.final_response.contains("5 msgs"));
}
#[tokio::test]
async fn interactive_responder_can_end_early() {
use crate::scorer::succeeded;
let eval = Eval::new("chat")
.sample("open", "hi")
.max_turns(10)
.subject(subject_fn(|_, _| async { Transcript::response("ok") }))
.responder(|_: &[Message]| None)
.scorer(succeeded())
.build();
let report = Runner::new().add(eval).run().await;
assert_eq!(report.outcomes[0].transcript.iterations, 1);
}
#[tokio::test]
async fn empty_scorers_means_not_passed() {
let eval = Eval::new("e")
.sample("a", "x")
.subject(subject_fn(|_, _| async { Transcript::response("x") }))
.build();
let report = Runner::new().add(eval).run().await;
assert!(!report.outcomes[0].passed);
assert_eq!(report.outcomes[0].aggregate, 0.0);
}
}