josie / alder-tools

report.rs + run.rs (state machine + headless mode, verified live)

Two modules; the full sweep pipeline now runs end-to-end without a TUI.
Last major module before the TUI is ui.rs.

report.rs (13 tests + clippy clean):
- Report struct: new(dir, name, params), add_sample, finish, path, to_json.
- Schema v1: schema_version, workload, params, started, isa_class
  (inferred: integer/sse/avx2/mixed/other), verdict {kind, detail?},
  peak_clock_khz, peak_vcore, peak_pkg_temp, peak_pkg_power, samples[].
- Incremental flush (rewrites whole file each tick — 48KB at 60s/250ms).
  Hand-rolled JSON (zero-dep). human_summary() for stdout.
- Per-test subdirs in tests to avoid /tmp races.

run.rs (4 tests + clippy clean):
- State machine: Cooldown(remaining) -> Running(duration) -> Done.
  Poll-driven (no async); tick() every poll_ms returns TickOutcome
  (WorkloadStarted / SampleTaken / WorkloadFinished / SweepDone / Idle).
- Run::new constructs all workloads up front, skips unknown with warning.
- MockWorkload in tests drives the state machine without real time.
- GOTCHA fixed: cooldown math must use Duration comparison, not whole-
  seconds subtraction (at 250ms poll, elapsed_secs is always 0, so the
  subtraction never advances). Fix: elapsed < Duration::from_secs(remaining).

main.rs:
- --headless mode drives the full sweep without a TUI (prints verdict +
  report path per workload; dots for progress).
- --dump, --smoke-workload retained.

Verified LIVE 2026-09-01 with --headless (2-workload test conf):
- stress-ng 3s: 11 samples, Clean verdict, peak 4.93 GHz / 1.304 V /
  142 W / 71 C.
- 7zip 3s: 11 samples, Stopped verdict (7z overruns, correctly stopped).
- Both JSON reports Python-validated as parseable.
- 37/37 tests pass; clippy clean.

9ceada7d48f44670eda0ac2aa840087f76dad2aa
josie <administrator@josie-c.com> · 2026-09-01T14:09 · browse files at this commit

parents: 863de1e

diff --git a/src/main.rs b/src/main.rs
index dece497..c614516 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,37 +1,101 @@
 mod config;
+mod report;
+mod run;
 mod sensors;
 mod workload;
 
 fn main() {
     let cfg = config::load();
     let args: Vec<String> = std::env::args().collect();
-    // `adlerbench --dump` = one-shot sensor snapshot (spike/verification
-    // tool, mirrors adlermon's dump mode). Default = config summary.
     if args.iter().any(|a| a == "--dump") {
-        let s = sensors::snapshot(None, 0.0);
-        println!("vCore      : {}", opt_volts(s.vcore));
-        println!("Pkg temp   : {}", opt_temp(s.pkg_temp));
-        println!("Pkg power  : {}  (None on first tick — no delta yet)", opt_watts(s.pkg_power));
-        println!("Peak clock : {}", opt_khz(s.peak_clock_khz));
-        println!("RAPL energy: {}", opt_uj(s.energy_uj()));
-        println!("Cores      : {} logical", s.core_freqs.len());
+        dump_sensors();
         return;
     }
-    // `adlerbench --smoke-workload <name>` = run one workload for 3s and
-    // print its verdict. Verifies the Workload trait end-to-end without
-    // needing the run loop / TUI.
     if let Some(idx) = args.iter().position(|a| a == "--smoke-workload") {
         if let Some(name) = args.get(idx + 1) {
             smoke_workload(name, &cfg);
             return;
         }
     }
+    if args.iter().any(|a| a == "--headless") {
+        headless_run(cfg);
+        return;
+    }
     println!("adlerbench — {} workloads configured", cfg.workloads.len());
     for w in &cfg.workloads {
         println!("  {}  {}s  cores={}", w.name, w.duration_secs, w.cores);
     }
 }
 
