use crate::types::{BenchDiff, CompareReport, CompareSummary, NeutralReason, Verdict};
pub const STICKY_MARKER: &str = "<!-- aatxe:report -->";
pub fn render_markdown(cmp: &CompareReport) -> String {
let sig_rows: Vec<&BenchDiff> = cmp
.diffs
.iter()
.filter(|d| matches!(d.verdict, Verdict::Regression | Verdict::Improvement))
.collect();
let new_rows: Vec<&BenchDiff> = cmp
.diffs
.iter()
.filter(|d| d.verdict == Verdict::New)
.collect();
let removed_rows: Vec<&BenchDiff> = cmp
.diffs
.iter()
.filter(|d| d.verdict == Verdict::Removed)
.collect();
let out_of_scope_rows: Vec<&BenchDiff> = cmp
.diffs
.iter()
.filter(|d| d.verdict == Verdict::OutOfScope)
.collect();
let neutral_rows: Vec<&BenchDiff> = cmp
.diffs
.iter()
.filter(|d| d.verdict == Verdict::Neutral)
.collect();
let noisy_count = neutral_rows
.iter()
.filter(|d| d.neutral_reason == Some(NeutralReason::TooNoisy))
.count();
let mut lines: Vec<String> = Vec::new();
lines.push(STICKY_MARKER.to_string());
lines.push(format!("## {}", headline_for_summary(&cmp.summary)));
lines.push(String::new());
lines.push(format!(
"Service `{}` ({}) · base `{}` → head `{}` · threshold ±{} · α={}",
cmp.head.service,
cmp.language.label(),
short_ref(&cmp.base.r#ref),
short_ref(&cmp.head.r#ref),
pct(cmp.threshold_pct),
cmp.alpha,
));
if let Some(scope) = &cmp.affected_scope {
let ran = scope.bench_files.len();
let skipped = scope.skipped_bench_files.len();
lines.push(String::new());
lines.push(format!(
"> Affected-scope run vs `{}`: ran {} of {} bench file(s) ({} file(s) changed). \
{} bench file(s) skipped as unaffected — see \"Out of scope\" below.",
scope.base,
ran,
ran + skipped,
scope.changed_files.len(),
skipped,
));
}
if noisy_count > 0 {
lines.push(String::new());
let bench_word = if noisy_count == 1 { "bench" } else { "benches" };
lines.push(format!(
"> ⚠ {} {} had CV > {}; their results were noise-gated.",
noisy_count,
bench_word,
pct(cmp.noisy_cv_threshold),
));
}
lines.push(String::new());
if !sig_rows.is_empty() {
lines.push("### Significant changes".to_string());
lines.push(String::new());
lines.push(render_table(&sig_rows));
lines.push(String::new());
}
if !new_rows.is_empty() || !removed_rows.is_empty() {
lines.push("### Inventory changes".to_string());
lines.push(String::new());
if !new_rows.is_empty() {
lines.push(format!("**New ({}):**", new_rows.len()));
for d in &new_rows {
let med = d
.head
.as_ref()
.map(|h| format_ns(h.median))
.unwrap_or_else(|| "—".into());
lines.push(format!("- `{}` · median {}", escape_md(&d.name), med));
}
lines.push(String::new());
}
if !removed_rows.is_empty() {
lines.push(format!("**Removed ({}):**", removed_rows.len()));
for d in &removed_rows {
lines.push(format!("- `{}`", escape_md(&d.name)));
}
lines.push(String::new());
}
}
if !neutral_rows.is_empty() {
lines.push(format!(
"<details><summary>Neutral ({})</summary>",
neutral_rows.len()
));
lines.push(String::new());
lines.push(render_table(&neutral_rows));
lines.push(String::new());
lines.push("</details>".to_string());
lines.push(String::new());
}
if !out_of_scope_rows.is_empty() {
lines.push(format!(
"<details><summary>Out of scope ({}) — not run on this PR</summary>",
out_of_scope_rows.len()
));
lines.push(String::new());
lines.push(
"These benches exist in base but weren't re-run on head because their source files \
weren't touched by this PR. They are **not** counted as regressions."
.to_string(),
);
lines.push(String::new());
for d in &out_of_scope_rows {
let med = d
.base
.as_ref()
.map(|b| format_ns(b.median))
.unwrap_or_else(|| "—".into());
lines.push(format!("- `{}` · base median {}", escape_md(&d.name), med));
}
lines.push(String::new());
lines.push("</details>".to_string());
lines.push(String::new());
}
lines.push("<details><summary>Methodology</summary>".to_string());
lines.push(String::new());
lines.push(format!(
"Both refs run on the same CI machine with identical toolchain, back-to-back. \
Each bench: warmup samples discarded, then adaptive sampling (auto-batched for sub-µs \
ops) until target CV {} or time budget. Effect size: relative median delta. \
Significance: Mann–Whitney U two-tailed p-value (non-parametric, no normality \
assumption). Verdict: regression when |Δmedian| ≥ {} AND p < {} AND not noise-gated. \
Noise gate: max(CV_base, CV_head) > {} AND |Δmedian| < 2 × max(CV).",
pct(0.02),
pct(cmp.threshold_pct),
cmp.alpha,
pct(cmp.noisy_cv_threshold),
));
lines.push(String::new());
lines.push("</details>".to_string());
lines.join("\n")
}
fn render_table(diffs: &[&BenchDiff]) -> String {
let headers = [
"Bench",
"Base (median)",
"Head (median)",
"Δ",
"p95 Δ",
"CV (b→h)",
"p",
"Verdict",
];
let align = [
"---", "---:", "---:", "---:", "---:", "---:", "---:", ":---:",
];
let mut out: Vec<String> = Vec::with_capacity(diffs.len() + 2);
out.push(format!("| {} |", headers.join(" | ")));
out.push(format!("| {} |", align.join(" | ")));
for d in diffs {
let base_med = d
.base
.as_ref()
.map(|b| format_ns(b.median))
.unwrap_or_else(|| "—".into());
let head_med = d
.head
.as_ref()
.map(|h| format_ns(h.median))
.unwrap_or_else(|| "—".into());
let delta = d
.delta_pct
.map(fmt_signed_pct)
.unwrap_or_else(|| "—".into());
let p95_delta = match (d.base.as_ref(), d.head.as_ref()) {
(Some(b), Some(h)) if b.p95 > 0.0 => fmt_signed_pct((h.p95 - b.p95) / b.p95),
_ => "—".into(),
};
let cv = match (d.base.as_ref(), d.head.as_ref()) {
(Some(b), Some(h)) => format!("{}→{}", pct(b.cv), pct(h.cv)),
_ => "—".into(),
};
let p_val = d
.p_value
.map(|p| format!("{:.2e}", p))
.unwrap_or_else(|| "—".into());
out.push(format!(
"| `{}` | {} | {} | {} | {} | {} | {} | {} |",
escape_md(&d.name),
base_med,
head_med,
delta,
p95_delta,
cv,
p_val,
verdict_badge(d),
));
}
out.join("\n")
}
fn headline_for_summary(s: &CompareSummary) -> String {
if s.regressions > 0 {
return format!(
"Performance · {} regression{}",
s.regressions,
if s.regressions == 1 { "" } else { "s" }
);
}
if s.improvements > 0 {
return format!(
"Performance · {} improvement{}",
s.improvements,
if s.improvements == 1 { "" } else { "s" }
);
}
if s.new + s.removed > 0 {
return "Performance · inventory changed".to_string();
}
"Performance · no significant changes".to_string()
}
fn verdict_badge(d: &BenchDiff) -> &'static str {
match d.verdict {
Verdict::Regression => "🔴 Regression",
Verdict::Improvement => "🟢 Improvement",
Verdict::Neutral => match d.neutral_reason {
Some(NeutralReason::TooNoisy) => "🟡 Noisy",
_ => "⚪ Neutral",
},
Verdict::New => "🆕 New",
Verdict::Removed => "🗑 Removed",
Verdict::OutOfScope => "⏭ Skipped",
}
}
pub fn format_ns(ns: f64) -> String {
if !ns.is_finite() {
return "—".to_string();
}
if ns < 1_000.0 {
format!("{:.0}ns", ns)
} else if ns < 1_000_000.0 {
format!("{:.2}µs", ns / 1_000.0)
} else if ns < 1_000_000_000.0 {
format!("{:.2}ms", ns / 1_000_000.0)
} else {
format!("{:.2}s", ns / 1_000_000_000.0)
}
}
fn pct(frac: f64) -> String {
let v = frac * 100.0;
let prec = if frac.abs() < 0.001 {
2
} else if frac.abs() < 0.01 {
1
} else {
0
};
format!("{:.*}%", prec, v)
}
fn fmt_signed_pct(frac: f64) -> String {
let v = frac * 100.0;
let sign = if v > 0.0 { "+" } else { "" };
format!("{}{:.1}%", sign, v)
}
fn short_ref(r: &str) -> String {
if r.len() > 7 {
r[..7].to_string()
} else {
r.to_string()
}
}
fn escape_md(s: &str) -> String {
s.replace('|', "\\|")
}