use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CaseOutcome {
pub name: String,
pub passed: bool,
pub predicates: Vec<(String, bool)>,
pub latency: Duration,
pub tokens: Option<u32>,
pub tool_calls: Vec<String>,
pub final_text: Option<String>,
pub error: Option<String>,
#[serde(default)]
pub transcript: Vec<Value>,
}
impl CaseOutcome {
#[allow(clippy::too_many_arguments)]
pub fn new(
name: String,
passed: bool,
predicates: Vec<(String, bool)>,
latency: Duration,
tokens: Option<u32>,
tool_calls: Vec<String>,
final_text: Option<String>,
error: Option<String>,
transcript: Vec<Value>,
) -> Self {
Self {
name,
passed,
predicates,
latency,
tokens,
tool_calls,
final_text,
error,
transcript,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalReport {
pub outcomes: Vec<CaseOutcome>,
pub temperature: f32,
pub backend: String,
#[serde(default)]
pub system_prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub model: String,
pub timestamp_display: String,
pub timestamp_file: String,
pub backend: String,
pub cases_dir: String,
#[serde(default)]
pub system_prompt: String,
pub report: EvalReport,
}
impl EvalReport {
pub fn new(
outcomes: Vec<CaseOutcome>,
temperature: f32,
backend: String,
system_prompt: String,
) -> Self {
Self {
outcomes,
temperature,
backend,
system_prompt,
}
}
pub fn total(&self) -> usize {
self.outcomes.len()
}
pub fn passed(&self) -> usize {
self.outcomes.iter().filter(|o| o.passed).count()
}
pub fn accuracy(&self) -> f64 {
if self.outcomes.is_empty() {
0.0
} else {
self.passed() as f64 / self.total() as f64
}
}
pub fn mean_latency(&self) -> Duration {
if self.outcomes.is_empty() {
return Duration::ZERO;
}
let total: Duration = self.outcomes.iter().map(|o| o.latency).sum();
total / self.outcomes.len() as u32
}
pub fn latency_percentile(&self, p: f64) -> Duration {
if self.outcomes.is_empty() {
return Duration::ZERO;
}
let mut lats: Vec<Duration> = self.outcomes.iter().map(|o| o.latency).collect();
lats.sort_unstable();
let n = lats.len();
let rank = (p * n as f64).ceil().max(1.0) as usize;
lats[rank.min(n) - 1]
}
pub fn p50_latency(&self) -> Duration {
self.latency_percentile(0.50)
}
pub fn p95_latency(&self) -> Duration {
self.latency_percentile(0.95)
}
pub fn total_tokens(&self) -> Option<u32> {
let reported: Vec<u32> = self.outcomes.iter().filter_map(|o| o.tokens).collect();
if reported.is_empty() {
None
} else {
Some(reported.iter().sum())
}
}
pub fn mean_tokens(&self) -> Option<f64> {
let reported: Vec<u32> = self.outcomes.iter().filter_map(|o| o.tokens).collect();
if reported.is_empty() {
None
} else {
Some(reported.iter().map(|&t| t as f64).sum::<f64>() / reported.len() as f64)
}
}
}
fn ms(d: Duration) -> String {
format!("{:.1}ms", d.as_secs_f64() * 1000.0)
}
impl std::fmt::Display for EvalReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "AI eval report")?;
writeln!(f, " backend: {}", self.backend)?;
writeln!(f, " temperature: {} (0 = deterministic)", self.temperature)?;
writeln!(
f,
" accuracy: {}/{} cases passed ({:.0}%)",
self.passed(),
self.total(),
self.accuracy() * 100.0
)?;
writeln!(
f,
" latency: mean {} p50 {} p95 {}",
ms(self.mean_latency()),
ms(self.p50_latency()),
ms(self.p95_latency())
)?;
match (self.total_tokens(), self.mean_tokens()) {
(Some(total), Some(mean)) => writeln!(
f,
" tokens: {total} completion total, {mean:.0} mean/case (cases reporting usage)"
)?,
_ => writeln!(
f,
" tokens: unavailable (no turn reported a `usage` object)"
)?,
}
writeln!(f, " cases:")?;
let name_w = self
.outcomes
.iter()
.map(|o| o.name.len())
.max()
.unwrap_or(4)
.max(4);
for o in &self.outcomes {
let mark = if o.passed { "PASS" } else { "FAIL" };
let passed_preds = o.predicates.iter().filter(|(_, p)| *p).count();
write!(
f,
" [{mark}] {:<name_w$} {} preds {}/{} calls {}",
o.name,
ms(o.latency),
passed_preds,
o.predicates.len(),
o.tool_calls.len(),
)?;
if let Some(t) = o.tokens {
write!(f, " tok {t}")?;
}
writeln!(f)?;
if !o.passed {
for call in &o.tool_calls {
writeln!(f, " - emitted: {call}")?;
}
for (label, passed) in &o.predicates {
if !passed {
writeln!(f, " - FAILED: {label}")?;
}
}
if let Some(err) = &o.error {
writeln!(f, " - run error: {err}")?;
}
}
}
Ok(())
}
}