use serde::{Deserialize, Serialize};
use super::config::HarnessConfig;
use super::task::StreamResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessReport {
pub timestamp: String,
pub model: String,
pub endpoint: String,
pub max_concurrent: usize,
pub tasks_total: usize,
pub tasks_passed: usize,
pub tasks_failed: usize,
pub total_prompt_tokens: u64,
pub total_completion_tokens: u64,
pub tokens_per_sec: f64,
pub latency_p50_ms: u64,
pub latency_p95_ms: u64,
pub latency_p99_ms: u64,
pub latency_avg_ms: f64,
pub latency_min_ms: u64,
pub latency_max_ms: u64,
pub avg_score: f64,
pub total_duration_secs: f64,
pub error_rate: f64,
pub results: Vec<StreamResult>,
}
impl HarnessReport {
pub fn from_results(
config: &HarnessConfig,
mut results: Vec<StreamResult>,
total_duration_secs: f64,
) -> Self {
let tasks_total = results.len();
let tasks_passed = results.iter().filter(|r| r.success).count();
let tasks_failed = tasks_total - tasks_passed;
let total_prompt_tokens: u64 = results.iter().map(|r| r.prompt_tokens).sum();
let total_completion_tokens: u64 = results.iter().map(|r| r.completion_tokens).sum();
let tokens_per_sec = if total_duration_secs > 0.0 {
total_completion_tokens as f64 / total_duration_secs
} else {
0.0
};
let mut latencies: Vec<u64> = results
.iter()
.filter(|r| r.transport_succeeded)
.map(|r| r.latency_ms)
.collect();
latencies.sort_unstable();
let latency_p50_ms = percentile(&latencies, 50);
let latency_p95_ms = percentile(&latencies, 95);
let latency_p99_ms = percentile(&latencies, 99);
let latency_avg_ms = if latencies.is_empty() {
0.0
} else {
latencies.iter().sum::<u64>() as f64 / latencies.len() as f64
};
let latency_min_ms = latencies.first().copied().unwrap_or(0);
let latency_max_ms = latencies.last().copied().unwrap_or(0);
let scored: Vec<f64> = results
.iter()
.filter_map(|r| r.eval.as_ref().map(|e| e.score))
.collect();
let avg_score = if scored.is_empty() {
0.0
} else {
scored.iter().sum::<f64>() / scored.len() as f64
};
let error_rate = if tasks_total > 0 {
tasks_failed as f64 / tasks_total as f64
} else {
0.0
};
results.sort_by(|a, b| a.task_id.cmp(&b.task_id));
let timestamp = chrono::Utc::now().to_rfc3339();
Self {
timestamp,
model: config.model.clone(),
endpoint: config.endpoint.clone(),
max_concurrent: config.max_concurrent,
tasks_total,
tasks_passed,
tasks_failed,
total_prompt_tokens,
total_completion_tokens,
tokens_per_sec,
latency_p50_ms,
latency_p95_ms,
latency_p99_ms,
latency_avg_ms,
latency_min_ms,
latency_max_ms,
avg_score,
total_duration_secs,
error_rate,
results,
}
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn to_markdown(&self) -> String {
let mut md = String::new();
md.push_str(&format!("# Benchmark Report — {}\n", self.model));
md.push_str(&format!(
"**Date**: {} | **Endpoint**: {} | **Concurrency**: {}\n\n",
self.timestamp, self.endpoint, self.max_concurrent,
));
md.push_str("## Summary\n\n");
md.push_str("| Metric | Value |\n|--------|-------|\n");
md.push_str(&format!(
"| Tasks | {}/{} passed ({:.0}% error rate) |\n",
self.tasks_passed,
self.tasks_total,
self.error_rate * 100.0,
));
md.push_str(&format!("| Avg Score | {:.1}% |\n", self.avg_score * 100.0,));
md.push_str(&format!(
"| Throughput | {:.0} tok/s |\n",
self.tokens_per_sec,
));
md.push_str(&format!(
"| Tokens | {} prompt + {} completion |\n",
format_tokens(self.total_prompt_tokens),
format_tokens(self.total_completion_tokens),
));
md.push_str(&format!(
"| Duration | {:.1}s |\n",
self.total_duration_secs,
));
md.push_str("\n## Latency\n\n");
md.push_str("| Percentile | ms |\n|-----------|----|\n");
md.push_str(&format!("| p50 | {} |\n", self.latency_p50_ms));
md.push_str(&format!("| p95 | {} |\n", self.latency_p95_ms));
md.push_str(&format!("| p99 | {} |\n", self.latency_p99_ms));
md.push_str(&format!("| avg | {:.0} |\n", self.latency_avg_ms));
md.push_str(&format!(
"| min/max | {}/{} |\n",
self.latency_min_ms, self.latency_max_ms,
));
md.push_str("\n## Per-Task Results\n\n");
md.push_str("| Task | Status | Score | Latency | Tokens |\n");
md.push_str("|------|--------|-------|---------|--------|\n");
for r in &self.results {
let status = if r.success { "PASS" } else { "FAIL" };
let score = r
.eval
.as_ref()
.map(|e| format!("{:.0}%", e.score * 100.0))
.unwrap_or_else(|| "N/A".into());
md.push_str(&format!(
"| {} | {} | {} | {}ms | {}+{} |\n",
r.task_id, status, score, r.latency_ms, r.prompt_tokens, r.completion_tokens,
));
}
md
}
pub fn write_to_dir(&self, dir: &std::path::Path) -> anyhow::Result<()> {
std::fs::create_dir_all(dir)?;
std::fs::write(dir.join("bench_report.json"), self.to_json()?)?;
std::fs::write(dir.join("bench_report.md"), self.to_markdown())?;
Ok(())
}
}
fn percentile(sorted: &[u64], pct: usize) -> u64 {
if sorted.is_empty() {
return 0;
}
let idx = (pct as f64 / 100.0 * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn format_tokens(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result.chars().rev().collect()
}
#[cfg(test)]
#[path = "../../tests/unit/bench_harness/report/report_test.rs"]
mod tests;