1use anyhow::{Context, Result};
5use maud::{Markup, html};
6use wowlab_fs::{atomic, directory, path::Path};
7
8use super::{
9 charts,
10 types::{BenchHistory, BenchRun},
11};
12use crate::report::{Page, chart_box};
13
14const GITHUB_REPO: &str = "https://github.com/legacy3/wowlab";
15const BENCH_JS: &str = include_str!("report.js");
16
17const TIMESTAMP_DATE_LEN: usize = 10;
18
19struct RunCharts {
20 throughput: String,
21 dps: String,
22 scatter: String,
23 scaling_throughput: Option<String>,
24 scaling_multiplier: Option<String>,
25 scaling_efficiency: Option<String>,
26 scaling_marginal: Option<String>,
27}
28
29fn run_label(run: &BenchRun, index: usize, total: usize) -> String {
30 let age = total - index;
31 let tag = if age == 1 {
32 " (latest)".to_string()
33 } else {
34 format!(" (-{} runs)", age - 1)
35 };
36 let date_end = TIMESTAMP_DATE_LEN.min(run.timestamp.len());
37 let date = &run.timestamp[..date_end];
39
40 format!(
41 "v{} {}@{} {}{}",
42 run.engine_version, run.git_branch, run.git_hash, date, tag
43 )
44}
45
46fn build_run_charts(run: &BenchRun) -> Result<RunCharts> {
47 let single = BenchHistory {
48 runs: vec![run.clone()],
49 };
50
51 let scaling =
52 |f: fn(&[super::types::ScalingResult]) -> Result<String>| -> Result<Option<String>> {
53 if run.scaling.is_empty() {
54 Ok(None)
55 } else {
56 f(&run.scaling).map(Some)
57 }
58 };
59
60 Ok(RunCharts {
61 throughput: charts::throughput(&single)?,
62 dps: charts::dps(&single)?,
63 scatter: charts::throughput_vs_dps(&single)?,
64 scaling_throughput: scaling(charts::scaling_throughput)?,
65 scaling_multiplier: scaling(charts::scaling_multiplier)?,
66 scaling_efficiency: scaling(charts::scaling_efficiency)?,
67 scaling_marginal: scaling(charts::scaling_marginal_gain)?,
68 })
69}
70
71fn render_meta(run: &BenchRun) -> Markup {
72 let sys = &run.system;
73 let cpu = if sys.cpu_model.is_empty() {
74 "Unknown"
75 } else {
76 &sys.cpu_model
77 };
78
79 let items: Vec<(&str, String)> = vec![
80 ("Engine", format!("v{}", run.engine_version)),
81 ("Git", format!("{}@{}", run.git_branch, run.git_hash)),
82 ("CPU", cpu.to_string()),
83 ("Cores", format!("{}P + {}E", sys.p_cores, sys.e_cores)),
84 ("Rustc", run.rustc_version.clone()),
85 ];
86
87 html! {
88 div class="field is-grouped is-grouped-multiline mb-4" {
89 @for (label, value) in &items {
90 div class="control" {
91 div class="tags has-addons" {
92 span class="tag is-dark" { (label) }
93 span class="tag is-info is-light" { (value) }
94 }
95 }
96 }
97 }
98 }
99}
100
101fn render_table(run: &BenchRun, prev: Option<&BenchRun>) -> Markup {
102 let mut sorted = run.results.clone();
103
104 sorted.sort_by(|a, b| {
105 b.iterations_per_sec
106 .partial_cmp(&a.iterations_per_sec)
107 .unwrap_or(std::cmp::Ordering::Equal)
108 });
109
110 html! {
111 div class="table-container" {
112 table class="table is-hoverable is-fullwidth is-narrow is-striped" {
113 caption class="is-sr-only" { "Per-spec throughput results, latest run" }
114 thead {
115 tr {
116 th scope="col" { "Spec" }
117 th scope="col" class="has-text-right" { "Iter/s" }
118 th scope="col" class="has-text-right" { "vs Prev" }
119 th scope="col" class="has-text-right" { "Mean DPS" }
120 th scope="col" class="has-text-right" { "Time (ms)" }
121 th scope="col" class="has-text-right" { "Iterations" }
122 }
123 }
124 tbody {
125 @for r in &sorted {
126 @let prev_ips = prev.and_then(|p| {
127 p.results.iter()
128 .find(|pr| pr.slug == r.slug)
129 .map(|pr| pr.iterations_per_sec)
130 });
131 @let (delta_text, delta_cls) = format_delta(r.iterations_per_sec, prev_ips);
132 tr {
133 td { (r.slug) }
134 td class="has-text-right" { (format!("{:.0}", r.iterations_per_sec)) }
135 td class=(format!("has-text-right {delta_cls}")) { (delta_text) }
136 td class="has-text-right" { (format!("{:.0}", r.mean_dps)) }
137 td class="has-text-right" { (r.elapsed_ms) }
138 td class="has-text-right" { (r.iterations) }
139 }
140 }
141 }
142 }
143 }
144 }
145}
146
147fn format_delta(current: f64, previous: Option<f64>) -> (String, &'static str) {
148 match previous {
149 Some(old) if old > 0.0 => {
150 let pct = ((current - old) / old) * wowlab_types::constants::HUNDRED;
151 let sign = if pct > 0.0 { "+" } else { "" };
152 let cls = if pct.abs() < 1.0 {
153 "neutral"
154 } else if pct > 0.0 {
155 "pos"
156 } else {
157 "neg"
158 };
159
160 (format!("{sign}{pct:.1}%"), cls)
161 }
162 _ => ("-".to_string(), "neutral"),
163 }
164}
165
166fn run_chart_data(run_charts: &RunCharts, commit_url: &str) -> serde_json::Value {
167 serde_json::json!({
168 "commitUrl": commit_url,
169 "throughput": serde_json::from_str::<serde_json::Value>(&run_charts.throughput).unwrap_or_default(),
170 "dps": serde_json::from_str::<serde_json::Value>(&run_charts.dps).unwrap_or_default(),
171 "scatter": serde_json::from_str::<serde_json::Value>(&run_charts.scatter).unwrap_or_default(),
172 "scalingThroughput": run_charts.scaling_throughput.as_deref()
173 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
174 "scalingMultiplier": run_charts.scaling_multiplier.as_deref()
175 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
176 "scalingEfficiency": run_charts.scaling_efficiency.as_deref()
177 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
178 "scalingMarginal": run_charts.scaling_marginal.as_deref()
179 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
180 })
181}
182
183struct RunView {
184 index: usize,
185 label: String,
186 meta: Markup,
187 table: Markup,
188 chart_data: serde_json::Value,
189 has_scaling: bool,
190}
191
192fn build_run_views(history: &BenchHistory) -> Result<Vec<RunView>> {
193 let total = history.runs.len();
194 let mut views = Vec::new();
195
196 for (i, run) in history.runs.iter().enumerate() {
197 let prev = i.checked_sub(1).and_then(|index| history.runs.get(index));
198 let label = run_label(run, i, total);
199 let meta = render_meta(run);
200 let table = render_table(run, prev);
201 let run_charts = build_run_charts(run)?;
202 let commit_url = format!("{}/tree/{}", GITHUB_REPO, run.git_hash);
203 let chart_data = run_chart_data(&run_charts, &commit_url);
204
205 views.push(RunView {
206 index: i,
207 label,
208 meta,
209 table,
210 chart_data,
211 has_scaling: !run.scaling.is_empty(),
212 });
213 }
214
215 Ok(views)
216}
217
218fn render_header_extras(run_views: &[RunView]) -> Markup {
219 let latest = run_views.len().saturating_sub(1);
220
221 html! {
222 div class="field is-grouped mb-3" {
223 div class="control" {
224 div class="select is-small" {
225 select #run-select
226 aria-label="Select benchmark run"
227 onchange="benchSelectRun(this.value)" {
228 @for v in run_views.iter().rev() {
229 option value=(v.index) selected[v.index == latest] { (v.label) }
230 }
231 }
232 }
233 }
234 div class="control" {
235 a #commit-link
236 class="is-size-7 has-text-weak"
237 href=""
238 target="_blank"
239 rel="noopener noreferrer" { "" }
240 }
241 }
242 }
243}
244
245fn render_overview(run_views: &[RunView]) -> Markup {
246 let latest = run_views.len().saturating_sub(1);
247
248 html! {
249 @for v in run_views {
250 div class="run-view" data-run=(v.index) hidden[v.index != latest] {
251 (v.meta)
252 div class="columns" {
253 div class="column is-half" {
254 (chart_box("Throughput", "chart", "throughput"))
255 }
256 div class="column is-half" {
257 (chart_box("DPS", "chart", "dps"))
258 }
259 }
260 (chart_box("Throughput vs DPS", "chart", "scatter"))
261 }
262 }
263 }
264}
265
266fn render_scaling(run_views: &[RunView]) -> Markup {
267 let latest = run_views.len().saturating_sub(1);
268
269 html! {
270 @for v in run_views {
271 div class="run-view" data-run=(v.index) hidden[v.index != latest] {
272 @if v.has_scaling {
273 (chart_box("Scaling: Throughput", "chart-tall", "scalingThroughput"))
274 div class="columns" {
275 div class="column is-half" {
276 (chart_box("Multiplier", "chart", "scalingMultiplier"))
277 }
278 div class="column is-half" {
279 (chart_box("Efficiency", "chart", "scalingEfficiency"))
280 }
281 }
282 (chart_box("Marginal Gain", "chart", "scalingMarginal"))
283 } @else {
284 p class="has-text-grey" { "No scaling data for this run." }
285 }
286 }
287 }
288 }
289}
290
291fn render_history() -> Markup {
292 html! {
293 div class="box" {
294 p class="chart-label" { "Throughput Over Time" }
295 div #historyChart class="chart-tall" {}
296 }
297 div class="box" {
298 p class="chart-label" { "DPS Over Time" }
299 div #dpsHistoryChart class="chart-tall" {}
300 }
301 }
302}
303
304fn render_details(run_views: &[RunView]) -> Markup {
305 let latest = run_views.len().saturating_sub(1);
306
307 html! {
308 @for v in run_views {
309 div class="run-view" data-run=(v.index) hidden[v.index != latest] {
310 div class="box" {
311 (v.table.clone())
312 }
313 }
314 }
315 }
316}
317
318pub(crate) fn generate(output_path: &Path, history: &BenchHistory) -> Result<()> {
319 if let Some(parent) = output_path.parent() {
320 directory::ensure(parent)
321 .with_context(|| format!("failed to create directory {}", parent.display()))?;
322 }
323
324 let history_chart_json = charts::history_lines(history)?;
325 let dps_history_chart_json = charts::dps_history_lines(history)?;
326 let run_views = build_run_views(history)?;
327
328 let mut page = Page::new(
329 "Engine Benchmark Report",
330 "Performance tracking across specs and commits",
331 )
332 .footer_command("cargo forge bench")
333 .header_extras(render_header_extras(&run_views))
334 .tab("overview", "Overview")
335 .tab("scaling", "Scaling")
336 .tab("history", "History")
337 .tab("details", "Details")
338 .section("overview", render_overview(&run_views))
339 .section("scaling", render_scaling(&run_views))
340 .section("history", render_history())
341 .section("details", render_details(&run_views));
342
343 for v in &run_views {
344 page = page.data_script(format!("run-data-{}", v.index), v.chart_data.to_string());
345 }
346
347 page = page
348 .data_script("history-data", history_chart_json)
349 .data_script("dps-history-data", dps_history_chart_json)
350 .extra_js(BENCH_JS);
351 let rendered = page.render();
352
353 atomic::replace(output_path, rendered)
354 .with_context(|| format!("failed to write report to {}", output_path.display()))?;
355
356 Ok(())
357}