+fn dump_sensors() {
+    let s = sensors::snapshot(None, 0.0);
+    println!("vCore      : {}", opt_volts(s.vcore));
+    println!("Pkg temp   : {}", opt_temp(s.pkg_temp));
+    println!("Pkg power  : {}  (None on first tick — no delta yet)", opt_watts(s.pkg_power));
+    println!("Peak clock : {}", opt_khz(s.peak_clock_khz));
+    println!("RAPL energy: {}", opt_uj(s.energy_uj()));
+    println!("Cores      : {} logical", s.core_freqs.len());
+}
+
+/// `--headless` mode: drive the run loop without a TUI. Prints one line
+/// per workload (the human summary) at the end. Useful for CI / scripts
+/// and for verifying run.rs end-to-end before the TUI is wired.
+fn headless_run(cfg: config::Config) {
+    use std::thread;
+    let mut run = run::Run::new(cfg);
+    for (name, why) in run.skipped() {
+        eprintln!("skipped: {name} ({why})");
+    }
+    if run.total_workloads() == 0 {
+        eprintln!("no workloads to run");
+        return;
+    }
+    let poll_ms = {
+        // Read poll_ms from the run's config via the cfg we still own —
+        // simpler than exposing it on Run.
+        // (We moved cfg into run::Run::new, so re-load to read poll_ms.
+        // A bit awkward; if it matters, expose cfg on Run later.)
+        config::load().poll_ms
+    };
+    let poll = std::time::Duration::from_millis(poll_ms);
+    let mut summaries: Vec<String> = Vec::new();
+    loop {
+        match run.tick() {
+            Ok(run::TickOutcome::WorkloadStarted { idx, name, duration_secs }) => {
+                println!("[{idx}] starting {name} ({duration_secs}s)");
+            }
+            Ok(run::TickOutcome::SampleTaken { latest }) => {
+                // In headless mode we don't print every sample — too
+                // noisy. Print one dot per tick to stderr so the user
+                // sees progress.
+                eprint!(".");
+                let _ = latest;
+            }
+            Ok(run::TickOutcome::WorkloadFinished { idx, name, verdict, report_path }) => {
+                eprintln!();
+                println!("[{idx}] finished {name}: {:?}", verdict);
+                println!("  report: {}", report_path.display());
+                // Re-read the report we just wrote + print its summary.
+                // (The run owns the Report; we don't have a borrow to it
+                // here. For the summary we'd need Run to expose it. For
+                // now, skip the summary in headless mode — the verdict +
+                // path are the useful parts.)
+                summaries.push(name);
+            }
+            Ok(run::TickOutcome::SweepDone) => {
+                println!("sweep complete: {} workloads", summaries.len());
+                break;
+            }
+            Ok(run::TickOutcome::Idle) => {}
+            Err(e) => {
+                eprintln!("run error: {e}");
+                break;
+            }
+        }
+        thread::sleep(poll);
+    }
+}
+
 fn smoke_workload(name: &str, cfg: &config::Config) {
     use std::thread;
     use std::time::Duration;
diff --git a/src/report.rs b/src/report.rs
new file mode 100644
index 0000000..08cd224
--- /dev/null
+++ b/src/report.rs
@@ -0,0 +1,479 @@
+//! 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(' '));
+    }
+}
\ No newline at end of file
diff --git a/src/run.rs b/src/run.rs
new file mode 100644
index 0000000..94be5b1
--- /dev/null
+++ b/src/run.rs
@@ -0,0 +1,414 @@
+//! Run orchestration — the new core (adlermon has no equivalent). Owns
+//! the sweep lifecycle: for each workload in the config, cooldown →
+//! workload.start() → tick loop (sample sensors, add to report) →
+//! workload.stop() / wait for exit → flush report. Front-end-agnostic:
+//! the TUI drives it by calling `tick()` every `poll_ms`; a future
+//! `--headless` mode does the same without rendering.
+//!
+//! State machine (poll-driven, no async):
+//!   Cooldown(remaining) ── elapsed → Running(idx)
+//!   Running(idx) ── workload exits OR duration elapsed → ReportFinished(idx+1)
+//!   ReportFinished(idx) ── next idx exists → Cooldown(cool_secs)
+//!                         └─ no more workloads → Done
+
+#![allow(dead_code)] // consumed by main.rs + ui.rs — ui.rs not yet wired
+
+use std::io;
+use std::time::{Duration, Instant};
+
+use crate::config::Config;
+use crate::report::Report;
+use crate::sensors;
+use crate::workload::{self, Verdict, Workload};
+
+/// What a tick produced. The caller (TUI or headless loop) uses this to
+/// decide what to render + when to stop driving the run.
+#[derive(Clone, Debug)]
+pub enum TickOutcome {
+    /// A workload was just started this tick. `idx` is the workload index,
+    /// `name` is its name, `duration_secs` is the planned run length.
+    WorkloadStarted { idx: usize, name: String, duration_secs: u64 },
+    /// A sensor sample was taken and added to the report. The latest
+    /// snapshot is included so the TUI can render it without borrowing
+    /// into the run's history.
+    SampleTaken { latest: sensors::Snapshot },
+    /// The workload exited (either it self-finished or the duration timer
+    /// elapsed and we stopped it). The verdict + report path are included
+    /// for the TUI's result table + stdout summary.
+    WorkloadFinished {
+        idx: usize,
+        name: String,
+        verdict: Verdict,
+        report_path: std::path::PathBuf,
+    },
+    /// The sweep is complete (all workloads done, final cooldown elapsed).
+    SweepDone,
+    /// Nothing happened this tick (still in cooldown or still running and
+    /// no sample was due). The caller can just render the existing state.
+    Idle,
+}
+
+/// Run state. The run loop (TUI or headless) owns one of these and calls
+/// `tick()` every `poll_ms`.
+pub struct Run {
+    cfg: Config,
+    /// All workloads that could be built from the config, in order. Names
+    /// that `from_spec` couldn't wire (deferred impls, typos) are skipped
+    /// during construction with a warning stored here so the TUI can show
+    /// them.
+    workloads: Vec<Option<Box<dyn Workload>>>,
+    /// Skipped workload names + why (for the TUI / stdout warning).
+    skipped: Vec<(String, String)>,
+    /// Index into `workloads` for the current/next workload to run.
+    current: usize,
+    state: State,
+    /// When the current state (Cooldown or Running) started.
+    state_start: Instant,
+    /// When the last sensor sample was taken (for the dt_secs in power
+    /// delta math + pacing samples within a tick).
+    last_sample: Instant,
+    /// Previous RAPL energy reading, for the power-delta computation in
+    /// `sensors::snapshot`. None on the first sample of a workload.
+    prev_energy_uj: Option<u64>,
+    /// The report being written for the current workload.
+    report: Option<Report>,
+}
+
+enum State {
+    /// Cooling down before starting workload `current`. `remaining` is the
+    /// cooldown left in seconds (drives the TUI's cooldown display).
+    Cooldown { remaining: u64 },
+    /// Running workload `current`. `duration_secs` is the planned length
+    /// (the run loop stops the workload when elapsed >= this).
+    Running { duration_secs: u64 },
+    /// All workloads done; the sweep is over. Further ticks return
+    /// `SweepDone` indefinitely.
+    Done,
+}
+
+impl Run {
+    /// Build a run from a config. Constructs all workloads up front so
+    /// start-time errors (missing binary, etc.) surface before the sweep
+    /// begins rather than mid-sweep. Workloads that can't be built are
+    /// skipped with a recorded warning.
+    pub fn new(cfg: Config) -> Self {
+        let mut workloads: Vec<Option<Box<dyn Workload>>> = Vec::new();
+        let mut skipped: Vec<(String, String)> = Vec::new();
+        for spec in &cfg.workloads {
+            match workload::from_spec(spec, &cfg.bin_dir) {
+                Some(w) => workloads.push(Some(w)),
+                None => skipped.push((
+                    spec.name.clone(),
+                    "unknown or not-yet-wired workload".to_string(),
+                )),
+            }
+        }
+        Run {
+            cfg,
+            workloads,
+            skipped,
+            current: 0,
+            state: State::Cooldown {
+                remaining: 0,
+            },
+            state_start: Instant::now(),
+            last_sample: Instant::now(),
+            prev_energy_uj: None,
+            report: None,
+        }
+    }
+
+    /// Skipped workload names + reasons (for the TUI / stdout warning).
+    pub fn skipped(&self) -> &[(String, String)] {
+        &self.skipped
+    }
+
+    /// The total number of workloads that will run (skipped ones excluded).
+    pub fn total_workloads(&self) -> usize {
+        self.workloads.iter().filter(|w| w.is_some()).count()
+    }
+
+    /// One tick of the state machine. Call every `poll_ms`. Reads sensors
+    /// (when running), advances the state, and returns what happened.
+    pub fn tick(&mut self) -> io::Result<TickOutcome> {
+        let elapsed = self.state_start.elapsed();
+        match &mut self.state {
+            State::Cooldown { remaining } => {
+                let cooldown_dur = Duration::from_secs(*remaining);
+                if elapsed < cooldown_dur {
+                    // Still cooling. Don't reset state_start — `elapsed`
+                    // is measured from the cooldown's start, and comparing
+                    // to the full cooldown duration is the clean test.
+                    return Ok(TickOutcome::Idle);
+                }
+                // Cooldown done — start the next workload.
+                self.start_current_workload()
+            }
+            State::Running { duration_secs } => {
+                let running = self.workloads[self.current]
+                    .as_mut()
+                    .map(|w| w.is_running())
+                    .unwrap_or(false);
+                if !running || elapsed >= Duration::from_secs(*duration_secs) {
+                    // Workload finished (on its own or we stop it now).
+                    return self.finish_current_workload();
+                }
+                // Still running — take a sensor sample.
+                let now = Instant::now();
+                let dt = now.duration_since(self.last_sample).as_secs_f64();
+                self.last_sample = now;
+                let snap = sensors::snapshot(self.prev_energy_uj, dt);
+                self.prev_energy_uj = snap.energy_uj();
+                // Stamp the sample with the workload's elapsed time.
+                let t = elapsed.as_secs_f64();
+                let mut snap = snap;
+                snap.t = t;
+                if let Some(r) = &mut self.report {
+                    r.add_sample(snap.clone())?;
+                }
+                Ok(TickOutcome::SampleTaken { latest: snap })
+            }
+            State::Done => Ok(TickOutcome::SweepDone),
+        }
+    }
+
+    fn start_current_workload(&mut self) -> io::Result<TickOutcome> {
+        // Skip any None entries (shouldn't happen since `current` only
+        // advances to Some indices, but be defensive).
+        while self.current < self.workloads.len() && self.workloads[self.current].is_none() {
+            self.current += 1;
+        }
+        if self.current >= self.workloads.len() {
+            self.state = State::Done;
+            return Ok(TickOutcome::SweepDone);
+        }
+        let spec = &self.cfg.workloads[self.current];
+        let report = Report::new(&self.cfg.report_dir, &spec.name, &self.workload_params(spec))?;
+        self.report = Some(report);
+        let w = self.workloads[self.current].as_mut().unwrap();
+        w.start()?;
+        let duration_secs = spec.duration_secs;
+        self.state = State::Running { duration_secs };
+        self.state_start = Instant::now();
+        self.last_sample = Instant::now();
+        self.prev_energy_uj = None;
+        Ok(TickOutcome::WorkloadStarted {
+            idx: self.current,
+            name: spec.name.clone(),
+            duration_secs,
+        })
+    }
+
+    fn finish_current_workload(&mut self) -> io::Result<TickOutcome> {
+        let verdict = {
+            let w = self.workloads[self.current].as_mut();
+            if let Some(w) = w {
+                if w.is_running() {
+                    let _ = w.stop();
+                }
+                w.wait()
+            } else {
+                Verdict::Error("no workload".to_string())
+            }
+        };
+        let report_path = self
+            .report
+            .as_ref()
+            .map(|r| r.path().to_path_buf())
+            .unwrap_or_default();
+        if let Some(r) = &mut self.report {
+            r.finish(verdict.clone())?;
+        }
+        let idx = self.current;
+        let name = self.cfg.workloads[idx].name.clone();
+        // Advance to the next workload + enter cooldown.
+        self.current += 1;
+        // Skip None entries on advance too.
+        while self.current < self.workloads.len() && self.workloads[self.current].is_none() {
+            self.current += 1;
+        }
+        if self.current >= self.workloads.len() {
+            self.state = State::Done;
+        } else {
+            self.state = State::Cooldown {
+                remaining: self.cfg.cool_secs,
+            };
+            self.state_start = Instant::now();
+        }
+        Ok(TickOutcome::WorkloadFinished {
+            idx,
+            name,
+            verdict,
+            report_path,
+        })
+    }
+
+    /// Build the params string for a workload spec (mirrors the Workload's
+    /// own `params()` but available before the workload is started, so the
+    /// report header can be written at start time).
+    fn workload_params(&self, spec: &crate::config::WorkloadSpec) -> String {
+        format!("{}s cores={}", spec.duration_secs, spec.cores)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::WorkloadSpec;
+
+    /// A workload that exits after N `is_running()` calls, so tests can
+    /// drive the state machine without real time or real processes.
+    struct MockWorkload {
+        name: String,
+        ticks_until_exit: usize,
+        ticks_seen: usize,
+        started: bool,
+        stopped: bool,
+    }
+
+    impl Workload for MockWorkload {
+        fn name(&self) -> &str {
+            &self.name
+        }
+        fn params(&self) -> String {
+            format!("mock ticks={}", self.ticks_until_exit)
+        }
+        fn start(&mut self) -> io::Result<()> {
+            self.started = true;
+            Ok(())
+        }
+        fn is_running(&mut self) -> bool {
+            if !self.started || self.stopped {
+                return false;
+            }
+            self.ticks_seen += 1;
+            self.ticks_seen < self.ticks_until_exit
+        }
+        fn stop(&mut self) -> io::Result<()> {
+            self.stopped = true;
+            Ok(())
+        }
+        fn wait(&mut self) -> Verdict {
+            if self.stopped {
+                Verdict::Stopped
+            } else {
+                Verdict::Clean
+            }
+        }
+    }
+
+    fn mock_cfg(workloads: Vec<WorkloadSpec>) -> Config {
+        Config {
+            workloads,
+            poll_ms: 10,
+            cool_secs: 0, // no cooldown in tests
+            vcore_limit: 1.4,
+            temp_crit: 95.0,
+            report_dir: std::path::PathBuf::from("/tmp/opencode/run-tests"),
+            bin_dir: std::path::PathBuf::from("bin"),
+            loaded_path: None,
+        }
+    }
+
+    /// Build a Run with mock workloads in place of `from_spec`. Bypasses
+    /// the real constructor so we don't need real binaries.
+    fn run_with_mocks(cfg: Config, mocks: Vec<MockWorkload>) -> Run {
+        let workloads: Vec<Option<Box<dyn Workload>>> =
+            mocks.into_iter().map(|m| Some(Box::new(m) as Box<dyn Workload>)).collect();
+        Run {
+            cfg,
+            workloads,
+            skipped: Vec::new(),
+            current: 0,
+            state: State::Cooldown { remaining: 0 },
+            state_start: Instant::now(),
+            last_sample: Instant::now(),
+            prev_energy_uj: None,
+            report: None,
+        }
+    }
+
+    #[test]
+    fn sweep_runs_all_workloads_then_done() {
+        let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
+        let cfg = mock_cfg(vec![
+            WorkloadSpec { name: "mock-a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+            WorkloadSpec { name: "mock-b".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+        ]);
+        let mut run = run_with_mocks(cfg, vec![
+            MockWorkload { name: "mock-a".to_string(), ticks_until_exit: 3, ticks_seen: 0, started: false, stopped: false },
+            MockWorkload { name: "mock-b".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+        ]);
+        // Tick 1: cooldown (0s) elapses → start mock-a.
+        let mut started = 0;
+        let mut finished = 0;
+        let mut done = false;
+        for _ in 0..20 {
+            match run.tick().unwrap() {
+                TickOutcome::WorkloadStarted { name, .. } => {
+                    started += 1;
+                    assert!(name == "mock-a" || name == "mock-b");
+                }
+                TickOutcome::SampleTaken { .. } => {}
+                TickOutcome::WorkloadFinished { name, verdict, .. } => {
+                    finished += 1;
+                    assert!(name == "mock-a" || name == "mock-b");
+                    assert_eq!(verdict, Verdict::Clean);
+                }
+                TickOutcome::SweepDone => {
+                    done = true;
+                    break;
+                }
+                TickOutcome::Idle => {}
+            }
+        }
+        assert!(done);
+        assert_eq!(started, 2);
+        assert_eq!(finished, 2);
+    }
+
+    #[test]
+    fn stopped_verdict_when_duration_elapses() {
+        // MockWorkload that never self-exits (ticks_until_exit huge);
+        // the run's duration timer must stop it → Stopped verdict.
+        let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
+        let cfg = mock_cfg(vec![
+            WorkloadSpec { name: "never-exits".to_string(), duration_secs: 0, cores: "0-15".to_string() },
+        ]);
+        let mut run = run_with_mocks(cfg, vec![
+            MockWorkload { name: "never-exits".to_string(), ticks_until_exit: 1000, ticks_seen: 0, started: false, stopped: false },
+        ]);
+        let mut verdict = None;
+        for _ in 0..10 {
+            match run.tick().unwrap() {
+                TickOutcome::WorkloadFinished { verdict: v, .. } => {
+                    verdict = Some(v);
+                    break;
+                }
+                _ => {}
+            }
+        }
+        // duration_secs=0 → first Running tick sees elapsed >= 0 → stops.
+        assert_eq!(verdict, Some(Verdict::Stopped));
+    }
+
+    #[test]
+    fn total_workloads_counts_only_some() {
+        let cfg = mock_cfg(vec![]);
+        let mut run = run_with_mocks(cfg, vec![]);
+        run.workloads.push(None); // a skipped entry
+        run.workloads.push(Some(Box::new(MockWorkload {
+            name: "x".to_string(), ticks_until_exit: 1, ticks_seen: 0, started: false, stopped: false,
+        })));
+        assert_eq!(run.total_workloads(), 1);
+    }
+
+    #[test]
+    fn skipped_workloads_reported() {
+        let run = Run::new(Config::default());
+        // Can't easily inject skipped entries via the public ctor without
+        // an unknown workload name in the config; just verify the accessor
+        // exists + returns a slice.
+        let _ = run.skipped();
+    }
+}
\ No newline at end of file
diff --git a/src/sensors.rs b/src/sensors.rs
index d36529e..1332f27 100644
--- a/src/sensors.rs
+++ b/src/sensors.rs
@@ -240,7 +240,7 @@ pub struct Snapshot {
     pub core_freqs: Vec<(usize, u64)>,
     /// Raw RAPL energy counter from this tick (for delta math next tick).
     /// NOT serialized into the report — the report writer skips this field.
-    energy_uj: Option<u64>,
+    pub(crate) energy_uj: Option<u64>,
 }
 
 /// Read a fresh Snapshot. `prev_energy_uj` is the previous RAPL reading