//! 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, /// Set by `finish()`. None until the workload completes. pub verdict: Option, /// 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, /// Max vCore observed across all ticks, volts. pub peak_vcore: Option, /// Max package temp across all ticks, °C. pub peak_pkg_temp: Option, /// Max package power across all ticks, watts. pub peak_pkg_power: Option, /// 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 `-.json`. pub fn new(report_dir: &Path, workload: &str, params: &str) -> io::Result { 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 ` 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 — 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) -> 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) -> 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::>() .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(' ')); } }