use serde::{Deserialize, Serialize};
use super::{ChildAnswer, Verdict, VerdictLine};
#[must_use]
pub fn count_f64(n: usize) -> f64 {
f64::from(u32::try_from(n).unwrap_or(u32::MAX))
}
#[must_use]
pub fn percentile(sorted: &[f64], percent: usize) -> Option<f64> {
let rank = percent.saturating_mul(sorted.len()).div_ceil(100).max(1);
sorted.get(rank - 1).copied()
}
fn mean(values: &[f64]) -> Option<f64> {
(!values.is_empty()).then(|| values.iter().sum::<f64>() / count_f64(values.len()))
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Summary {
pub questions: usize,
pub wins: usize,
pub ties: usize,
pub losses: usize,
pub unparsed: usize,
pub errors: usize,
#[serde(default)]
pub truncated: usize,
pub win_or_tie: Option<f64>,
pub latency_p50: Option<f64>,
pub latency_p95: Option<f64>,
pub first_token_p50: Option<f64>,
pub tokens_per_second: Option<f64>,
pub mean_seconds: Option<f64>,
}
#[must_use]
pub fn summarize(verdicts: &[VerdictLine], answers: &[ChildAnswer]) -> Summary {
let count = |verdict| {
verdicts
.iter()
.filter(|line| line.verdict == verdict)
.count()
};
let (wins, ties, losses, unparsed, errors) = (
count(Verdict::Win),
count(Verdict::Tie),
count(Verdict::Loss),
count(Verdict::Unparsed),
count(Verdict::Error),
);
let counted = verdicts.len() - unparsed;
let sorted = |pick: fn(&ChildAnswer) -> Option<f64>| {
let mut values: Vec<f64> = answers
.iter()
.filter(|answer| !answer.is_error())
.filter_map(pick)
.filter(|value| value.is_finite())
.collect();
values.sort_by(f64::total_cmp);
values
};
let seconds = sorted(|answer| answer.seconds);
let first = sorted(|answer| answer.first_token_seconds);
let rates = sorted(|answer| answer.tokens_per_second);
Summary {
questions: verdicts.len(),
wins,
ties,
losses,
unparsed,
errors,
truncated: answers
.iter()
.filter(|answer| !answer.is_error() && answer.finish.as_deref() == Some("length"))
.count(),
win_or_tie: (counted > 0).then(|| count_f64(wins + ties) / count_f64(counted)),
latency_p50: percentile(&seconds, 50),
latency_p95: percentile(&seconds, 95),
first_token_p50: percentile(&first, 50),
tokens_per_second: mean(&rates),
mean_seconds: mean(&seconds),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(id: &str, verdict: Verdict) -> VerdictLine {
VerdictLine {
id: id.into(),
verdict,
reason: None,
child_first: true,
}
}
fn answer(id: &str, seconds: f64) -> ChildAnswer {
ChildAnswer {
id: id.into(),
answer: Some("A.".into()),
seconds: Some(seconds),
first_token_seconds: Some(seconds / 10.0),
tokens_per_second: Some(50.0),
..ChildAnswer::default()
}
}
fn close(value: Option<f64>, expected: f64) -> bool {
value.is_some_and(|value| (value - expected).abs() < 1e-9)
}
#[test]
fn percentiles_use_the_nearest_rank() {
let sorted = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
assert_eq!(percentile(&sorted, 50), Some(5.0));
assert_eq!(percentile(&sorted, 95), Some(10.0));
assert_eq!(percentile(&[3.0], 95), Some(3.0));
assert_eq!(percentile(&[], 50), None);
}
#[test]
fn the_rate_leaves_out_unparsed_and_counts_errors_as_losses() {
let verdicts = [
line("q1", Verdict::Win),
line("q2", Verdict::Tie),
line("q3", Verdict::Loss),
line("q4", Verdict::Unparsed),
line("q5", Verdict::Error),
];
let answers = [
answer("q1", 1.0),
answer("q2", 2.0),
answer("q3", 3.0),
answer("q4", 4.0),
];
let summary = summarize(&verdicts, &answers);
assert_eq!(
(
summary.questions,
summary.wins,
summary.ties,
summary.losses,
summary.unparsed,
summary.errors
),
(5, 1, 1, 1, 1, 1)
);
assert!(close(summary.win_or_tie, 0.5));
assert!(close(summary.latency_p50, 2.0));
assert!(close(summary.latency_p95, 4.0));
assert!(close(summary.mean_seconds, 2.5));
assert!(close(summary.tokens_per_second, 50.0));
}
#[test]
fn answers_cut_at_the_token_limit_are_counted() {
let cut = |id: &str, finish: &str| ChildAnswer {
finish: Some(finish.into()),
..answer(id, 1.0)
};
let failed = ChildAnswer {
id: "q4".into(),
finish: Some("length".into()),
error: Some("timeout".into()),
..ChildAnswer::default()
};
let answers = [
cut("q1", "length"),
cut("q2", "length"),
cut("q3", "stop"),
failed,
];
let summary = summarize(&[line("q1", Verdict::Loss)], &answers);
assert_eq!(summary.truncated, 2);
assert_eq!(summarize(&[], &[]).truncated, 0);
}
#[test]
fn an_empty_compare_has_no_rate() {
let summary = summarize(&[], &[]);
assert_eq!(summary.win_or_tie, None);
assert_eq!(summary.latency_p50, None);
}
}