use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::types::{read_json, write_json};
use super::{
ChildAnswer, CompareError, CompareSetup, Costs, Hardware, Prices, REPORT_JSON, REPORT_MD,
Summary, Verdict, VerdictLine, costs, summarize,
};
use crate::config::RoleModel;
const SHOWN: usize = 5;
const SMALL_SAMPLE: usize = 100;
const SHA_SHOWN: usize = 12;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuestionResult {
pub id: String,
pub topic: String,
pub question: String,
pub child: Option<String>,
pub parent: String,
pub verdict: Verdict,
pub reason: Option<String>,
pub seconds: Option<f64>,
pub first_token_seconds: Option<f64>,
pub tokens_per_second: Option<f64>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub run: String,
pub compare: String,
pub base_model: Option<String>,
pub quantize: String,
pub gguf_sha256: String,
pub llama_cpp: String,
pub hardware: Option<Hardware>,
pub judge: String,
pub judge_is_parent: bool,
pub verdicts_file: String,
pub created: String,
pub seed: u64,
pub summary: Summary,
pub costs: Costs,
pub child_price_from_pod: bool,
pub questions: Vec<QuestionResult>,
}
impl Report {
pub fn load(dir: &Path) -> Result<Self, CompareError> {
read_json(&dir.join(REPORT_JSON))
}
}
#[derive(Debug, Clone, Copy)]
pub struct JudgeInfo<'a> {
pub role: &'a RoleModel,
pub is_parent: bool,
pub verdicts_file: &'a str,
}
#[derive(Debug, Clone)]
pub struct Parts<'a> {
pub setup: &'a CompareSetup,
pub answers: &'a [ChildAnswer],
pub verdicts: &'a [VerdictLine],
pub hardware: Option<Hardware>,
pub judge: JudgeInfo<'a>,
pub prices: Prices,
pub child_price_from_pod: bool,
}
#[must_use]
pub fn build(parts: &Parts<'_>) -> Report {
let setup = parts.setup;
let answers: HashMap<&str, &ChildAnswer> = parts
.answers
.iter()
.map(|answer| (answer.id.as_str(), answer))
.collect();
let verdicts: HashMap<&str, &VerdictLine> = parts
.verdicts
.iter()
.map(|line| (line.id.as_str(), line))
.collect();
let questions: Vec<QuestionResult> = setup
.questions
.iter()
.filter_map(|question| {
let line = verdicts.get(question.id.as_str())?;
let answer = answers.get(question.id.as_str());
Some(QuestionResult {
id: question.id.clone(),
topic: question.topic.clone(),
question: question.text().to_string(),
child: answer
.filter(|answer| !answer.is_error())
.and_then(|answer| answer.answer.as_deref())
.map(super::strip_reasoning),
parent: question.parent.clone(),
verdict: line.verdict,
reason: line.reason.clone(),
seconds: answer.and_then(|answer| answer.seconds),
first_token_seconds: answer.and_then(|answer| answer.first_token_seconds),
tokens_per_second: answer.and_then(|answer| answer.tokens_per_second),
error: answer.and_then(|answer| answer.error.clone()),
})
})
.collect();
let summary = summarize(parts.verdicts, parts.answers);
let costs = costs(&parts.prices, &setup.questions, summary.mean_seconds);
Report {
run: setup.run.clone(),
compare: setup.compare.clone(),
base_model: setup.base_model.clone(),
quantize: setup.quantize.clone(),
gguf_sha256: setup.gguf_sha256.clone(),
llama_cpp: setup.llama_cpp.clone(),
hardware: parts.hardware.clone(),
judge: format!("{}/{}", parts.judge.role.provider, parts.judge.role.model),
judge_is_parent: parts.judge.is_parent,
verdicts_file: parts.judge.verdicts_file.to_string(),
created: setup.created.clone(),
seed: setup.seed,
summary,
costs,
child_price_from_pod: parts.child_price_from_pod,
questions,
}
}
pub fn write(dir: &Path, report: &Report) -> Result<PathBuf, CompareError> {
write_json(&dir.join(REPORT_JSON), report)?;
let markdown = dir.join(REPORT_MD);
std::fs::write(&markdown, render_markdown(report))
.map_err(super::error::io_error(&markdown))?;
Ok(markdown)
}
fn percent(rate: f64) -> String {
format!("{:.1}%", rate * 100.0)
}
fn secs(seconds: f64) -> String {
format!("{seconds:.2} s")
}
fn dollars(dollars: f64) -> String {
format!("${dollars:.4}")
}
fn or_dash<T>(value: Option<T>, render: impl Fn(T) -> String) -> String {
value.map_or_else(|| "-".to_string(), render)
}
fn one_line(text: &str, max: usize) -> String {
let line: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if line.chars().count() <= max {
return line;
}
let cut: String = line.chars().take(max.saturating_sub(3)).collect();
format!("{cut}...")
}
fn quoted(text: &str) -> String {
text.lines()
.map(|line| {
if line.is_empty() {
">".to_string()
} else {
format!("> {line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn render_markdown(report: &Report) -> String {
let mut text = String::new();
let _ = writeln!(text, "# Compare of run {}\n", report.run);
render_header(&mut text, report);
render_summary(&mut text, report);
render_examples(&mut text, report);
render_limits(&mut text, report);
text
}
fn render_header(text: &mut String, report: &Report) {
let sha: String = report.gguf_sha256.chars().take(SHA_SHOWN).collect();
let judge = if report.judge_is_parent {
format!("{} (the parent)", report.judge)
} else {
report.judge.clone()
};
let rows = [
("Compare", report.compare.clone()),
(
"Base model",
report
.base_model
.clone()
.unwrap_or_else(|| "unknown".into()),
),
(
"GGUF",
format!(
"{}, llama.cpp {}, SHA-256 {sha}",
report.quantize, report.llama_cpp
),
),
(
"Hardware",
report
.hardware
.as_ref()
.map_or_else(|| "unknown".into(), Hardware::describe),
),
("Judge", judge),
("Questions", report.summary.questions.to_string()),
("Date", report.created.clone()),
];
let _ = writeln!(text, "| | |\n|---|---|");
for (name, value) in rows {
let _ = writeln!(text, "| {name} | {value} |");
}
text.push('\n');
}
fn render_summary(text: &mut String, report: &Report) {
let summary = &report.summary;
let costs = &report.costs;
let unset = "not computed: set [compare] prices";
let child_cost = costs.child_per_1k.map_or_else(
|| unset.to_string(),
|cost| format!("{} (upper bound)", dollars(cost)),
);
let rows = [
("Win or tie", or_dash(summary.win_or_tie, percent)),
(
"Wins / ties / losses",
format!(
"{} / {} / {}",
summary.wins,
summary.ties,
summary.losses + summary.errors
),
),
(
"Unparsed verdicts (not counted)",
summary.unparsed.to_string(),
),
(
"Child errors (counted as losses)",
summary.errors.to_string(),
),
(
"Child answers cut at the token limit",
summary.truncated.to_string(),
),
(
"Latency p50 / p95",
format!(
"{} / {}",
or_dash(summary.latency_p50, secs),
or_dash(summary.latency_p95, secs)
),
),
(
"Time to first token p50",
or_dash(summary.first_token_p50, secs),
),
(
"Output tokens per second",
or_dash(summary.tokens_per_second, |rate| format!("{rate:.1}")),
),
(
"Parent cost per 1,000 requests",
costs
.parent_per_1k
.map_or_else(|| unset.to_string(), dollars),
),
("Child cost per 1,000 requests", child_cost),
("Child cost / parent cost", or_dash(costs.ratio, percent)),
];
let _ = writeln!(text, "## Summary\n\n| Measure | Value |\n|---|---|");
for (name, value) in rows {
let _ = writeln!(text, "| {name} | {value} |");
}
text.push('\n');
}
fn render_examples(text: &mut String, report: &Report) {
let errors = report
.questions
.iter()
.filter(|question| question.verdict == Verdict::Error);
let losses = report
.questions
.iter()
.filter(|question| question.verdict == Verdict::Loss);
let lost: Vec<&QuestionResult> = errors.chain(losses).collect();
let won: Vec<&QuestionResult> = report
.questions
.iter()
.filter(|question| question.verdict == Verdict::Win)
.collect();
for (title, list) in [("Losses", lost), ("Wins", won)] {
if list.is_empty() {
continue;
}
let _ = writeln!(
text,
"## {title} (first {} of {})\n",
list.len().min(SHOWN),
list.len()
);
for question in list.into_iter().take(SHOWN) {
render_example(text, question);
}
}
}
fn render_example(text: &mut String, question: &QuestionResult) {
let _ = writeln!(
text,
"### {}: {}\n",
question.id,
one_line(&question.question, 80)
);
let child = match (&question.child, &question.error) {
(Some(child), _) => quoted(child),
(None, Some(error)) => format!("> (no answer: {error})"),
(None, None) => "> (no answer)".to_string(),
};
let _ = writeln!(text, "Child:\n\n{child}\n");
let _ = writeln!(text, "Parent:\n\n{}\n", quoted(&question.parent));
if let Some(reason) = &question.reason {
let _ = writeln!(text, "Judge: {reason}\n");
}
}
fn render_limits(text: &mut String, report: &Report) {
let summary = &report.summary;
let mut limits = Vec::new();
if report.judge_is_parent {
limits.push(
"The judge is the parent model: it may favor its own answers. Set `roles.judge` to \
another model and run `overbrainer compare --rejudge <compare-id>`."
.to_string(),
);
}
if summary.questions < SMALL_SAMPLE {
limits.push(format!(
"{} questions: a small sample, the rates move by several points from one set to \
another.",
summary.questions
));
}
limits.push(
"Requests were sent one at a time: the latency is that of one user, and the child cost \
is an upper bound (a server under load answers several requests at once)."
.to_string(),
);
if report.child_price_from_pod {
limits.push("The child's hourly price is the Runpod pod's.".to_string());
}
if summary.truncated > 0 {
let answers = if summary.truncated == 1 {
"child answer"
} else {
"child answers"
};
limits.push(format!(
"{} {answers} hit the token limit; raise [compare] max_tokens or the context.",
summary.truncated
));
}
if report
.hardware
.as_ref()
.is_some_and(|hardware| !hardware.has_gpu())
{
limits.push("The child ran on a CPU.".to_string());
}
let _ = writeln!(text, "## Limits\n");
for limit in limits {
let _ = writeln!(text, "- {limit}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compare::fixtures::{answers, judge, sample_report, setup, verdicts};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const SNAPSHOTS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/snapshots/compare");
fn snapshot(name: &str, text: &str) {
let mut settings = insta::Settings::clone_current();
settings.set_snapshot_path(SNAPSHOTS);
settings.set_prepend_module_to_snapshot(false);
settings.set_omit_expression(true);
settings.bind(|| insta::assert_snapshot!(name.to_string(), text));
}
#[test]
fn report_with_prices() -> TestResult {
let report = sample_report()?;
assert_eq!(report.summary.win_or_tie, Some(5.0 / 7.0));
snapshot("report_with_prices", &render_markdown(&report));
Ok(())
}
#[test]
fn report_without_prices_on_cpu() -> TestResult {
let (setup, mut answers, verdicts, role) = (setup(), answers(), verdicts(), judge()?);
for answer in answers
.iter_mut()
.filter(|answer| answer.id == "q5" || answer.id == "q6")
{
answer.finish = Some("length".into());
}
let report = build(&Parts {
setup: &setup,
answers: &answers,
verdicts: &verdicts,
hardware: Some(Hardware {
build: "ubuntu-x64".into(),
gpus: Vec::new(),
cpu: Some("AMD EPYC 7413".into()),
}),
judge: JudgeInfo {
role: &role,
is_parent: false,
verdicts_file: "verdicts-0123456789abcdef.jsonl",
},
prices: Prices::default(),
child_price_from_pod: false,
});
assert_eq!(report.summary.truncated, 2);
snapshot("report_without_prices_on_cpu", &render_markdown(&report));
Ok(())
}
#[test]
fn the_json_report_reads_back() -> TestResult {
let dir = tempfile::tempdir()?;
let report = sample_report()?;
let markdown = write(dir.path(), &report)?;
assert!(markdown.ends_with(crate::compare::REPORT_MD));
let back = Report::load(dir.path())?;
assert_eq!(back.questions.len(), 8);
assert_eq!(
serde_json::to_string_pretty(&back)?,
serde_json::to_string_pretty(&report)?
);
let first = back.questions.first().ok_or("no question")?;
assert_eq!(first.id, "q1");
assert_eq!(first.verdict, Verdict::Win);
let first_token = first.first_token_seconds.ok_or("no first token time")?;
assert!((first_token - 0.05).abs() < 1e-9);
let rate = first.tokens_per_second.ok_or("no rate")?;
assert!((rate - 60.0).abs() < 1e-9);
let failed = back.questions.get(6).ok_or("no q7")?;
assert_eq!(failed.tokens_per_second, None);
let json = std::fs::read_to_string(dir.path().join(crate::compare::REPORT_JSON))?;
assert!(!json.contains("api_key") && !json.contains("base_url"));
Ok(())
}
}