f24503a3caf6f35f5e69a06ef8ba4312c2acc98f / alderbench/src/report.rs · 16203 bytes · raw
//! JSON report writer. One file per workload run, under `report_dir`
//! (`./runs/` by default). Schema v2: run metadata, workload verdict,
//! and the perf score. No sensor fields — aldermon owns sensors; this
//! report only records what the workload itself produced.
//!
//! Incremental flush: the whole file is rewritten on each call. A v2
//! report is small (no per-tick samples), so we only flush once at
//! `finish()` — partial runs leave a JSON file with verdict=null +
//! score=null, which is parseable.
//!
//! Zero-dep: hand-rolled JSON. The schema is flat enough that serde_json
//! would be one dep for little gain; revisit if the schema nests deeper.
#![allow(dead_code)] // consumed by run.rs + main.rs — not yet wired
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::workload::{Score, Verdict};
/// The data captured for one workload run. The run loop owns this,
/// feeds it the verdict + score when the workload completes, and calls
/// `finish()` to flush.
pub struct Report {
pub schema_version: u32,
pub workload: String,
pub params: String,
/// Unix timestamp (seconds) when the run started.
pub started: u64,
/// ISA class — "integer", "sse", "avx2", "mixed", or "other".
/// Inferred from the workload name (see `isa_class`).
pub isa_class: &'static str,
/// Set by `finish()`. None until the workload completes.
pub verdict: Option<Verdict>,
/// Set by `finish()`. None for stability-only workloads (stress-ng),
/// or until the workload completes.
pub score: Option<Score>,
/// Path the report will be written to (set at construction).
path: PathBuf,
}
impl Report {
/// Construct a new report for a workload. Creates `report_dir` if
/// missing. The filename is `<unix-seconds>-<workload>.json`.
pub fn new(report_dir: &Path, workload: &str, params: &str) -> io::Result<Self> {
fs::create_dir_all(report_dir)?;
let started = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Sanitize the workload name for the filename (replace chars that
// are hostile to filesystems).
let safe = workload.replace(['/', ' ', ':'], "_");
let path = report_dir.join(format!("{started}-{safe}.json"));
Ok(Report {
schema_version: 2,
workload: workload.to_string(),
params: params.to_string(),
started,
isa_class: isa_class(workload),
verdict: None,
score: None,
path,
})
}
/// Mark the run complete + flush the final state with the verdict +
/// score. Called once, after the workload has exited and its score
/// has been parsed. The score is stored verbatim — `Score::None` is
/// kept (so `human_summary` can distinguish "stability-only workload"
/// from "not finished"), and `json_score` renders it as `null`.
pub fn finish(&mut self, verdict: Verdict, score: Score) -> io::Result<()> {
self.verdict = Some(verdict);
self.score = Some(score);
self.flush()
}
/// Where the report is being written. The run loop may want this for
/// the TUI / stdout summary.
pub fn path(&self) -> &Path {
&self.path
}
fn flush(&self) -> io::Result<()> {
let mut f = File::create(&self.path)?;
f.write_all(self.to_json().as_bytes())?;
f.sync_all().ok(); // best-effort; don't fail the run on sync error
Ok(())
}
/// Serialize to a JSON string. Used by `flush()` and by the
/// `--report <path>` printer in main.rs.
pub fn to_json(&self) -> String {
let mut out = String::with_capacity(1024);
out.push('{');
out.push_str(&format!("\"schema_version\":{},", self.schema_version));
out.push_str(&format!("\"workload\":{},", json_str(&self.workload)));
out.push_str(&format!("\"params\":{},", json_str(&self.params)));
out.push_str(&format!("\"started\":{},", self.started));
out.push_str(&format!("\"isa_class\":{},", json_str(self.isa_class)));
out.push_str(&format!("\"verdict\":{},", json_verdict(&self.verdict)));
out.push_str(&format!("\"score\":{}", json_score(&self.score)));
out.push('}');
out
}
}
/// JSON-encode a string. Escapes the minimal set (quote, backslash,
/// control chars). Assumes UTF-8 input (Rust strings are).
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
/// JSON-encode the verdict. None (run not finished) → `null`; otherwise
/// an object with `kind` + optional `detail`.
fn json_verdict(v: &Option<Verdict>) -> String {
match v {
None => "null".to_string(),
Some(Verdict::Clean) => "{\"kind\":\"clean\"}".to_string(),
Some(Verdict::Failed(msg)) => format!("{{\"kind\":\"failed\",\"detail\":{}}}", json_str(msg)),
Some(Verdict::Stopped) => "{\"kind\":\"stopped\"}".to_string(),
Some(Verdict::Error(msg)) => format!("{{\"kind\":\"error\",\"detail\":{}}}", json_str(msg)),
}
}
/// JSON-encode a Score. None (no score / not finished) → `null`.
/// Otherwise an object with `kind` + the per-kind fields.
fn json_score(s: &Option<Score>) -> String {
match s {
None => "null".to_string(),
Some(Score::None) => "null".to_string(),
Some(Score::SevenZip {
rating_mips,
r_u_mips,
usage_pct,
compress_mips,
decompress_mips,
}) => format!(
"{{\"kind\":\"7zip\",\"rating_mips\":{rating_mips},\"r_u_mips\":{r_u_mips},\
\"usage_pct\":{usage_pct},\"compress_mips\":{compress_mips},\
\"decompress_mips\":{decompress_mips}}}"
),
Some(Score::YCruncher { tests }) => {
let mut out = String::from("{\"kind\":\"y-cruncher\",\"tests\":[");
for (i, t) in tests.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"tag\":{},\"passed\":{},\"bits_per_sec\":{}}}",
json_str(&t.tag),
t.passed,
t.bits_per_sec
));
}
out.push_str("]}");
out
}
}
}
/// Infer the ISA class from the workload name. Used for the report's
/// `isa_class` field so sweeps can be grouped/diffed by rail.
pub fn isa_class(workload: &str) -> &'static str {
match workload {
"stress-ng-cpu" => "integer",
"y-cruncher-sse" => "sse",
"y-cruncher-avx2" => "avx2",
"7zip-bench" => "mixed",
"c2c-latency" | "dram-latency" => "other",
_ => "other",
}
}
/// Human-readable one-line summary of a finished report. Printed to
/// stdout after the TUI exits, one per workload in the sweep.
pub fn human_summary(r: &Report) -> String {
let verdict = match &r.verdict {
Some(Verdict::Clean) => "CLEAN".to_string(),
Some(Verdict::Failed(msg)) => format!("FAILED ({msg})"),
Some(Verdict::Stopped) => "STOPPED".to_string(),
Some(Verdict::Error(msg)) => format!("ERROR ({msg})"),
None => "(not finished)".to_string(),
};
let score = match &r.score {
None => "(no score)".to_string(),
Some(Score::None) => "(stability only)".to_string(),
Some(Score::SevenZip { rating_mips, .. }) => format!("rating={rating_mips} MIPS"),
Some(Score::YCruncher { tests }) => {
if tests.len() == 1 {
let t = &tests[0];
let gbps = t.bits_per_sec / 1e9;
format!("{} {:.2} Gb/s", t.tag, gbps)
} else {
format!("{} tests", tests.len())
}
}
};
format!(
"{:<18} {:<8} {:<24} verdict={}",
r.workload, r.isa_class, score, verdict
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workload::YCruncherTest;
/// Unique dir per test (by test name) so tests don't race on a shared
/// directory when one test's `remove_dir_all` wipes another's parent.
fn test_dir(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/opencode/test-reports").join(name)
}
fn sample_report() -> Report {
let mut r = Report::new(&test_dir("sample_report"), "y-cruncher-avx2", "60s cores=0-15 FFTv4")
.unwrap();
r.finish(
Verdict::Clean,
Score::YCruncher {
tests: vec![YCruncherTest {
tag: "FFTv4".to_string(),
passed: true,
bits_per_sec: 8.94e8,
}],
},
)
.unwrap();
r
}
#[test]
fn isa_class_infers_from_name() {
assert_eq!(isa_class("stress-ng-cpu"), "integer");
assert_eq!(isa_class("y-cruncher-sse"), "sse");
assert_eq!(isa_class("y-cruncher-avx2"), "avx2");
assert_eq!(isa_class("7zip-bench"), "mixed");
assert_eq!(isa_class("c2c-latency"), "other");
assert_eq!(isa_class("unknown-future"), "other");
}
#[test]
fn json_escapes_strings() {
assert_eq!(json_str("hello"), "\"hello\"");
assert_eq!(json_str("a\"b"), "\"a\\\"b\"");
assert_eq!(json_str("a\\b"), "\"a\\\\b\"");
assert_eq!(json_str("a\nb"), "\"a\\nb\"");
assert_eq!(json_str(""), "\"\"");
}
#[test]
fn json_verdict_variants() {
assert_eq!(json_verdict(&None), "null");
assert_eq!(json_verdict(&Some(Verdict::Clean)), "{\"kind\":\"clean\"}");
assert_eq!(json_verdict(&Some(Verdict::Stopped)), "{\"kind\":\"stopped\"}");
assert!(json_verdict(&Some(Verdict::Failed("oops".to_string()))).contains("\"kind\":\"failed\""));
assert!(json_verdict(&Some(Verdict::Failed("oops".to_string()))).contains("\"detail\":\"oops\""));
}
#[test]
fn json_score_null_for_none() {
assert_eq!(json_score(&None), "null");
assert_eq!(json_score(&Some(Score::None)), "null");
}
#[test]
fn json_score_7zip_round_trips() {
let s = Score::SevenZip {
rating_mips: 66259,
r_u_mips: 4653,
usage_pct: 1431,
compress_mips: 5346,
decompress_mips: 3961,
};
let j = json_score(&Some(s));
assert!(j.contains("\"kind\":\"7zip\""));
assert!(j.contains("\"rating_mips\":66259"));
assert!(j.contains("\"r_u_mips\":4653"));
assert!(j.contains("\"usage_pct\":1431"));
assert!(j.contains("\"compress_mips\":5346"));
assert!(j.contains("\"decompress_mips\":3961"));
}
#[test]
fn json_score_ycruncher_round_trips() {
let s = Score::YCruncher {
tests: vec![
YCruncherTest { tag: "FFTv4".to_string(), passed: true, bits_per_sec: 8.94e8 },
YCruncherTest { tag: "BKT".to_string(), passed: true, bits_per_sec: 9.49e9 },
],
};
let j = json_score(&Some(s));
assert!(j.contains("\"kind\":\"y-cruncher\""));
assert!(j.contains("\"tag\":\"FFTv4\""));
assert!(j.contains("\"passed\":true"));
assert!(j.contains("\"bits_per_sec\":894000000"));
assert!(j.contains("\"tag\":\"BKT\""));
}
#[test]
fn finish_stores_score_and_verdict() {
let mut r = Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
r.finish(
Verdict::Clean,
Score::SevenZip {
rating_mips: 1000,
r_u_mips: 500,
usage_pct: 1500,
compress_mips: 700,
decompress_mips: 300,
},
)
.unwrap();
assert_eq!(r.verdict, Some(Verdict::Clean));
assert!(matches!(r.score, Some(Score::SevenZip { .. })));
}
#[test]
fn finish_keeps_none_score_for_summary() {
let mut r = Report::new(&test_dir("finish_keeps_none_score_for_summary"), "test", "test").unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
// Stored as Some(Score::None) so human_summary can distinguish
// "stability only" from "not finished". JSON still renders null.
assert_eq!(r.score, Some(Score::None));
assert!(r.to_json().contains("\"score\":null"));
}
#[test]
fn to_json_has_schema_v2() {
let r = sample_report();
let j = r.to_json();
assert!(j.contains("\"schema_version\":2"));
assert!(!j.contains("\"samples\"")); // v1 field, gone
assert!(!j.contains("\"peak_clock_khz\"")); // v1 field, gone
}
#[test]
fn to_json_is_valid_structure() {
let r = sample_report();
let j = r.to_json();
assert!(j.starts_with('{'));
assert!(j.ends_with('}'));
assert!(j.contains("\"workload\":\"y-cruncher-avx2\""));
assert!(j.contains("\"isa_class\":\"avx2\""));
assert!(j.contains("\"verdict\":{\"kind\":\"clean\"}"));
assert!(j.contains("\"kind\":\"y-cruncher\""));
assert!(j.contains("\"tag\":\"FFTv4\""));
assert!(j.contains("\"bits_per_sec\":894000000"));
}
#[test]
fn flush_writes_readable_file() {
let dir = test_dir("flush_writes_readable_file");
let _ = fs::remove_dir_all(&dir);
let mut r = Report::new(&dir, "flush-test", "30s cores=0-15").unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
let written = fs::read_to_string(r.path()).unwrap();
assert!(written.contains("\"workload\":\"flush-test\""));
assert!(written.contains("\"verdict\":{\"kind\":\"clean\"}"));
assert!(written.contains("\"score\":null"));
assert!(r.path().file_name().unwrap().to_str().unwrap().contains("flush-test.json"));
}
#[test]
fn human_summary_formats_one_line() {
let r = sample_report();
let s = human_summary(&r);
assert!(s.contains("y-cruncher-avx2"));
assert!(s.contains("avx2"));
assert!(s.contains("CLEAN"));
assert!(s.contains("FFTv4"));
assert!(s.contains("Gb/s"));
}
#[test]
fn human_summary_for_7zip() {
let mut r = Report::new(&test_dir("human_summary_for_7zip"), "7zip-bench", "60s").unwrap();
r.finish(
Verdict::Clean,
Score::SevenZip {
rating_mips: 66259,
r_u_mips: 4653,
usage_pct: 1431,
compress_mips: 5346,
decompress_mips: 3961,
},
)
.unwrap();
let s = human_summary(&r);
assert!(s.contains("66259 MIPS"));
assert!(s.contains("mixed"));
}
#[test]
fn human_summary_for_stability_only() {
let mut r = Report::new(&test_dir("human_summary_for_stability_only"), "stress-ng-cpu", "60s").unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
let s = human_summary(&r);
assert!(s.contains("(stability only)"));
assert!(s.contains("integer"));
assert!(s.contains("CLEAN"));
}
#[test]
fn filename_sanitizes_workload_name() {
let dir = test_dir("filename_sanitizes_workload_name");
let _ = fs::remove_dir_all(&dir);
let r = Report::new(&dir, "workload/with:spaces", "test").unwrap();
let name = r.path().file_name().unwrap().to_str().unwrap();
assert!(!name.contains('/'));
assert!(!name.contains(':'));
assert!(!name.contains(' '));
}
}