//! JSON report writer. One file per workload run, under `report_dir`
//! (`./runs/` by default). Schema v1: run metadata, per-tick sensor
//! samples, workload verdict, observed peak clock, ISA class.
//!
//! Incremental flush: the whole file is rewritten on each `add_sample`
//! call. For a 60s run at 250ms poll that's ~240 samples × ~200 bytes =
//! ~48 KB per rewrite — cheap. A crash mid-run leaves the last
//! successfully-flushed state on disk, which is a valid (partial) JSON
//! document (the verdict + peak clock fields are filled with null until
//! the run completes, so a partial file is still 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.
#![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::sensors::Snapshot;
use crate::workload::Verdict;
/// The data captured for one workload run. The run loop owns this,
/// feeds it samples each tick, and calls `finish()` with the verdict +
/// observed peak clock when the workload completes.
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,
/// Per-tick sensor samples, in order.
pub samples: Vec<Snapshot>,
/// Set by `finish()`. None until the workload completes.
pub verdict: Option<Verdict>,
/// Session peak clock across all ticks, kHz. None until the first
/// tick with a readable clock. The per-ISA offset measurement.
pub peak_clock_khz: Option<u64>,
/// Max vCore observed across all ticks, volts.
pub peak_vcore: Option<f64>,
/// Max package temp across all ticks, °C.
pub peak_pkg_temp: Option<f64>,
/// Max package power across all ticks, watts.
pub peak_pkg_power: Option<f64>,
/// 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: 1,
workload: workload.to_string(),
params: params.to_string(),
started,
isa_class: isa_class(workload),
samples: Vec::new(),
verdict: None,
peak_clock_khz: None,
peak_vcore: None,
peak_pkg_temp: None,
peak_pkg_power: None,
path,
})
}
/// Append a sample + update the running peaks, then flush the whole
/// file. Called by the run loop each tick.
pub fn add_sample(&mut self, s: Snapshot) -> io::Result<()> {
if let Some(khz) = s.peak_clock_khz {
self.peak_clock_khz = Some(self.peak_clock_khz.map_or(khz, |p| p.max(khz)));
}
if let Some(v) = s.vcore {
self.peak_vcore = Some(self.peak_vcore.map_or(v, |p| p.max(v)));
}
if let Some(t) = s.pkg_temp {
self.peak_pkg_temp = Some(self.peak_pkg_temp.map_or(t, |p| p.max(t)));
}
if let Some(w) = s.pkg_power {
self.peak_pkg_power = Some(self.peak_pkg_power.map_or(w, |p| p.max(w)));
}
self.samples.push(s);
self.flush()
}
/// Mark the run complete + flush the final state with the verdict.
pub fn finish(&mut self, verdict: Verdict) -> io::Result<()> {
self.verdict = Some(verdict);
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(4096);
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!(
"\"peak_clock_khz\":{},",
json_num(self.peak_clock_khz.map(|v| v as f64))
));
out.push_str(&format!("\"peak_vcore\":{},", json_num(self.peak_vcore)));
out.push_str(&format!("\"peak_pkg_temp\":{},", json_num(self.peak_pkg_temp)));
out.push_str(&format!("\"peak_pkg_power\":{},", json_num(self.peak_pkg_power)));
out.push_str("\"samples\":[");
for (i, s) in self.samples.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&sample_json(s));
}
out.push_str("]}");
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 an Option<f64> — None → `null`, Some(v) → the number.
/// NaN/inf can't appear in JSON; clamp to null if they somehow do.
/// Forces at least one decimal place so `0.0` renders as `0.0` not `0`
/// (keeps the report readable + the tests honest).
fn json_num(v: Option<f64>) -> String {
match v {
Some(x) if x.is_finite() => {
if x.fract() == 0.0 {
format!("{x:.1}")
} else {
format!("{x}")
}
}
_ => "null".to_string(),
}
}
/// 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 one sample. Skips the `energy_uj` private field (that's
/// for the run loop's delta math, not the report).
fn sample_json(s: &Snapshot) -> String {
format!(
"{{\"t\":{},\"vcore\":{},\"pkg_temp\":{},\"pkg_power\":{},\"peak_clock_khz\":{},\"core_freqs\":[{}]}}",
json_num(Some(s.t)),
json_num(s.vcore),
json_num(s.pkg_temp),
json_num(s.pkg_power),
json_num(s.peak_clock_khz.map(|v| v as f64)),
s.core_freqs
.iter()
.map(|(cpu, khz)| format!("[{cpu},{khz}]"))
.collect::<Vec<_>>()
.join(",")
)
}
/// 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 peak_clk = r
.peak_clock_khz
.map(|khz| format!("{:.3} GHz", khz as f64 / 1_000_000.0))
.unwrap_or_else(|| "unreadable".to_string());
let peak_v = r
.peak_vcore
.map(|v| format!("{:.3} V", v))
.unwrap_or_else(|| "unreadable".to_string());
let peak_t = r
.peak_pkg_temp
.map(|t| format!("{:.1} C", t))
.unwrap_or_else(|| "unreadable".to_string());
let peak_w = r
.peak_pkg_power
.map(|w| format!("{:.1} W", w))
.unwrap_or_else(|| "unreadable".to_string());
format!(
"{:<18} {:<8} peak_clk={:<12} vCore={:<10} pkg={:<10} temp={:<10} verdict={}",
r.workload, r.isa_class, peak_clk, peak_v, peak_w, peak_t, verdict
)
}
#[cfg(test)]
mod tests {
use super::*;
/// 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")
.unwrap();
r.add_sample(Snapshot {
t: 0.0,
vcore: Some(1.1),
pkg_temp: Some(50.0),
pkg_power: None,
peak_clock_khz: Some(4_000_000),
core_freqs: vec![(0, 4_000_000)],
energy_uj: None,
})
.unwrap();
r.add_sample(Snapshot {
t: 0.25,
vcore: Some(1.15),
pkg_temp: Some(55.0),
pkg_power: Some(95.0),
peak_clock_khz: Some(4_100_000),
core_freqs: vec![(0, 4_100_000), (1, 4_000_000)],
energy_uj: None,
})
.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_num_handles_none_and_finite() {
assert_eq!(json_num(None), "null");
assert_eq!(json_num(Some(1.5)), "1.5");
assert_eq!(json_num(Some(f64::NAN)), "null");
assert_eq!(json_num(Some(f64::INFINITY)), "null");
}
#[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 add_sample_updates_peaks() {
let mut r = Report::new(&test_dir("add_sample_updates_peaks"), "test", "test").unwrap();
r.add_sample(Snapshot {
t: 0.0,
vcore: Some(1.1),
pkg_temp: Some(50.0),
pkg_power: Some(80.0),
peak_clock_khz: Some(4_000_000),
core_freqs: vec![],
energy_uj: None,
})
.unwrap();
r.add_sample(Snapshot {
t: 0.25,
vcore: Some(1.05), // lower than prev
pkg_temp: Some(60.0), // higher
pkg_power: Some(95.0), // higher
peak_clock_khz: Some(3_900_000), // lower
core_freqs: vec![],
energy_uj: None,
})
.unwrap();
assert_eq!(r.peak_vcore, Some(1.1)); // peak, not latest
assert_eq!(r.peak_pkg_temp, Some(60.0));
assert_eq!(r.peak_pkg_power, Some(95.0));
assert_eq!(r.peak_clock_khz, Some(4_000_000));
}
#[test]
fn add_sample_keeps_peak_when_none() {
let mut r = Report::new(&test_dir("add_sample_keeps_peak_when_none"), "test", "test").unwrap();
r.add_sample(Snapshot {
t: 0.0,
vcore: Some(1.1),
pkg_temp: Some(50.0),
pkg_power: Some(80.0),
peak_clock_khz: Some(4_000_000),
core_freqs: vec![],
energy_uj: None,
})
.unwrap();
r.add_sample(Snapshot {
t: 0.25,
vcore: None, // sensor unreadable this tick
pkg_temp: None,
pkg_power: None,
peak_clock_khz: None,
core_freqs: vec![],
energy_uj: None,
})
.unwrap();
// Peaks stay at the previous tick's values — None doesn't reset.
assert_eq!(r.peak_vcore, Some(1.1));
assert_eq!(r.peak_pkg_temp, Some(50.0));
assert_eq!(r.peak_pkg_power, Some(80.0));
assert_eq!(r.peak_clock_khz, Some(4_000_000));
}
#[test]
fn to_json_is_valid_structure() {
let r = sample_report();
let json = r.to_json();
assert!(json.starts_with('{'));
assert!(json.ends_with('}'));
assert!(json.contains("\"schema_version\":1"));
assert!(json.contains("\"workload\":\"y-cruncher-avx2\""));
assert!(json.contains("\"isa_class\":\"avx2\""));
assert!(json.contains("\"samples\":["));
assert!(json.contains("\"t\":0.0"));
assert!(json.contains("\"t\":0.25"));
// core_freqs encoded as [[cpu,khz],...] — second sample has two
// entries: cpu0 @ 4.1 GHz, cpu1 @ 4.0 GHz
assert!(json.contains("[[0,4100000],[1,4000000]]"));
}
#[test]
fn to_json_handles_empty_samples() {
let r = Report::new(&test_dir("to_json_handles_empty_samples"), "test", "test").unwrap();
let json = r.to_json();
assert!(json.contains("\"samples\":[]"));
}
#[test]
fn to_json_with_verdict() {
let mut r = sample_report();
r.finish(Verdict::Clean).unwrap();
let json = r.to_json();
assert!(json.contains("\"verdict\":{\"kind\":\"clean\"}"));
}
#[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.add_sample(Snapshot {
t: 0.0,
vcore: Some(1.2),
pkg_temp: Some(45.0),
pkg_power: Some(50.0),
peak_clock_khz: Some(4_500_000),
core_freqs: vec![],
energy_uj: None,
})
.unwrap();
r.finish(Verdict::Clean).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("\"t\":0.0"));
// Path includes the started timestamp + sanitized name
assert!(r.path().file_name().unwrap().to_str().unwrap().contains("flush-test.json"));
}
#[test]
fn human_summary_formats_one_line() {
let mut r = sample_report();
r.finish(Verdict::Clean).unwrap();
let s = human_summary(&r);
assert!(s.contains("y-cruncher-avx2"));
assert!(s.contains("avx2"));
assert!(s.contains("CLEAN"));
assert!(s.contains("peak_clk="));
assert!(s.contains("vCore="));
}
#[test]
fn human_summary_handles_unreadable_sensors() {
let r = Report::new(&test_dir("human_summary_handles_unreadable_sensors"), "test", "test").unwrap();
let s = human_summary(&r);
assert!(s.contains("unreadable"));
}
#[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(' '));
}
}