use crate::protocol::RunResult;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Json,
Junit,
Markdown,
Html,
Jsonl,
Csv,
}
impl std::str::FromStr for Format {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"json" => Ok(Format::Json),
"junit" | "xml" => Ok(Format::Junit),
"md" | "markdown" => Ok(Format::Markdown),
"html" => Ok(Format::Html),
"jsonl" | "ndjson" => Ok(Format::Jsonl),
"csv" => Ok(Format::Csv),
other => Err(format!(
"unknown format: {other} (json|jsonl|csv|junit|md|html)"
)),
}
}
}
impl Format {
pub fn extension(&self) -> &'static str {
match self {
Format::Json => "json",
Format::Junit => "xml",
Format::Markdown => "md",
Format::Html => "html",
Format::Jsonl => "jsonl",
Format::Csv => "csv",
}
}
}
#[derive(Clone, Copy)]
pub struct Group<'a> {
pub key: &'a str,
pub values: &'a [Option<String>],
}
pub fn render(results: &[RunResult], format: Format) -> String {
render_with_group(results, format, None)
}
pub fn render_with_group(
results: &[RunResult],
format: Format,
group: Option<Group<'_>>,
) -> String {
match format {
Format::Json => {
serde_json::to_string_pretty(&results_json_with_group(results, group.as_ref()))
.unwrap_or_default()
}
Format::Junit => junit_xml(results),
Format::Markdown => {
let mut out = markdown(results);
if let Some(g) = group {
out.push_str(&group_markdown(results, g.key, g.values));
}
out
}
Format::Html => html_with_group(results, group),
Format::Jsonl => jsonl(results),
Format::Csv => csv(results),
}
}
fn params_suffix(params: &crate::Params) -> String {
if params.is_empty() {
return String::new();
}
format!(
"[{}]",
params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",")
)
}
fn case_suffix(r: &RunResult) -> String {
format!(
"{}{}",
params_suffix(&r.params),
crate::trial_suffix(r.trial, r.trials)
)
}
pub fn print_results(results: &[RunResult]) {
println!("\n── cases ──");
for r in results {
let suffix = case_suffix(r);
if r.skipped {
println!(" [SKIP] {}/{}@{}{suffix}", r.eval, r.sample, r.target);
continue;
}
if is_na(r) {
let why = r
.transcript
.error
.as_deref()
.or_else(|| r.scores.first().map(|s| s.reason.as_str()))
.unwrap_or("not evaluated");
println!(
" [N/A] {}/{}@{}{suffix} ({why})",
r.eval, r.sample, r.target
);
continue;
}
let mark = if r.passed { "PASS" } else { "FAIL" };
println!(
" [{mark}] {}/{}@{}{suffix} ({:.0}%)",
r.eval,
r.sample,
r.target,
r.aggregate * 100.0
);
let t = &r.transcript;
let mut metrics = vec![format!("{} tok", t.usage.total_tokens())];
if t.usage.cost_usd > 0.0 {
metrics.push(format!("${:.4}", t.usage.cost_usd));
}
if t.timing.duration_ms > 0 {
metrics.push(format!("{}ms", t.timing.duration_ms));
}
if t.tool_calls_count > 0 {
metrics.push(format!("{} tool calls", t.tool_calls_count));
}
println!(" · {}", metrics.join(" · "));
for s in &r.scores {
let m = if s.na {
"–"
} else if s.pass {
"✓"
} else {
"✗"
};
println!(" {m} {} — {}", s.scorer, s.reason);
}
}
print_matrix(results);
print_trials(results);
let scored: Vec<_> = results.iter().filter(|r| !r.skipped && !is_na(r)).collect();
let passed = scored.iter().filter(|r| r.passed).count();
let na = results.iter().filter(|r| !r.skipped && is_na(r)).count();
let skipped = results.iter().filter(|r| r.skipped).count();
println!(
"\n{} passed / {} scored ({} failed, {} n/a, {} skipped)",
passed,
scored.len(),
scored.len() - passed,
na,
skipped,
);
}
pub struct GroupRate {
pub value: String,
pub passed: usize,
pub scored: usize,
}
impl GroupRate {
pub fn rate(&self) -> f64 {
if self.scored == 0 {
0.0
} else {
self.passed as f64 / self.scored as f64
}
}
}
pub fn group_rates(results: &[RunResult], values: &[Option<String>]) -> Vec<GroupRate> {
use std::collections::BTreeMap;
debug_assert_eq!(
results.len(),
values.len(),
"group_rates: values must be parallel to results"
);
let mut buckets: BTreeMap<String, (usize, usize)> = BTreeMap::new();
let mut unset = (0usize, 0usize);
for (r, value) in results.iter().zip(values) {
if r.skipped || is_na(r) {
continue;
}
let entry = match value {
Some(v) => buckets.entry(v.clone()).or_default(),
None => &mut unset,
};
entry.1 += 1;
if r.passed {
entry.0 += 1;
}
}
let mut out: Vec<GroupRate> = buckets
.into_iter()
.map(|(value, (passed, scored))| GroupRate {
value,
passed,
scored,
})
.collect();
if unset.1 > 0 {
out.push(GroupRate {
value: "(unset)".into(),
passed: unset.0,
scored: unset.1,
});
}
out
}
pub fn print_group_breakdown(results: &[RunResult], key: &str, values: &[Option<String>]) {
let rates = group_rates(results, values);
if rates.is_empty() {
return;
}
println!("\n── resolve rate by {key} (passed/scored) ──");
let w = rates
.iter()
.map(|r| r.value.len())
.max()
.unwrap_or(0)
.max(key.len());
for r in &rates {
println!(
" {:w$} {}/{} ({:.0}%)",
r.value,
r.passed,
r.scored,
r.rate() * 100.0,
w = w,
);
}
}
fn md_cell(s: &str) -> String {
s.replace('|', "\\|").replace(['\r', '\n'], " ")
}
fn group_markdown(results: &[RunResult], key: &str, values: &[Option<String>]) -> String {
let rates = group_rates(results, values);
if rates.is_empty() {
return String::new();
}
let key = md_cell(key);
let mut out = format!(
"\n### Resolve rate by {key}\n\n| {key} | passed | scored | rate |\n|---|---|---|---|\n"
);
for r in &rates {
out.push_str(&format!(
"| {} | {} | {} | {:.0}% |\n",
md_cell(&r.value),
r.passed,
r.scored,
r.rate() * 100.0
));
}
out
}
fn groups_json(results: &[RunResult], key: &str, values: &[Option<String>]) -> serde_json::Value {
let mut map = serde_json::Map::new();
for r in group_rates(results, values) {
map.insert(
r.value,
serde_json::json!({ "passed": r.passed, "scored": r.scored }),
);
}
serde_json::json!({ key: serde_json::Value::Object(map) })
}
fn axes(results: &[RunResult]) -> (Vec<String>, Vec<String>) {
let mut evals: Vec<String> = Vec::new();
let mut targets: Vec<String> = Vec::new();
for r in results {
if !evals.contains(&r.eval) {
evals.push(r.eval.clone());
}
if !targets.contains(&r.target) {
targets.push(r.target.clone());
}
}
(evals, targets)
}
fn print_matrix(results: &[RunResult]) {
let (evals, targets) = axes(results);
if evals.is_empty() || targets.is_empty() {
return;
}
println!("\n── matrix (passed/scored) ──");
let label_w = evals.iter().map(|e| e.len()).max().unwrap_or(4).max(4);
let col_w = targets.iter().map(|m| m.len()).max().unwrap_or(6).max(7);
print!(" {:label_w$}", "eval", label_w = label_w);
for m in &targets {
print!(" {:>col_w$}", m, col_w = col_w);
}
println!();
for eval in &evals {
print!(" {:label_w$}", eval, label_w = label_w);
for target in &targets {
print!(" {:>col_w$}", case(results, eval, target), col_w = col_w);
}
println!();
}
}
fn print_trials(results: &[RunResult]) {
let aggs: Vec<_> = crate::aggregate::aggregate_trials(results)
.into_iter()
.filter(|a| a.repeated())
.collect();
if aggs.is_empty() {
return;
}
println!("\n── trials (pass@k over repetitions) ──");
let key_w = aggs.iter().map(|a| a.key.len()).max().unwrap_or(4).max(4);
for a in &aggs {
let n = a.scored;
let mut parts = vec![
format!("{}/{} pass", a.passed, a.scored),
format!("rate {:.0}%", a.pass_rate * 100.0),
format!("pass@1 {:.2}", a.pass_at_k(1)),
];
if n > 1 {
parts.push(format!("pass@{n} {:.2}", a.pass_at_k(n)));
}
parts.push(format!("σ {:.3}", a.std_dev));
if a.na > 0 || a.skipped > 0 {
parts.push(format!("({} n/a, {} skip)", a.na, a.skipped));
}
println!(" {:key_w$} {}", a.key, parts.join(" · "), key_w = key_w);
}
}
fn case(results: &[RunResult], eval: &str, target: &str) -> String {
let cases: Vec<_> = results
.iter()
.filter(|r| r.eval == eval && r.target == target && !r.skipped && !is_na(r))
.collect();
if cases.is_empty() {
"—".to_string()
} else {
format!(
"{}/{}",
cases.iter().filter(|r| r.passed).count(),
cases.len()
)
}
}
pub fn results_json(results: &[RunResult]) -> serde_json::Value {
let mut record = serde_json::json!({
"summary": crate::run::RunSummary::of(results),
"cases": results,
});
if crate::aggregate::has_trials(results) {
let trials = crate::aggregate::aggregate_trials(results)
.into_iter()
.filter(|a| a.repeated())
.collect::<Vec<_>>();
record["trials"] = serde_json::to_value(trials).expect("trial aggregates serialize");
}
record
}
pub fn results_json_with_group(
results: &[RunResult],
group: Option<&Group<'_>>,
) -> serde_json::Value {
let mut record = results_json(results);
if let Some(g) = group
&& let Some(obj) = record.as_object_mut()
{
obj.insert("groups".into(), groups_json(results, g.key, g.values));
}
record
}
pub fn is_na(r: &RunResult) -> bool {
!r.scores.is_empty() && r.scores.iter().all(|s| s.na)
}
pub fn junit_xml(results: &[RunResult]) -> String {
let is_skipped = |r: &RunResult| r.skipped || is_na(r);
let skipped = results.iter().filter(|r| is_skipped(r)).count();
let failures = results
.iter()
.filter(|r| !is_skipped(r) && !r.passed)
.count();
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(&format!(
"<testsuite name=\"mira\" tests=\"{}\" failures=\"{}\" skipped=\"{}\">\n",
results.len(),
failures,
skipped
));
for r in results {
out.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}@{}{}\">",
xml_escape(&r.eval),
xml_escape(&r.sample),
xml_escape(&r.target),
xml_escape(&case_suffix(r)),
));
if is_skipped(r) {
out.push_str("\n <skipped/>\n ");
} else if !r.passed {
let reasons: Vec<String> = r
.scores
.iter()
.filter(|s| !s.pass && !s.na)
.map(|s| format!("{}: {}", s.scorer, s.reason))
.collect();
out.push_str(&format!(
"\n <failure message=\"{}\"/>\n ",
xml_escape(&reasons.join("; "))
));
}
out.push_str("</testcase>\n");
}
out.push_str("</testsuite>\n");
out
}
pub fn markdown(results: &[RunResult]) -> String {
let (evals, targets) = axes(results);
let mut out = String::new();
let scored = results.iter().filter(|r| !r.skipped && !is_na(r)).count();
let passed = results
.iter()
.filter(|r| !r.skipped && !is_na(r) && r.passed)
.count();
let na = results.iter().filter(|r| !r.skipped && is_na(r)).count();
let skipped = results.iter().filter(|r| r.skipped).count();
out.push_str(&format!(
"## Mira eval results\n\n**{passed} / {scored} passed** ({} failed, {na} n/a, {skipped} skipped)\n\n",
scored - passed,
));
if evals.is_empty() || targets.is_empty() {
return out;
}
out.push_str("| eval |");
for m in &targets {
out.push_str(&format!(" {m} |"));
}
out.push_str("\n|---|");
for _ in &targets {
out.push_str("---|");
}
out.push('\n');
for eval in &evals {
out.push_str(&format!("| {eval} |"));
for target in &targets {
out.push_str(&format!(" {} |", case(results, eval, target)));
}
out.push('\n');
}
out
}
pub fn jsonl(results: &[RunResult]) -> String {
let mut out = String::new();
for r in results {
out.push_str(&serde_json::to_string(r).expect("RunResult serializes"));
out.push('\n');
}
out
}
fn csv_cell(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
pub fn csv(results: &[RunResult]) -> String {
use std::collections::BTreeSet;
let mut metric_keys: BTreeSet<&str> = BTreeSet::new();
let mut meta_keys: BTreeSet<&str> = BTreeSet::new();
for r in results {
metric_keys.extend(r.transcript.metrics.keys().map(String::as_str));
meta_keys.extend(r.transcript.metadata.keys().map(String::as_str));
}
let mut header: Vec<String> = [
"eval",
"sample",
"target",
"params",
"trial",
"trials",
"seed",
"skipped",
"passed",
"aggregate",
"scorer",
"score_value",
"score_pass",
"score_na",
"score_reason",
"iterations",
"tool_calls",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"reasoning_tokens",
"total_tokens",
"cost_usd",
"duration_ms",
"time_to_first_token_ms",
"error",
"error_kind",
"final_response",
]
.map(String::from)
.to_vec();
header.extend(metric_keys.iter().map(|k| format!("metric.{k}")));
header.extend(meta_keys.iter().map(|k| format!("meta.{k}")));
let mut out = String::new();
out.push_str(&header.join(","));
out.push('\n');
for r in results {
let t = &r.transcript;
let params = r
.params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(";");
let seed = r.seed.map(|s| s.to_string()).unwrap_or_default();
let ttft = t
.timing
.time_to_first_token_ms
.map(|v| v.to_string())
.unwrap_or_default();
let error = r.transcript.error.clone().unwrap_or_default();
let error_kind = format!("{:?}", t.error_kind).to_ascii_lowercase();
let case_cols: Vec<String> = vec![
r.eval.clone(),
r.sample.clone(),
r.target.clone(),
params,
r.trial.to_string(),
r.trials.to_string(),
seed,
r.skipped.to_string(),
r.passed.to_string(),
r.aggregate.to_string(),
];
let mut tail_cols: Vec<String> = vec![
t.iterations.to_string(),
t.tool_calls_count.to_string(),
t.usage.input_tokens.to_string(),
t.usage.output_tokens.to_string(),
t.usage.cache_read_tokens.to_string(),
t.usage.reasoning_tokens.to_string(),
t.usage.total_tokens().to_string(),
t.usage.cost_usd.to_string(),
t.timing.duration_ms.to_string(),
ttft,
error,
error_kind,
t.final_response.clone(),
];
for k in &metric_keys {
tail_cols.push(t.metrics.get(*k).map(|v| v.to_string()).unwrap_or_default());
}
for k in &meta_keys {
tail_cols.push(
t.metadata
.get(*k)
.map(crate::metadata_display)
.unwrap_or_default(),
);
}
let write_row = |out: &mut String, score_cols: [String; 5]| {
let row = case_cols
.iter()
.chain(score_cols.iter())
.chain(tail_cols.iter())
.map(|c| csv_cell(c))
.collect::<Vec<_>>()
.join(",");
out.push_str(&row);
out.push('\n');
};
if r.scores.is_empty() {
write_row(&mut out, [(); 5].map(|_| String::new()));
} else {
for s in &r.scores {
write_row(
&mut out,
[
s.scorer.clone(),
s.value.to_string(),
s.pass.to_string(),
s.na.to_string(),
s.reason.clone(),
],
);
}
}
}
out
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub fn html(results: &[RunResult]) -> String {
html_with_group(results, None)
}
pub fn html_with_group(results: &[RunResult], group: Option<Group<'_>>) -> String {
let (evals, targets) = axes(results);
let scored = results.iter().filter(|r| !r.skipped && !is_na(r)).count();
let passed = results
.iter()
.filter(|r| !r.skipped && !is_na(r) && r.passed)
.count();
let na = results.iter().filter(|r| !r.skipped && is_na(r)).count();
let skipped = results.iter().filter(|r| r.skipped).count();
let failed = scored - passed;
let total_tokens: u64 = results
.iter()
.filter(|r| !r.skipped)
.map(|r| r.transcript.usage.total_tokens())
.sum();
let total_cost: f64 = results
.iter()
.filter(|r| !r.skipped)
.map(|r| r.transcript.usage.cost_usd)
.sum();
let mut out = String::new();
out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
out.push_str("<title>Mira eval report</title>\n<style>\n");
out.push_str(CSS);
out.push_str("</style>\n</head>\n<body>\n<main>\n");
out.push_str("<h1>Mira eval report</h1>\n");
out.push_str("<section class=\"cards\">\n");
let banner = if failed > 0 {
"bad"
} else if na > 0 {
"warn"
} else {
"ok"
};
out.push_str(&format!(
"<div class=\"card {banner}\"><b>{passed}/{scored}</b><span>passed</span></div>\n"
));
out.push_str(&format!(
"<div class=\"card\"><b>{failed}</b><span>failed</span></div>\n"
));
out.push_str(&format!(
"<div class=\"card\"><b>{na}</b><span>n/a</span></div>\n"
));
out.push_str(&format!(
"<div class=\"card\"><b>{skipped}</b><span>skipped</span></div>\n"
));
out.push_str(&format!(
"<div class=\"card\"><b>{total_tokens}</b><span>tokens</span></div>\n"
));
out.push_str(&format!(
"<div class=\"card\"><b>${total_cost:.4}</b><span>cost</span></div>\n"
));
out.push_str("</section>\n");
if !evals.is_empty() && !targets.is_empty() {
out.push_str("<h2>Matrix</h2>\n<table class=\"matrix\">\n<thead><tr><th>eval</th>");
for m in &targets {
out.push_str(&format!("<th>{}</th>", html_escape(m)));
}
out.push_str("</tr></thead>\n<tbody>\n");
for eval in &evals {
out.push_str(&format!("<tr><td>{}</td>", html_escape(eval)));
for target in &targets {
out.push_str(&format!(
"<td>{}</td>",
html_escape(&case(results, eval, target))
));
}
out.push_str("</tr>\n");
}
out.push_str("</tbody>\n</table>\n");
}
if let Some(g) = &group {
let rates = group_rates(results, g.values);
if !rates.is_empty() {
out.push_str(&format!(
"<h2>Resolve rate by {}</h2>\n<table class=\"matrix\">\n\
<thead><tr><th>{}</th><th>passed</th><th>scored</th><th>rate</th></tr></thead>\n<tbody>\n",
html_escape(g.key),
html_escape(g.key),
));
for r in &rates {
out.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{:.0}%</td></tr>\n",
html_escape(&r.value),
r.passed,
r.scored,
r.rate() * 100.0,
));
}
out.push_str("</tbody>\n</table>\n");
}
}
out.push_str("<h2>Cases</h2>\n");
for r in results {
let cls = if r.skipped {
"skip"
} else if is_na(r) {
"na"
} else if r.passed {
"pass"
} else {
"fail"
};
let badge = if r.skipped {
"SKIP"
} else if is_na(r) {
"N/A"
} else if r.passed {
"PASS"
} else {
"FAIL"
};
out.push_str(&format!("<details class=\"case {cls}\">\n<summary>"));
out.push_str(&format!(
"<span class=\"badge {cls}\">{badge}</span> <code>{}/{}@{}{}</code>",
html_escape(&r.eval),
html_escape(&r.sample),
html_escape(&r.target),
html_escape(&case_suffix(r)),
));
let t = &r.transcript;
out.push_str(&format!(
"<span class=\"metrics\">{} tok · ${:.4} · {}ms · {} tool calls</span>",
t.usage.total_tokens(),
t.usage.cost_usd,
t.timing.duration_ms,
t.tool_calls_count,
));
out.push_str("</summary>\n");
if !r.scores.is_empty() {
out.push_str("<ul class=\"scores\">\n");
for s in &r.scores {
let (m, scls) = if s.na {
("–", "na")
} else if s.pass {
("✓", "pass")
} else {
("✗", "fail")
};
out.push_str(&format!(
"<li class=\"{scls}\">{m} <b>{}</b> — {}</li>\n",
html_escape(&s.scorer),
html_escape(&s.reason),
));
}
out.push_str("</ul>\n");
}
if !t.tool_calls.is_empty() {
out.push_str(&format!(
"<p class=\"tools\"><b>tools:</b> {}</p>\n",
html_escape(&t.tool_calls.join(", "))
));
}
if !t.metrics.is_empty() {
out.push_str("<p class=\"meta\"><b>metrics:</b> ");
let items: Vec<String> = t
.metrics
.iter()
.map(|(k, v)| format!("{}={}", html_escape(k), v))
.collect();
out.push_str(&items.join(", "));
out.push_str("</p>\n");
}
if !t.metadata.is_empty() {
out.push_str("<p class=\"meta\"><b>metadata:</b> ");
let items: Vec<String> = t
.metadata
.iter()
.map(|(k, v)| {
let v = crate::metadata_display(v);
if v.starts_with("http://") || v.starts_with("https://") {
format!(
"{}=<a href=\"{}\">link</a>",
html_escape(k),
html_escape(&v)
)
} else {
format!("{}={}", html_escape(k), html_escape(&v))
}
})
.collect();
out.push_str(&items.join(", "));
out.push_str("</p>\n");
}
if let Some(err) = &t.error {
out.push_str(&format!(
"<pre class=\"error\">{}</pre>\n",
html_escape(err)
));
}
if !t.final_response.is_empty() {
out.push_str(&format!(
"<pre class=\"response\">{}</pre>\n",
html_escape(&t.final_response)
));
}
out.push_str("</details>\n");
}
let json = serde_json::to_string(&results_json_with_group(results, group.as_ref()))
.unwrap_or_default();
out.push_str("<script type=\"application/json\" id=\"mira-data\">\n");
out.push_str(&json.replace("</", "<\\/"));
out.push_str("\n</script>\n");
out.push_str("</main>\n</body>\n</html>\n");
out
}
const CSS: &str = "\
:root{color-scheme:light dark;--ok:#1a7f37;--bad:#cf222e;--warn:#9a6700;--mut:#57606a;--bg:#fff;--fg:#1f2328;--line:#d0d7de}
@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--line:#30363d;--mut:#8b949e}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif}
main{max-width:960px;margin:0 auto;padding:2rem 1.25rem}
h1{font-size:1.6rem;margin:0 0 1rem}h2{font-size:1.15rem;margin:2rem 0 .75rem;border-bottom:1px solid var(--line);padding-bottom:.25rem}
.cards{display:flex;flex-wrap:wrap;gap:.75rem}
.card{flex:1;min-width:96px;border:1px solid var(--line);border-radius:8px;padding:.75rem 1rem;text-align:center}
.card b{display:block;font-size:1.5rem}.card span{color:var(--mut);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
.card.ok b{color:var(--ok)}.card.bad b{color:var(--bad)}.card.warn b{color:var(--warn)}
table.matrix{border-collapse:collapse;width:100%}table.matrix th,table.matrix td{border:1px solid var(--line);padding:.4rem .6rem;text-align:center}
table.matrix td:first-child,table.matrix th:first-child{text-align:left}
.case{border:1px solid var(--line);border-radius:8px;margin:.5rem 0;padding:.25rem .75rem}
.case summary{cursor:pointer;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
.case code{font-size:.92rem}.metrics{color:var(--mut);font-size:.8rem;margin-left:auto}
.badge{font-size:.7rem;font-weight:700;padding:.1rem .4rem;border-radius:4px;color:#fff}
.badge.pass{background:var(--ok)}.badge.fail{background:var(--bad)}.badge.skip{background:var(--mut)}.badge.na{background:var(--warn)}
.scores{list-style:none;padding:0;margin:.5rem 0}.scores li{padding:.15rem 0}.scores li.pass{color:var(--ok)}.scores li.fail{color:var(--bad)}.scores li.na{color:var(--mut)}
.tools,.meta{font-size:.85rem;color:var(--mut)}
pre{background:rgba(127,127,127,.1);border-radius:6px;padding:.6rem .75rem;overflow:auto;font-size:.85rem;white-space:pre-wrap}
pre.error{color:var(--bad)}a{color:#0969da}
";
#[cfg(test)]
mod tests {
use super::*;
use crate::Score;
use crate::protocol::TranscriptSummary;
fn result(eval: &str, sample: &str, target: &str, passed: bool, skipped: bool) -> RunResult {
RunResult {
eval: eval.into(),
sample: sample.into(),
target: target.into(),
params: Default::default(),
trial: 0,
trials: 0,
seed: None,
input: Vec::new(),
expected: None,
passed,
aggregate: if passed { 1.0 } else { 0.0 },
scores: if passed {
vec![Score::pass("s", "ok")]
} else {
vec![Score::fail("s", "nope")]
},
transcript: TranscriptSummary::default(),
skipped,
}
}
fn sample_results() -> Vec<RunResult> {
vec![
result("greet", "hi", "sim", true, false),
result("greet", "hi", "opus", false, false),
result("code", "a", "opus", false, true),
]
}
#[test]
fn json_summary_counts() {
let v = results_json(&sample_results());
assert_eq!(v["summary"]["scored"], 2);
assert_eq!(v["summary"]["passed"], 1);
assert_eq!(v["summary"]["failed"], 1);
assert_eq!(v["summary"]["na"], 0);
assert_eq!(v["summary"]["skipped"], 1);
}
#[test]
fn all_na_case_counts_as_na_not_failed() {
let mut na = result("greet", "hi", "opus", false, false);
na.scores = vec![Score::na("infra", "provider 503")];
let results = vec![
result("greet", "hi", "sim", true, false),
na,
result("code", "a", "opus", false, true),
];
let v = results_json(&results);
assert_eq!(v["summary"]["scored"], 1); assert_eq!(v["summary"]["passed"], 1);
assert_eq!(v["summary"]["failed"], 0); assert_eq!(v["summary"]["na"], 1);
assert_eq!(v["summary"]["skipped"], 1);
assert!(markdown(&results).contains("1 n/a"));
let h = html(&results);
assert!(h.contains("N/A"));
assert!(h.contains("<span>n/a</span>"));
assert!(h.contains("card warn")); }
#[test]
fn junit_has_failure_and_skip() {
let xml = junit_xml(&sample_results());
assert!(xml.contains("tests=\"3\""));
assert!(xml.contains("failures=\"1\""));
assert!(xml.contains("skipped=\"1\""));
assert!(xml.contains("<failure message=\"s: nope\"/>"));
assert!(xml.contains("<skipped/>"));
assert!(xml.contains("name=\"hi@sim\""));
}
#[test]
fn junit_all_na_case_is_skipped_not_failed() {
let mut r = result("greet", "hi", "opus", false, false);
r.scores = vec![Score::na("judge", "unreachable")];
let xml = junit_xml(std::slice::from_ref(&r));
assert!(xml.contains("failures=\"0\""));
assert!(xml.contains("skipped=\"1\""));
assert!(xml.contains("<skipped/>"));
assert!(!xml.contains("<failure"));
}
#[test]
fn markdown_has_matrix() {
let md = markdown(&sample_results());
assert!(md.contains("1 / 2 passed"));
assert!(md.contains("| greet |"));
}
#[test]
fn xml_escaping() {
assert_eq!(xml_escape("a<b&\"c\""), "a<b&"c"");
}
#[test]
fn format_parsing() {
use std::str::FromStr;
assert_eq!(Format::from_str("json").unwrap(), Format::Json);
assert_eq!(Format::from_str("JUnit").unwrap(), Format::Junit);
assert_eq!(Format::from_str("md").unwrap(), Format::Markdown);
assert_eq!(Format::from_str("html").unwrap(), Format::Html);
assert_eq!(Format::from_str("jsonl").unwrap(), Format::Jsonl);
assert_eq!(Format::from_str("ndjson").unwrap(), Format::Jsonl);
assert_eq!(Format::from_str("csv").unwrap(), Format::Csv);
assert_eq!(Format::Html.extension(), "html");
assert_eq!(Format::Jsonl.extension(), "jsonl");
assert_eq!(Format::Csv.extension(), "csv");
assert!(Format::from_str("yaml").is_err());
}
#[test]
fn jsonl_one_object_per_case() {
let results = sample_results();
let text = jsonl(&results);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), results.len());
for (line, r) in lines.iter().zip(&results) {
let v: serde_json::Value = serde_json::from_str(line).expect("line is JSON");
assert_eq!(v["eval"], r.eval.as_str());
assert_eq!(v["target"], r.target.as_str());
}
assert!(!text.contains("\"summary\""));
}
#[test]
fn csv_is_long_format_one_row_per_score() {
let mut r = result("greet", "hi", "sim", true, false);
r.scores = vec![Score::pass("a", "ok"), Score::fail("b", "nope")];
let text = csv(std::slice::from_ref(&r));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3); assert!(lines[0].starts_with("eval,sample,target,params,trial,trials,seed"));
assert!(lines[1].starts_with("greet,hi,sim,,0,0,,false,true,1,a,1,true,false,ok"));
assert!(lines[2].starts_with("greet,hi,sim,,0,0,,false,true,1,b,0,false,false,nope"));
}
#[test]
fn csv_scoreless_case_still_emits_a_row() {
let mut r = result("greet", "hi", "sim", false, true);
r.scores = vec![];
let text = csv(std::slice::from_ref(&r));
assert_eq!(text.lines().count(), 2); assert!(
text.lines()
.nth(1)
.unwrap()
.starts_with("greet,hi,sim,,0,0,,true,false,0,,")
);
}
#[test]
fn csv_quotes_commas_quotes_and_newlines() {
let mut r = result("greet", "hi", "sim", false, false);
r.scores = vec![Score::fail(
"judge",
"has, comma and \"quote\"\nand newline",
)];
let text = csv(std::slice::from_ref(&r));
assert!(text.contains("\"has, comma and \"\"quote\"\"\nand newline\""));
}
#[test]
fn csv_flattens_metrics_and_metadata_columns() {
let mut r = result("greet", "hi", "sim", true, false);
r.transcript.metrics.insert("retries".into(), 2.0);
r.transcript
.metadata
.insert("repo".into(), serde_json::json!("acme/app"));
let text = csv(std::slice::from_ref(&r));
let header = text.lines().next().unwrap();
assert!(header.contains(",metric.retries"));
assert!(header.contains(",meta.repo"));
let row = text.lines().nth(1).unwrap();
assert!(row.ends_with(",2,acme/app"));
}
#[test]
fn html_is_self_contained() {
let h = html(&sample_results());
assert!(h.starts_with("<!doctype html>"));
assert!(h.contains("Mira eval report"));
assert!(h.contains("greet/hi@sim"));
assert!(h.contains("id=\"mira-data\""));
assert!(!h.contains("http-equiv"));
assert!(!h.contains("<link"));
assert!(!h.contains("src=\"http"));
}
#[test]
fn trials_aggregate_in_json_and_labels() {
let mut results = Vec::new();
for t in 0..3 {
let mut r = result("flaky", "a", "sim", t != 0, false);
r.trial = t;
r.trials = 3;
results.push(r);
}
let v = results_json(&results);
let trials = v["trials"].as_array().expect("trials array present");
assert_eq!(trials.len(), 1);
assert_eq!(trials[0]["key"], "flaky/a@sim");
assert_eq!(trials[0]["passed"], 2);
assert_eq!(trials[0]["scored"], 3);
let single = vec![result("greet", "hi", "sim", true, false)];
assert!(results_json(&single).get("trials").is_none());
let xml = junit_xml(&results);
assert!(xml.contains("name=\"a@sim#0\""));
assert!(xml.contains("name=\"a@sim#2\""));
let h = html(&results);
assert!(h.contains("flaky/a@sim#1"));
}
#[test]
fn group_rates_bucket_and_exclude_skips() {
let results = vec![
result("e", "a", "sim", true, false),
result("e", "b", "sim", false, false),
result("e", "c", "sim", true, false),
result("e", "d", "sim", false, true),
];
let values = vec![
Some("easy".to_string()),
Some("easy".to_string()),
Some("hard".to_string()),
None,
];
let rates = group_rates(&results, &values);
assert_eq!(rates.len(), 2); assert_eq!(rates[0].value, "easy");
assert_eq!((rates[0].passed, rates[0].scored), (1, 2));
assert_eq!(rates[0].rate(), 0.5);
assert_eq!(rates[1].value, "hard");
assert_eq!((rates[1].passed, rates[1].scored), (1, 1));
}
#[test]
fn unset_bucket_when_scored() {
let results = vec![result("e", "a", "sim", true, false)];
let rates = group_rates(&results, &[None]);
assert_eq!(rates.len(), 1);
assert_eq!(rates[0].value, "(unset)");
assert_eq!((rates[0].passed, rates[0].scored), (1, 1));
}
#[test]
fn group_renders_into_every_format() {
let results = vec![
result("e", "a", "sim", true, false),
result("e", "b", "sim", false, false),
];
let values = vec![Some("easy".to_string()), Some("hard".to_string())];
let group = Group {
key: "difficulty",
values: &values,
};
let md = render_with_group(&results, Format::Markdown, Some(group));
assert!(md.contains("Resolve rate by difficulty"));
assert!(md.contains("| easy | 1 | 1 | 100% |"));
let json = render_with_group(&results, Format::Json, Some(group));
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["groups"]["difficulty"]["easy"]["passed"], 1);
assert_eq!(v["groups"]["difficulty"]["hard"]["scored"], 1);
let html = render_with_group(&results, Format::Html, Some(group));
assert!(html.contains("Resolve rate by difficulty"));
assert!(html.contains("\"groups\""));
}
#[test]
fn group_markdown_escapes_pipe_and_newline() {
let results = vec![result("e", "a", "sim", true, false)];
let values = vec![Some("a|b\nc".to_string())];
let md = render_with_group(
&results,
Format::Markdown,
Some(Group {
key: "repo",
values: &values,
}),
);
assert!(md.contains("| a\\|b c |"), "got: {md}");
assert!(!md.contains("a|b"));
}
#[test]
fn params_show_in_labels() {
let mut r = result("greet", "hi", "sim", true, false);
r.params.insert("effort".into(), "high".into());
let xml = junit_xml(std::slice::from_ref(&r));
assert!(xml.contains("name=\"hi@sim[effort=high]\""));
let h = html(std::slice::from_ref(&r));
assert!(h.contains("greet/hi@sim[effort=high]"));
}
}