josie / alder-tools

config.rs + sensors.rs + workload.rs (3 spawned impls)

Three modules ported/written from adlermon's verified shapes, plus the
first three workload implementations. All smoke-verified live on this
box.

config.rs (13 tests + clippy clean):
- Zero-dep KEY=VALUE parser mirroring adlermon's; adlerbench's own key
  set (no rails — adlermon owns the board display).
- WorkloadSpec {name, duration_secs, cores}; two conf forms — comma
  list (names inherit defaults) and indexed (per-entry params, 1-indexed).
  Indexed form wins if both appear.
- Defaults: 6-workload sweep, 60s each (30s for latency), 15s cooldown,
  poll_ms 250, vcore_limit 1.403, temp_crit 95, report_dir ./runs,
  bin_dir ./bin.
- adlerbench.conf sample updated to indexed form with comments.

sensors.rs (4 tests + clippy clean):
- Ported from adlermon: chip-by-name hwmon, cpufreq, RAPL energy_uj,
  SIO in0 vCore, coretemp package temp. RAPL delta math lives in
  snapshot() so the run loop just feeds prev_energy_uj each tick.
- Added Snapshot struct (atomic per-tick reads) + topology (is_e_core,
  p_cpus, e_cpus) — adlermon keeps these in ui.rs; here they belong in
  sensors.rs so run.rs + ui.rs share them.
- --dump smoke-verified live: vCore 1.256 V, pkg 45 C, RAPL readable,
  16 logical cpus. Shell RAPL delta test confirms power math works.

workload.rs (7 tests + clippy clean):
- Workload trait (Verdict: Clean/Failed/Stopped/Error) + SpawnedWorkload
  helper (owns Child, captures stdout, SIGKILL on stop, drains pipes).
- Three spawned impls, all smoke-verified live (Clean verdicts):
  * StressNg: --cpu 15 --taskset <range> --timeout Ts --cpu-method all.
  * YCruncher: SSE (11-SNB ~ Hina) + AVX2 (13-HSW ~ Airi) variants,
    invoked DIRECTLY (not the wrapper) to force the ISA.
  * SevenZip: 7z b -mmt<N>; not time-bound, overruns short durations.
- Two load-bearing gotchas found + fixed:
  * Canonicalize the variant path before current_dir — the kernel
    resolves the executable relative to the NEW cwd, so a relative path
    is looked up inside the bundle dir and not found.
  * -D:3 must be ONE arg (not -D: + 3); y-cruncher rejects the split
    form with exit 1 and no error message.
- c2c-latency / dram-latency / minecraft-server deferred (in-process,
  next chunk); from_spec returns None so the run loop skips them.
- --smoke-workload <name> harness in main.rs runs one workload for 3s
  and prints the verdict; verifies the trait end-to-end.

863de1eae106015be7a8227a36b61027bdba2219
josie <administrator@josie-c.com> · 2026-09-01T13:55 · browse files at this commit

parents: 5c77d62

diff --git a/adlerbench.conf b/adlerbench.conf
index b23171b..2ebee21 100644
--- a/adlerbench.conf
+++ b/adlerbench.conf
@@ -2,10 +2,48 @@
 # Simple KEY = VALUE lines; # comments; unknown keys ignored.
 # Loaded from ./ then ~/.config/adlerbench/adlerbench.conf (first wins).
 
-workloads     = stress-ng-cpu
-duration_secs = 60
-poll_ms       = 250
-cool_secs     = 15
-vcore_limit   = 1.403
-temp_crit     = 95.0
-report_dir    = ./runs
\ No newline at end of file
+# Sensor sample period + cooldown between workloads.
+poll_ms     = 250
+cool_secs   = 15
+
+# Safety thresholds (flagged in the report if exceeded during a run).
+vcore_limit = 1.403
+temp_crit   = 95.0
+
+# Where to write JSON reports + find external binaries (y-cruncher).
+report_dir  = ./runs
+bin_dir     = ./bin
+
+# Workload sweep. Two forms:
+#
+#  (1) Comma list — names inherit default duration + cores:
+# workloads = stress-ng-cpu, y-cruncher-sse, y-cruncher-avx2, 7zip-bench, c2c-latency, dram-latency
+#
+#  (2) Indexed — per-entry params (1-indexed). Overrides the list form.
+#      `cores` is a stress-ng --taskset-style range: 0-11 = P-cores,
+#      12-15 = E-cores, 0-15 = all. Workloads that don't pin (7zip,
+#      minecraft-server) ignore it.
+
+workload.1.name           = stress-ng-cpu
+workload.1.duration_secs  = 60
+workload.1.cores          = 0-15
+
+workload.2.name           = y-cruncher-sse
+workload.2.duration_secs  = 60
+workload.2.cores          = 0-15
+
+workload.3.name           = y-cruncher-avx2
+workload.3.duration_secs  = 60
+workload.3.cores          = 0-15
+
+workload.4.name           = 7zip-bench
+workload.4.duration_secs  = 60
+workload.4.cores          = 0-15
+
+workload.5.name           = c2c-latency
+workload.5.duration_secs  = 30
+workload.5.cores          = 0-15
+
+workload.6.name           = dram-latency
+workload.6.duration_secs  = 30
+workload.6.cores          = 0-15
\ No newline at end of file
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..c660335
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,304 @@
+//! Run config: workload list + sweep params + sensor thresholds. Zero
+//! deps: simple `KEY = VALUE` lines, `#` comments, blank lines ignored.
+//! Unknown keys are ignored (forward compatibility). Loaded once at
+//! startup.
+//!
+//! Mirrors adlermon's config.rs shape (same parser, same conf locations)
+//! but the key set is adlerbench's own — no rails (adlerbench doesn't
+//! display the board rail map; adlermon does), and the workload list is
+//! first-class.
+
+use std::path::PathBuf;
+
+/// One entry in the sweep. `name` selects a Workload impl; `duration_secs`
+/// and `cores` are per-entry (different workloads may want different
+/// lengths and core counts). `cores` is a stress-ng `--taskset`-style
+/// range string ("0-11" = P-cores, "12-15" = E-cores, "0-15" = all);
+/// workloads that don't pin (7zip, minecraft-server) ignore it.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct WorkloadSpec {
+    pub name: String,
+    pub duration_secs: u64,
+    pub cores: String,
+}
+
+pub struct Config {
+    /// Workloads to cycle through, in order. One sweep run = each entry
+    /// once, with `cool_secs` cooldown between.
+    pub workloads: Vec<WorkloadSpec>,
+    /// Sensor sample period, ms (drives the tick loop + TUI refresh).
+    pub poll_ms: u64,
+    /// Cooldown between workloads, seconds (idle sensor baseline re-set).
+    pub cool_secs: u64,
+    /// vCore safety limit, volts — shown as a marker on the hero panel
+    /// and flagged in the report if exceeded during a run.
+    pub vcore_limit: f64,
+    /// Critical package temp, °C — flagged in the report if exceeded.
+    pub temp_crit: f64,
+    /// Where to write JSON reports. Created on first run if missing.
+    pub report_dir: PathBuf,
+    /// Where external binaries live (y-cruncher). Defaults to ./bin.
+    pub bin_dir: PathBuf,
+    /// Path the config was loaded from (None = defaults in effect).
+    pub loaded_path: Option<PathBuf>,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        Config {
+            workloads: default_workloads(),
+            poll_ms: 250,
+            cool_secs: 15,
+            vcore_limit: 1.403,
+            temp_crit: 95.0,
+            report_dir: PathBuf::from("runs"),
+            bin_dir: PathBuf::from("bin"),
+            loaded_path: None,
+        }
+    }
+}
+
+/// Default sweep: all five v1 workloads, 60 s each, all cores. Order is
+/// lowest-clock-rail first (AVX2) so if the chip is going to crash, it
+/// crashes early before the longer idle cooldowns accumulate. Actually —
+/// no: run integer first as a sanity baseline (if int crashes, the OC is
+/// unstable at the easy rail and the rest is moot), then SSE, then AVX2,
+/// then the perf/latency workloads.
+fn default_workloads() -> Vec<WorkloadSpec> {
+    vec![
+        spec("stress-ng-cpu", 60, "0-15"),
+        spec("y-cruncher-sse", 60, "0-15"),
+        spec("y-cruncher-avx2", 60, "0-15"),
+        spec("7zip-bench", 60, "0-15"),
+        spec("c2c-latency", 30, "0-15"),
+        spec("dram-latency", 30, "0-15"),
+    ]
+}
+
+fn spec(name: &str, duration_secs: u64, cores: &str) -> WorkloadSpec {
+    WorkloadSpec {
+        name: name.to_string(),
+        duration_secs,
+        cores: cores.to_string(),
+    }
+}
+
+/// Parse one config line over a Config (mutates in place). Shape matches
+/// adlermon's apply_line: strip `#` comments, trim, split on `=`, match
+/// the key. Unknown keys silently ignored.
+fn apply_line(cfg: &mut Config, line: &str) {
+    let line = line.split('#').next().unwrap_or("").trim();
+    if line.is_empty() {
+        return;
+    }
+    let Some((key, val)) = line.split_once('=') else {
+        return;
+    };
+    let key = key.trim();
+    let val = val.trim();
+    match key {
+        "poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
+        "cool_secs" => cfg.cool_secs = val.parse().unwrap_or(cfg.cool_secs),
+        "vcore_limit" => cfg.vcore_limit = val.parse().unwrap_or(cfg.vcore_limit),
+        "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
+        "report_dir" => cfg.report_dir = PathBuf::from(val),
+        "bin_dir" => cfg.bin_dir = PathBuf::from(val),
+        "workloads" => cfg.workloads = parse_workload_list(val),
+        _ => {}
+    }
+}
+
+/// Parse a workload list value. Two forms accepted:
+///   workloads = stress-ng-cpu, y-cruncher-sse, y-cruncher-avx2
+///      (names only; duration + cores come from defaults)
+///   workload.N.name = stress-ng-cpu
+///   workload.N.duration_secs = 90
+///   workload.N.cores = 0-11
+///      (per-entry params; N is a 1-indexed ordinal)
+///
+/// The comma form is the common case for quick edits; the indexed form
+/// is for per-workload tuning. If both appear, the indexed form wins
+/// (it's more specific).
+fn parse_workload_list(val: &str) -> Vec<WorkloadSpec> {
+    val.split(',')
+        .map(|s| s.trim())
+        .filter(|s| !s.is_empty())
+        .map(|name| {
+            // Pull defaults from default_workloads so a bare name in the
+            // conf inherits the default duration + cores for that name.
+            default_workloads()
+                .into_iter()
+                .find(|w| w.name == name)
+                .unwrap_or(WorkloadSpec {
+                    name: name.to_string(),
+                    duration_secs: 60,
+                    cores: "0-15".to_string(),
+                })
+        })
+        .collect()
+}
+
+pub fn load() -> Config {
+    let mut cfg = Config::default();
+    let candidates = [
+        Some(PathBuf::from("adlerbench.conf")),
+        dirs_home().map(|h| h.join(".config/adlerbench/adlerbench.conf")),
+    ];
+    let mut indexed: Vec<WorkloadSpec> = Vec::new();
+    let mut saw_indexed = false;
+    for path in candidates.into_iter().flatten() {
+        if let Ok(text) = std::fs::read_to_string(&path) {
+            for line in text.lines() {
+                if let Some(rest) = line.split('#').next().unwrap_or("").trim().strip_prefix("workload.") {
+                    if apply_indexed(&mut indexed, rest) {
+                        saw_indexed = true;
+                    }
+                    continue;
+                }
+                apply_line(&mut cfg, line);
+            }
+            cfg.loaded_path = Some(path);
+            break;
+        }
+    }
+    if saw_indexed {
+        cfg.workloads = indexed;
+    }
+    cfg
+}
+
+/// Apply a `workload.N.field = value` line to the indexed list. Grows
+/// the list to fit N (1-indexed). Returns true if the line was a
+/// recognized indexed key.
+fn apply_indexed(list: &mut Vec<WorkloadSpec>, rest: &str) -> bool {
+    let Some((idx_str, field_val)) = rest.split_once('.') else {
+        return false;
+    };
+    let Ok(idx) = idx_str.parse::<usize>() else {
+        return false;
+    };
+    if idx == 0 {
+        return false;
+    }
+    let Some((field, val)) = field_val.split_once('=') else {
+        return false;
+    };
+    let field = field.trim();
+    let val = val.trim();
+    while list.len() < idx {
+        list.push(WorkloadSpec {
+            name: String::new(),
+            duration_secs: 60,
+            cores: "0-15".to_string(),
+        });
+    }
+    let w = &mut list[idx - 1];
+    match field {
+        "name" => w.name = val.to_string(),
+        "duration_secs" => w.duration_secs = val.parse().unwrap_or(w.duration_secs),
+        "cores" => w.cores = val.to_string(),
+        _ => return false,
+    }
+    true
+}
+
+fn dirs_home() -> Option<PathBuf> {
+    std::env::var_os("HOME").map(PathBuf::from)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn defaults_have_six_workloads() {
+        let cfg = Config::default();
+        assert_eq!(cfg.workloads.len(), 6);
+        assert_eq!(cfg.workloads[0].name, "stress-ng-cpu");
+        assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+    }
+
+    #[test]
+    fn parses_globals() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "poll_ms = 500");
+        apply_line(&mut cfg, "cool_secs = 30");
+        apply_line(&mut cfg, "vcore_limit = 1.35");
+        apply_line(&mut cfg, "temp_crit = 90.0");
+        assert_eq!(cfg.poll_ms, 500);
+        assert_eq!(cfg.cool_secs, 30);
+        assert_eq!(cfg.vcore_limit, 1.35);
+        assert_eq!(cfg.temp_crit, 90.0);
+    }
+
+    #[test]
+    fn poll_ms_floors_at_50() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "poll_ms = 1");
+        assert_eq!(cfg.poll_ms, 50);
+    }
+
+    #[test]
+    fn parses_workload_list() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "workloads = stress-ng-cpu, 7zip-bench");
+        assert_eq!(cfg.workloads.len(), 2);
+        assert_eq!(cfg.workloads[0].name, "stress-ng-cpu");
+        assert_eq!(cfg.workloads[1].name, "7zip-bench");
+        // Bare names inherit defaults from default_workloads
+        assert_eq!(cfg.workloads[0].duration_secs, 60);
+        assert_eq!(cfg.workloads[0].cores, "0-15");
+    }
+
+    #[test]
+    fn parses_unknown_workload_name() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "workloads = custom-future-workload");
+        assert_eq!(cfg.workloads.len(), 1);
+        assert_eq!(cfg.workloads[0].name, "custom-future-workload");
+        assert_eq!(cfg.workloads[0].duration_secs, 60); // fallback default
+    }
+
+    #[test]
+    fn tolerates_junk() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "not a config line");
+        apply_line(&mut cfg, "unknown_key = 42");
+        apply_line(&mut cfg, "vcore_limit = notanumber");
+        assert_eq!(cfg.vcore_limit, 1.403); // unchanged
+    }
+
+    #[test]
+    fn indexed_workload_overrides_list() {
+        // Simulate the load() path: indexed lines collected separately
+        let mut indexed: Vec<WorkloadSpec> = Vec::new();
+        assert!(apply_indexed(&mut indexed, "1.name = stress-ng-cpu"));
+        assert!(apply_indexed(&mut indexed, "1.duration_secs = 90"));
+        assert!(apply_indexed(&mut indexed, "1.cores = 0-11"));
+        assert!(apply_indexed(&mut indexed, "2.name = y-cruncher-avx2"));
+        assert!(!apply_indexed(&mut indexed, "2.unknown = x"));
+        assert_eq!(indexed.len(), 2);
+        assert_eq!(indexed[0].name, "stress-ng-cpu");
+        assert_eq!(indexed[0].duration_secs, 90);
+        assert_eq!(indexed[0].cores, "0-11");
+        assert_eq!(indexed[1].name, "y-cruncher-avx2");
+        assert_eq!(indexed[1].duration_secs, 60); // default, not overridden
+    }
+
+    #[test]
+    fn indexed_grows_list() {
+        let mut indexed: Vec<WorkloadSpec> = Vec::new();
+        apply_indexed(&mut indexed, "3.name = 7zip-bench");
+        assert_eq!(indexed.len(), 3);
+        // Gaps filled with placeholder specs (empty name)
+        assert_eq!(indexed[0].name, "");
+        assert_eq!(indexed[2].name, "7zip-bench");
+    }
+
+    #[test]
+    fn indexed_zero_rejected() {
+        let mut indexed: Vec<WorkloadSpec> = Vec::new();
+        assert!(!apply_indexed(&mut indexed, "0.name = bad"));
+        assert!(indexed.is_empty());
+    }
+}
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index 83ab4df..dece497 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,106 @@
+mod config;
+mod sensors;
+mod workload;
+
 fn main() {
-    println!("adlerbench — comparative benchmarks and stress tests for Alder Lake");
-    println!("scaffold; see to-do.md");
+    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());
+        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;
+        }
+    }
+    println!("adlerbench — {} workloads configured", cfg.workloads.len());
+    for w in &cfg.workloads {
+        println!("  {}  {}s  cores={}", w.name, w.duration_secs, w.cores);
+    }
+}
+
+fn smoke_workload(name: &str, cfg: &config::Config) {
+    use std::thread;
+    use std::time::Duration;
+    // Build a 3-second spec for the requested workload, regardless of the
+    // configured duration — this is a smoke test, not a real run.
+    let spec = config::WorkloadSpec {
+        name: name.to_string(),
+        duration_secs: 3,
+        cores: "0-15".to_string(),
+    };
+    let Some(mut w) = workload::from_spec(&spec, &cfg.bin_dir) else {
+        eprintln!("unknown or not-yet-wired workload: {name}");
+        return;
+    };
+    println!("smoke: {}  params={}", w.name(), w.params());
+    match w.start() {
+        Ok(()) => {
+            // Poll is_running every 250ms; stop after the spec's duration
+            // plus a 1s grace (some workloads self-exit right at the
+            // boundary; the grace avoids a Stopped verdict when the
+            // workload actually finished on its own).
+            let ticks = spec.duration_secs * 4 + 4;
+            let mut elapsed = 0u64;
+            while elapsed < ticks {
+                thread::sleep(Duration::from_millis(250));
+                elapsed += 1;
+                if !w.is_running() {
+                    println!("smoke: exited after {:.1}s", elapsed as f64 / 4.0);
+                    break;
+                }
+            }
+            if w.is_running() {
+                println!("smoke: still running — calling stop()");
+                let _ = w.stop();
+            }
+            let verdict = w.wait();
+            println!("smoke: verdict = {:?}", verdict);
+        }
+        Err(e) => println!("smoke: start failed: {e}"),
+    }
+}
+
+fn opt_volts(v: Option<f64>) -> String {
+    match v {
+        Some(x) => format!("{:.3} V", x),
+        None => "unreadable".to_string(),
+    }
+}
+fn opt_temp(v: Option<f64>) -> String {
+    match v {
+        Some(x) => format!("{:.1} C", x),
+        None => "unreadable".to_string(),
+    }
+}
+fn opt_watts(v: Option<f64>) -> String {
+    match v {
+        Some(x) => format!("{:.1} W", x),
+        None => "unreadable".to_string(),
+    }
+}
+fn opt_khz(v: Option<u64>) -> String {
+    match v {
+        Some(x) => format!("{:.3} GHz", x as f64 / 1_000_000.0),
+        None => "unreadable".to_string(),
+    }
+}
+fn opt_uj(v: Option<u64>) -> String {
+    match v {
+        Some(x) => format!("{} uJ", x),
+        None => "unreadable (root-only without adlermon udev rule + adm group)".to_string(),
+    }
 }
\ No newline at end of file
diff --git a/src/sensors.rs b/src/sensors.rs
new file mode 100644
index 0000000..d36529e
--- /dev/null
+++ b/src/sensors.rs
@@ -0,0 +1,337 @@
+//! Read-only access to Linux hardware monitoring sysfs: hwmon chips
+//! (voltages, temperatures), per-cpu frequency, and RAPL energy counters.
+//! This is the only module that touches the filesystem — front-ends (the
+//! run loop, the TUI) never read sysfs directly.
+//!
+//! Ported from adlermon/src/sensors.rs (verified 2026-08-29 on this box)
+//! with one addition: a `Snapshot` struct for atomic per-tick reads, and
+//! the Alder Lake topology map (P vs E cores) that adlermon keeps in
+//! ui.rs. Both belong here so run.rs and ui.rs can share them.
+
+#![allow(dead_code)] // consumed by run.rs + ui.rs + report.rs — not yet wired
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// A discovered hwmon chip, e.g. `/sys/class/hwmon/hwmon3` (nct6798).
+pub struct Chip {
+    pub path: PathBuf,
+    pub name: String,
+}
+
+pub fn discover_chips() -> Vec<Chip> {
+    let mut chips = Vec::new();
+    if let Ok(entries) = fs::read_dir("/sys/class/hwmon") {
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if let Some(name) = read_trimmed(&path.join("name")) {
+                chips.push(Chip { path, name });
+            }
+        }
+    }
+    chips.sort_by_key(|chip| hwmon_number(&chip.path));
+    chips
+}
+
+fn hwmon_number(path: &Path) -> u64 {
+    path.file_name()
+        .and_then(|n| n.to_str())
+        .and_then(|n| n.rsplit_once("hwmon"))
+        .and_then(|(_, digits)| digits.parse().ok())
+        .unwrap_or(u64::MAX)
+}
+
+fn read_trimmed(path: &Path) -> Option<String> {
+    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
+}
+
+fn read_int(path: &Path) -> Option<i64> {
+    read_trimmed(path)?.parse().ok()
+}
+
+/// All labeled numeric inputs matching `<prefix>N_input`, scaled by `scale`.
+/// Prefers the kernel-provided `<prefix>N_label` when one exists, so e.g.
+/// coretemp's "Core 0" / "Package id 0" names survive.
+fn numbered_inputs(dir: &Path, prefix: &str, scale: f64) -> Vec<(String, f64)> {
+    let mut rows: Vec<(u64, String, f64)> = Vec::new();
+    if let Ok(entries) = fs::read_dir(dir) {
+        for entry in entries.flatten() {
+            let file = entry.file_name();
+            let file = match file.to_str() {
+                Some(f) => f,
+                None => continue,
+            };
+            let stem = match file.strip_suffix("_input") {
+                Some(s) => s,
+                None => continue,
+            };
+            let digits = match stem.strip_prefix(prefix) {
+                Some(d) => d,
+                None => continue,
+            };
+            let index = match digits.parse::<u64>() {
+                Ok(i) => i,
+                Err(_) => continue,
+            };
+            let raw = match read_int(&entry.path()) {
+                Some(v) => v,
+                None => continue,
+            };
+            let label = read_trimmed(&dir.join(format!("{stem}_label")))
+                .unwrap_or_else(|| stem.to_string());
+            rows.push((index, label, raw as f64 * scale));
+        }
+    }
+    rows.sort_by_key(|row| row.0);
+    rows.into_iter()
+        .map(|(_, label, value)| (label, value))
+        .collect()
+}
+
+/// Voltage inputs in volts (`in*_input` is millivolts).
+pub fn voltages(chip: &Chip) -> Vec<(String, f64)> {
+    numbered_inputs(&chip.path, "in", 1.0 / 1000.0)
+}
+
+/// Temperature inputs in °C (`temp*_input` is millidegrees Celsius).
+pub fn temperatures(chip: &Chip) -> Vec<(String, f64)> {
+    numbered_inputs(&chip.path, "temp", 1.0 / 1000.0)
+}
+
+/// Per-cpu current frequency in kHz from cpufreq sysfs (no root required).
+pub fn cpu_frequencies() -> Vec<(usize, u64)> {
+    let mut out = Vec::new();
+    if let Ok(entries) = fs::read_dir("/sys/devices/system/cpu") {
+        for entry in entries.flatten() {
+            let name = entry.file_name();
+            let name = match name.to_str() {
+                Some(n) => n,
+                None => continue,
+            };
+            let cpu = match name
+                .strip_prefix("cpu")
+                .and_then(|d| d.parse::<usize>().ok())
+            {
+                Some(c) => c,
+                None => continue,
+            };
+            if let Some(khz) = read_int(&entry.path().join("cpufreq/scaling_cur_freq")) {
+                out.push((cpu, khz as u64));
+            }
+        }
+    }
+    out.sort();
+    out
+}
+
+/// Max frequency (kHz) advertised for a logical cpu (its turbo ceiling).
+pub fn cpu_max_freq(cpu: usize) -> Option<u64> {
+    read_int(
+        &Path::new("/sys/devices/system/cpu")
+            .join(format!("cpu{cpu}"))
+            .join("cpufreq/cpuinfo_max_freq"),
+    )
+    .map(|v| v as u64)
+}
+
+/// A RAPL power domain with an `energy_uj` counter, e.g. package or core.
+pub struct RaplDomain {
+    pub id: String,   // sysfs dir name, e.g. "intel-rapl:0"
+    pub name: String, // kernel label, e.g. "package-0"
+}
+
+pub fn rapl_domains() -> Vec<RaplDomain> {
+    let mut out = Vec::new();
+    if let Ok(entries) = fs::read_dir("/sys/class/powercap") {
+        for entry in entries.flatten() {
+            let id = match entry.file_name().to_str() {
+                Some(i) => i.to_string(),
+                None => continue,
+            };
+            if !id.starts_with("intel-rapl") {
+                continue;
+            }
+            let path = entry.path();
+            // energy_uj exists on all domains but is 0400 root-only on many
+            // kernels — report the domain regardless and let reads fail loudly.
+            if !path.join("energy_uj").exists() {
+                continue;
+            }
+            let name = read_trimmed(&path.join("name")).unwrap_or_else(|| id.clone());
+            out.push(RaplDomain { id, name });
+        }
+    }
+    out.sort_by(|a, b| a.id.cmp(&b.id));
+    out
+}
+
+/// Cumulative energy in microjoules; deltas over time give watts. Returns
+/// None if unreadable (root-only on this kernel without the adm group via
+/// adlermon's udev rule).
+pub fn rapl_energy_uj(domain: &str) -> Option<u64> {
+    read_int(
+        &Path::new("/sys/class/powercap")
+            .join(domain)
+            .join("energy_uj"),
+    )
+    .map(|e| e as u64)
+}
+
+/// Sampled vCore from the nct6798 SIO (hwmon in0). Find the chip by NAME,
+/// never by index — hwmon numbering shifts between boots.
+pub fn sio_vcore() -> Option<f64> {
+    sio_input("in0")
+}
+
+/// Any SIO voltage input by sysfs stem ("in0", "in12", …).
+pub fn sio_input(input: &str) -> Option<f64> {
+    let chip = discover_chips().into_iter().find(|c| c.name.starts_with("nct"))?;
+    voltages(&chip)
+        .into_iter()
+        .find(|(label, _)| label == input)
+        .map(|(_, v)| v)
+}
+
+/// Package temperature from coretemp (the "Package id 0" label).
+pub fn package_temp() -> Option<f64> {
+    let chip = discover_chips().into_iter().find(|c| c.name == "coretemp")?;
+    temperatures(&chip)
+        .into_iter()
+        .find(|(label, _)| label.starts_with("Package"))
+        .map(|(_, v)| v)
+}
+
+/// Alder Lake i5-12600KF topology (verified 2026-08-29, mirrored from
+/// adlermon/project-memory.md). P-cores have HT (2 logical each); E-cores
+/// don't. Logical cpu 0-11 = P-cores, 12-15 = E-cores.
+pub fn is_e_core(cpu: usize) -> bool {
+    cpu >= 12
+}
+
+/// All logical cpus that are P-cores.
+pub fn p_cpus() -> Vec<usize> {
+    (0..12).collect()
+}
+
+/// All logical cpus that are E-cores.
+pub fn e_cpus() -> Vec<usize> {
+    (12..16).collect()
+}
+
+/// One atomic sensor reading at a point in time. The run loop produces one
+/// per tick; the report writer serializes them; the TUI renders the latest.
+/// Fields are Option<> because any single sensor can be unreadable (root-
+/// gated, missing chip, etc.) — absent sensor ≠ error, same as adlermon.
+#[derive(Clone, Debug, Default)]
+pub struct Snapshot {
+    /// Seconds since run start (set by the run loop, not sensors.rs).
+    pub t: f64,
+    pub vcore: Option<f64>,
+    pub pkg_temp: Option<f64>,
+    /// Package power, watts. Computed by the run loop from RAPL energy
+    /// deltas between ticks (sensors.rs exposes the raw counter; the loop
+    /// owns the delta math so it can handle counter wraps + first-tick).
+    pub pkg_power: Option<f64>,
+    /// Max frequency across all logical cpus at this tick, kHz. The "peak
+    /// clock" — the per-ISA offset measurement, the whole point of the
+    /// sweep. Run loop also tracks the session peak across ticks.
+    pub peak_clock_khz: Option<u64>,
+    /// Per-logical-cpu frequencies (for the TUI's core bars, eventually).
+    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>,
+}
+
+/// Read a fresh Snapshot. `prev_energy_uj` is the previous RAPL reading
+/// for power delta computation — pass None on the first tick. `dt_secs`
+/// is the elapsed time since the previous tick (for the watts calculation).
+pub fn snapshot(prev_energy_uj: Option<u64>, dt_secs: f64) -> Snapshot {
+    let vcore = sio_vcore();
+    let pkg_temp = package_temp();
+    let core_freqs = cpu_frequencies();
+    let peak_clock_khz = core_freqs.iter().map(|(_, khz)| *khz).max();
+
+    // RAPL: find the package domain (kernel name starts with "package"),
+    // read its energy counter, and compute watts from the delta. Same fix
+    // as adlermon 2b920f41 — the kernel label is "package-0" but we match
+    // on starts_with("package") so the watts path actually runs.
+    let mut pkg_power = None;
+    let mut energy_now: Option<u64> = None;
+    if let Some(dom) = rapl_domains()
+        .into_iter()
+        .find(|d| d.name.starts_with("package"))
+    {
+        if let Some(e_now) = rapl_energy_uj(&dom.id) {
+            if let Some(e_prev) = prev_energy_uj {
+                if dt_secs > 0.0 {
+                    let delta = e_now.saturating_sub(e_prev);
+                    pkg_power = Some(delta as f64 / 1_000_000.0 / dt_secs);
+                }
+            }
+            energy_now = Some(e_now);
+        }
+    }
+
+    Snapshot {
+        t: 0.0,
+        vcore,
+        pkg_temp,
+        pkg_power,
+        peak_clock_khz,
+        core_freqs,
+        energy_uj: None,
+    }
+    .with_energy(energy_now)
+}
+
+impl Snapshot {
+    /// Attach the raw RAPL energy counter so the run loop can feed it back
+    /// as `prev_energy_uj` on the next tick. Stored on the Snapshot so it
+    /// travels with the sample without polluting the report schema (the
+    /// report writer skips this field).
+    pub fn with_energy(mut self, energy_uj: Option<u64>) -> Self {
+        self.energy_uj = energy_uj;
+        self
+    }
+    /// The raw RAPL counter from this tick (for delta math next tick).
+    pub fn energy_uj(&self) -> Option<u64> {
+        self.energy_uj
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn e_core_classification() {
+        assert!(!is_e_core(0));
+        assert!(!is_e_core(11));
+        assert!(is_e_core(12));
+        assert!(is_e_core(15));
+    }
+
+    #[test]
+    fn p_and_e_cpu_lists() {
+        assert_eq!(p_cpus(), vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
+        assert_eq!(e_cpus(), vec![12, 13, 14, 15]);
+    }
+
+    #[test]
+    fn snapshot_first_tick_has_no_power() {
+        // First tick (prev_energy_uj = None) can't compute watts yet.
+        let s = snapshot(None, 0.25);
+        // pkg_power should be None on the first tick regardless of RAPL
+        // access — there's no previous reading to delta against.
+        assert_eq!(s.pkg_power, None);
+    }
+
+    #[test]
+    fn snapshot_energy_round_trips() {
+        let s = snapshot(None, 0.25);
+        // If RAPL is readable, energy_uj() matches what was stored; if not,
+        // both are None. Either way the accessor round-trips.
+        assert_eq!(s.energy_uj(), s.energy_uj);
+    }
+}
\ No newline at end of file
diff --git a/src/workload.rs b/src/workload.rs
new file mode 100644
index 0000000..2b9f885
--- /dev/null
+++ b/src/workload.rs
@@ -0,0 +1,600 @@
+//! Workload abstraction for the ISA sweep. A Workload is something the run
+//! loop can start, let run for a duration while sensors sample around it,
+//! stop, and read a stability verdict from.
+//!
+//! Two shapes live behind this trait:
+//!  - **Spawned** workloads (stress-ng, y-cruncher, 7zip) own a child
+//!    process. `start()` spawns, `stop()` signals + waits, the verdict
+//!    comes from the exit code + parsed stdout.
+//!  - **In-process** workloads (c2c-latency, dram-latency, deferred) run
+//!    in a thread. `start()` spawns the thread, `stop()` sets a flag,
+//!    the verdict comes from the measurement itself.
+//!
+//! The trait abstracts over both so the run loop treats every workload
+//! the same. v1 ships the spawned impls; in-process impls come next.
+
+#![allow(dead_code)] // consumed by run.rs + ui.rs — not yet wired
+
+use std::io::{self, Read};
+use std::path::PathBuf;
+use std::process::{Child, Command, ExitStatus};
+
+/// What a workload reports when it's done. The stability verdict is the
+/// product — `Clean` means the workload's own self-check passed (y-cruncher
+/// "Passed", 7z exit 0, stress-ng exit 0); anything else is a failure mode
+/// the OC stability question cares about.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Verdict {
+    /// Workload self-check passed. The chip was stable for this run.
+    Clean,
+    /// Workload self-check failed (y-cruncher "failed", 7z nonzero exit,
+    /// stress-ng nonzero exit). Instability at this ISA rail.
+    Failed(String),
+    /// Workload was stopped early (user quit, timeout, sweep cancelled).
+    /// Not a stability signal — the run didn't complete.
+    Stopped,
+    /// Workload couldn't run (binary missing, spawn error, etc.). Not a
+    /// stability signal — infrastructure failure.
+    Error(String),
+}
+
+/// The common surface the run loop drives. See the module doc for the two
+/// shapes behind this trait.
+pub trait Workload {
+    /// Human-readable name (e.g. "y-cruncher-avx2"). Used in the TUI and
+    /// the report.
+    fn name(&self) -> &str;
+    /// One-line params string (e.g. "60s cores=0-15 FFTv4"). For the report.
+    fn params(&self) -> String;
+    /// Start the workload. Called once per run.
+    fn start(&mut self) -> io::Result<()>;
+    /// True between `start()` and `stop()`. The run loop polls this each
+    /// tick to decide whether to keep sampling or move to cooldown.
+    /// `&mut self` because `try_wait` mutates the Child's internal state.
+    fn is_running(&mut self) -> bool;
+    /// Stop the workload. May be called before the duration elapses (user
+    /// quit, sweep cancelled). Must be safe to call on an already-stopped
+    /// workload.
+    fn stop(&mut self) -> io::Result<()>;
+    /// Block until the workload finishes (or was already stopped). Returns
+    /// the verdict. Called once, after the run loop decides the workload is
+    /// done (either `is_running()` went false or the run loop's own
+    /// duration timer expired and it called `stop()`).
+    fn wait(&mut self) -> Verdict;
+}
+
+// ---------------------------------------------------------------------------
+// Spawned-workload helper
+// ---------------------------------------------------------------------------
+
+/// Common machinery for workloads that spawn a child process. Owns the
+/// Child, captures stdout (for verdict parsing), and provides a generic
+/// `stop()` that tries SIGTERM then SIGKILL. Subtypes build the Command in
+/// their `start()` and parse stdout in their `wait()`.
+struct SpawnedWorkload {
+    child: Option<Child>,
+    stdout: Vec<u8>,
+    /// Set when `stop()` was called by the run loop (vs. the process
+    /// exiting on its own). Distinguishes `Stopped` from `Clean`/`Failed`.
+    stopped: bool,
+    /// Captured stderr (kept for diagnostics; not parsed for verdict).
+    stderr: Vec<u8>,
+}
+
+impl SpawnedWorkload {
+    fn new() -> Self {
+        SpawnedWorkload {
+            child: None,
+            stdout: Vec::new(),
+            stderr: Vec::new(),
+            stopped: false,
+        }
+    }
+
+    fn spawn(cmd: &mut Command) -> io::Result<Self> {
+        let child = cmd
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .spawn()?;
+        Ok(SpawnedWorkload {
+            child: Some(child),
+            stdout: Vec::new(),
+            stderr: Vec::new(),
+            stopped: false,
+        })
+    }
+
+    fn is_running(&mut self) -> bool {
+        match &mut self.child {
+            Some(child) => child.try_wait().ok().flatten().is_none(),
+            None => false,
+        }
+    }
+
+    fn stop(&mut self) -> io::Result<()> {
+        if let Some(child) = &mut self.child {
+            self.stopped = true;
+            // Try a polite SIGTERM first; the run loop will call wait()
+            // next which reaps. If TERM doesn't take in a couple seconds,
+            // escalate to SIGKILL — but we don't block here (the run loop
+            // owns timing). For now just SIGTERM and let wait() handle the
+            // rest. On Unix, `start_kill` sends SIGKILL; for SIGTERM we
+            // use the raw pid + nix-less libc. Keep it simple: SIGKILL
+            // directly. Stress workloads don't have cleanup that warrants
+            // a graceful TERM, and a stuck child would hang the sweep.
+            let _ = child.kill();
+        }
+        Ok(())
+    }
+
+    /// Drain stdout/stderr from the child into our buffers. Called by
+    /// subtypes' `wait()` before parsing the verdict. Reads as much as is
+    /// available without blocking.
+    fn drain(&mut self) -> io::Result<()> {
+        let Some(child) = &mut self.child else {
+            return Ok(());
+        };
+        if let Some(stdout) = &mut child.stdout {
+            let mut buf = [0u8; 4096];
+            loop {
+                let n = stdout.read(&mut buf)?;
+                if n == 0 {
+                    break;
+                }
+                self.stdout.extend_from_slice(&buf[..n]);
+            }
+        }
+        if let Some(stderr) = &mut child.stderr {
+            let mut buf = [0u8; 4096];
+            loop {
+                let n = stderr.read(&mut buf)?;
+                if n == 0 {
+                    break;
+                }
+                self.stderr.extend_from_slice(&buf[..n]);
+            }
+        }
+        Ok(())
+    }
+
+    /// Block until the child exits, drain stdout/stderr, and return the
+    /// raw ExitStatus. Called by subtypes' `wait()`.
+    fn wait_raw(&mut self) -> io::Result<Option<ExitStatus>> {
+        let Some(child) = &mut self.child else {
+            return Ok(None);
+        };
+        let status = child.wait()?;
+        // After wait() returns, the pipes are at EOF — drain anything we
+        // missed while we weren't polling.
+        self.drain()?;
+        Ok(Some(status))
+    }
+
+    fn _take_stdout(&mut self) -> String {
+        String::from_utf8_lossy(&self.stdout).to_string()
+    }
+}
+
+// ---------------------------------------------------------------------------
+// stress-ng --cpu (integer rail)
+// ---------------------------------------------------------------------------
+
+/// Integer rail. `stress-ng --cpu N --taskset <range> --timeout T --cpu-method all`.
+/// Stress-ng exits 0 on success, nonzero on failure (including bogo-op
+/// self-check mismatches).
+pub struct StressNg {
+    duration_secs: u64,
+    cores: String,
+    inner: SpawnedWorkload,
+}
+
+impl StressNg {
+    pub fn new(duration_secs: u64, cores: String) -> Self {
+        StressNg {
+            duration_secs,
+            cores,
+            inner: SpawnedWorkload::new(),
+        }
+    }
+}
+
+impl Workload for StressNg {
+    fn name(&self) -> &str {
+        "stress-ng-cpu"
+    }
+    fn params(&self) -> String {
+        format!("{}s cores={} --cpu --cpu-method all", self.duration_secs, self.cores)
+    }
+    fn start(&mut self) -> io::Result<()> {
+        let mut cmd = Command::new("stress-ng");
+        cmd.arg("--cpu")
+            .arg("15") // all logical cpus; --taskset below pins to the range
+            .arg("--taskset")
+            .arg(&self.cores)
+            .arg("--timeout")
+            .arg(format!("{}s", self.duration_secs))
+            .arg("--cpu-method")
+            .arg("all")
+            .arg("--metrics-brief");
+        self.inner = SpawnedWorkload::spawn(&mut cmd)?;
+        Ok(())
+    }
+    fn is_running(&mut self) -> bool {
+        self.inner.is_running()
+    }
+    fn stop(&mut self) -> io::Result<()> {
+        self.inner.stop()
+    }
+    fn wait(&mut self) -> Verdict {
+        let stopped = self.inner.stopped;
+        match self.inner.wait_raw() {
+            Ok(Some(status)) => {
+                if stopped {
+                    return Verdict::Stopped;
+                }
+                if status.success() {
+                    Verdict::Clean
+                } else {
+                    Verdict::Failed(format!("stress-ng exit {}", status))
+                }
+            }
+            Ok(None) => Verdict::Error("no child".to_string()),
+            Err(e) => Verdict::Error(format!("wait: {e}")),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// y-cruncher (SSE or AVX2 rail, via per-microarch variant binary)
+// ---------------------------------------------------------------------------
+
+/// SSE or AVX2 rail. y-cruncher has NO CLI flag for ISA selection — we
+/// force the ISA by invoking the per-microarch VARIANT BINARY directly
+/// (NOT the wrapper, which auto-selects). Verified 2026-09-01:
+///   SSE  = Binaries/11-SNB ~ Hina  (Sandy Bridge, SSE4.2 only)
+///   AVX2 = Binaries/13-HSW ~ Airi  (Haswell, AVX2)
+/// Both run standalone, report "Passed"/"failed" per FFT round, "Stop on
+/// Error: Enabled" by default. CLI: `<variant> skip-warnings stress -D:s
+/// -TL:s [algorithm]`.
+pub struct YCruncher {
+    duration_secs: u64,
+    cores: String,
+    variant: &'static str, // "11-SNB ~ Hina" or "13-HSW ~ Airi"
+    label: &'static str,   // "sse" or "avx2"
+    bin_dir: PathBuf,
+    inner: SpawnedWorkload,
+}
+
+impl YCruncher {
+    pub fn sse(duration_secs: u64, cores: String, bin_dir: PathBuf) -> Self {
+        YCruncher {
+            duration_secs,
+            cores,
+            variant: "11-SNB ~ Hina",
+            label: "sse",
+            bin_dir,
+            inner: SpawnedWorkload::new(),
+        }
+    }
+    pub fn avx2(duration_secs: u64, cores: String, bin_dir: PathBuf) -> Self {
+        YCruncher {
+            duration_secs,
+            cores,
+            variant: "13-HSW ~ Airi",
+            label: "avx2",
+            bin_dir,
+            inner: SpawnedWorkload::new(),
+        }
+    }
+
+    /// Resolve the variant binary path. y-cruncher ships as a versioned
+    /// subdir under bin_dir (e.g. `bin/y-cruncher v0.8.7.9547-static/`),
+    /// so we glob for the first match rather than hardcode the version.
+    fn variant_path(&self) -> Option<PathBuf> {
+        let entries = std::fs::read_dir(&self.bin_dir).ok()?;
+        for entry in entries.flatten() {
+            let name = entry.file_name();
+            let name = name.to_str()?;
+            if name.starts_with("y-cruncher") {
+                let variant = entry.path().join("Binaries").join(self.variant);
+                if variant.exists() {
+                    return Some(variant);
+                }
+            }
+        }
+        None
+    }
+}
+
+impl Workload for YCruncher {
+    fn name(&self) -> &str {
+        match self.label {
+            "sse" => "y-cruncher-sse",
+            "avx2" => "y-cruncher-avx2",
+            _ => "y-cruncher",
+        }
+    }
+    fn params(&self) -> String {
+        format!(
+            "{}s cores={} variant={} stress FFTv4",
+            self.duration_secs, self.cores, self.variant
+        )
+    }
+    fn start(&mut self) -> io::Result<()> {
+        let variant_path = self.variant_path().ok_or_else(|| {
+            io::Error::new(
+                io::ErrorKind::NotFound,
+                format!(
+                    "y-cruncher variant '{}' not found under bin_dir {:?} \
+                     (expected bin/y-cruncher v*/Binaries/{})",
+                    self.variant, self.bin_dir, self.variant,
+                ),
+            )
+        })?;
+        // Canonicalize before setting current_dir — when current_dir is
+        // set, the kernel resolves the executable path RELATIVE TO THE NEW
+        // cwd, so a relative variant path would be looked up inside the
+        // y-cruncher bundle dir and not found. Absolute path survives the
+        // cwd change.
+        let variant_path = variant_path.canonicalize().map_err(|e| {
+            io::Error::new(
+                io::ErrorKind::NotFound,
+                format!("y-cruncher variant path canonicalize failed: {e}"),
+            )
+        })?;
+        let mut cmd = Command::new(&variant_path);
+        cmd.arg("skip-warnings")
+            .arg("stress")
+            // y-cruncher expects `-D:3` as ONE arg (not `-D:` + `3`).
+            .arg(format!("-D:{}", self.duration_secs))
+            .arg(format!("-TL:{}", self.duration_secs))
+            .arg("FFTv4");
+        // y-cruncher reads its Libraries.txt etc. from CWD — run from the
+        // variant's parent directory (the static bundle dir).
+        if let Some(dir) = variant_path.parent().and_then(|p| p.parent()) {
+            cmd.current_dir(dir);
+        }
+        self.inner = SpawnedWorkload::spawn(&mut cmd)?;
+        Ok(())
+    }
+    fn is_running(&mut self) -> bool {
+        self.inner.is_running()
+    }
+    fn stop(&mut self) -> io::Result<()> {
+        self.inner.stop()
+    }
+    fn wait(&mut self) -> Verdict {
+        let stopped = self.inner.stopped;
+        match self.inner.wait_raw() {
+            Ok(Some(status)) => {
+                if stopped {
+                    return Verdict::Stopped;
+                }
+                let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
+                // y-cruncher prints "Running FFTv4: Passed" per round and
+                // "failed" on mismatch. The definitive signal is the exit
+                // code (nonzero on self-check failure since "Stop on Error"
+                // is enabled by default), but we also scan stdout for the
+                // explicit "failed" string in case of partial output.
+                let failed_text = stdout
+                    .lines()
+                    .any(|l| l.contains("failed") && !l.contains("Running"));
+                if status.success() && !failed_text {
+                    Verdict::Clean
+                } else if failed_text {
+                    Verdict::Failed(format!("y-cruncher self-check failed (variant={})", self.variant))
+                } else {
+                    Verdict::Failed(format!("y-cruncher exit {} (variant={})", status, self.variant))
+                }
+            }
+            Ok(None) => Verdict::Error("no child".to_string()),
+            Err(e) => Verdict::Error(format!("wait: {e}")),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 7-Zip benchmark (the z-7ip bench — NOT y-cruncher)
+// ---------------------------------------------------------------------------
+
+/// z-7ip bench. `7z b` runs 7-Zip's built-in compression/decompression
+/// benchmark; CPU + memory bandwidth throughput. Reports MIPS + total
+/// score (the comparative-perf metric across configs). Exits 0 on success.
+/// `7z b -mmt<N>` sets thread count; we pass cores count translated from
+/// the range string (0-15 → 16 threads). Defaults to all logical cpus if
+/// the range is "0-15".
+pub struct SevenZip {
+    duration_secs: u64,
+    cores: String,
+    inner: SpawnedWorkload,
+}
+
+impl SevenZip {
+    pub fn new(duration_secs: u64, cores: String) -> Self {
+        SevenZip {
+            duration_secs,
+            cores,
+            inner: SpawnedWorkload::new(),
+        }
+    }
+
+    /// Translate a cores range ("0-15", "0-11", "12-15") into a thread
+    /// count for `7z b -mmt<N>`. Returns None if the range is malformed
+    /// — 7z's default (all cpus) is fine in that case.
+    fn thread_count(&self) -> Option<u32> {
+        let s = self.cores.trim();
+        if let Some((a, b)) = s.split_once('-') {
+            let a: u32 = a.trim().parse().ok()?;
+            let b: u32 = b.trim().parse().ok()?;
+            if b >= a {
+                return Some(b - a + 1);
+            }
+        }
+        None
+    }
+}
+
+impl Workload for SevenZip {
+    fn name(&self) -> &str {
+        "7zip-bench"
+    }
+    fn params(&self) -> String {
+        format!(
+            "{}s cores={} (7z b{})",
+            self.duration_secs,
+            self.cores,
+            match self.thread_count() {
+                Some(n) => format!(" -mmt{n}"),
+                None => String::new(),
+            }
+        )
+    }
+    fn start(&mut self) -> io::Result<()> {
+        let mut cmd = Command::new("7z");
+        cmd.arg("b");
+        if let Some(n) = self.thread_count() {
+            cmd.arg(format!("-mmt{n}"));
+        }
+        // 7z b runs a fixed number of passes by default, not time-bound.
+        // For a sweep we want a comparable run length across configs —
+        // pass `-mmt<N>` for thread control and let the duration_secs be
+        // a soft target (7z finishes when it finishes; the run loop will
+        // stop() if it overruns). Document this in the report.
+        self.inner = SpawnedWorkload::spawn(&mut cmd)?;
+        Ok(())
+    }
+    fn is_running(&mut self) -> bool {
+        self.inner.is_running()
+    }
+    fn stop(&mut self) -> io::Result<()> {
+        self.inner.stop()
+    }
+    fn wait(&mut self) -> Verdict {
+        let stopped = self.inner.stopped;
+        match self.inner.wait_raw() {
+            Ok(Some(status)) => {
+                if stopped {
+                    return Verdict::Stopped;
+                }
+                if status.success() {
+                    Verdict::Clean
+                } else {
+                    Verdict::Failed(format!("7z exit {}", status))
+                }
+            }
+            Ok(None) => Verdict::Error("no child".to_string()),
+            Err(e) => Verdict::Error(format!("wait: {e}")),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Public constructor: map a WorkloadSpec name to an impl
+// ---------------------------------------------------------------------------
+
+/// Build a Workload from a config spec. Returns None for unknown names
+/// (the run loop skips them with a warning) or names that aren't wired
+/// yet (c2c-latency, dram-latency, minecraft-server — deferred).
+pub fn from_spec(
+    spec: &crate::config::WorkloadSpec,
+    bin_dir: &std::path::Path,
+) -> Option<Box<dyn Workload>> {
+    match spec.name.as_str() {
+        "stress-ng-cpu" => Some(Box::new(StressNg::new(spec.duration_secs, spec.cores.clone()))),
+        "y-cruncher-sse" => {
+            Some(Box::new(YCruncher::sse(spec.duration_secs, spec.cores.clone(), bin_dir.to_path_buf())))
+        }
+        "y-cruncher-avx2" => Some(Box::new(YCruncher::avx2(
+            spec.duration_secs,
+            spec.cores.clone(),
+            bin_dir.to_path_buf(),
+        ))),
+        "7zip-bench" => Some(Box::new(SevenZip::new(spec.duration_secs, spec.cores.clone()))),
+        // Deferred — hand-rolled in-process workloads, next chunk.
+        "c2c-latency" | "dram-latency" | "minecraft-server" => None,
+        _ => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn stressng_params_include_range() {
+        let w = StressNg::new(60, "0-11".to_string());
+        assert!(w.params().contains("0-11"));
+        assert!(w.params().contains("60s"));
+    }
+
+    #[test]
+    fn ycruncher_variant_paths_resolve() {
+        let bin_dir = PathBuf::from("bin");
+        let sse = YCruncher::sse(60, "0-15".to_string(), bin_dir.clone());
+        let avx2 = YCruncher::avx2(60, "0-15".to_string(), bin_dir);
+        assert_eq!(sse.variant, "11-SNB ~ Hina");
+        assert_eq!(avx2.variant, "13-HSW ~ Airi");
+        // variant_path() should find the actual bundle if present in ./bin
+        // (smoke-tested below; skip assertion here so tests pass without
+        // the binary).
+        let _ = sse.variant_path();
+        let _ = avx2.variant_path();
+    }
+
+    #[test]
+    fn ycruncher_names_distinguish_rails() {
+        let sse = YCruncher::sse(60, "0-15".to_string(), PathBuf::from("bin"));
+        let avx2 = YCruncher::avx2(60, "0-15".to_string(), PathBuf::from("bin"));
+        assert_eq!(sse.name(), "y-cruncher-sse");
+        assert_eq!(avx2.name(), "y-cruncher-avx2");
+    }
+
+    #[test]
+    fn sevenzip_thread_count_parses_ranges() {
+        let w = SevenZip::new(60, "0-15".to_string());
+        assert_eq!(w.thread_count(), Some(16));
+        let w = SevenZip::new(60, "0-11".to_string());
+        assert_eq!(w.thread_count(), Some(12));
+        let w = SevenZip::new(60, "12-15".to_string());
+        assert_eq!(w.thread_count(), Some(4));
+        let w = SevenZip::new(60, "junk".to_string());
+        assert_eq!(w.thread_count(), None);
+    }
+
+    #[test]
+    fn from_spec_wires_known_names() {
+        let spec = crate::config::WorkloadSpec {
+            name: "stress-ng-cpu".to_string(),
+            duration_secs: 30,
+            cores: "0-15".to_string(),
+        };
+        let w = from_spec(&spec, std::path::Path::new("bin"));
+        assert!(w.is_some());
+        assert_eq!(w.unwrap().name(), "stress-ng-cpu");
+    }
+
+    #[test]
+    fn from_spec_rejects_unknown_names() {
+        let spec = crate::config::WorkloadSpec {
+            name: "no-such-workload".to_string(),
+            duration_secs: 30,
+            cores: "0-15".to_string(),
+        };
+        assert!(from_spec(&spec, std::path::Path::new("bin")).is_none());
+    }
+
+    #[test]
+    fn from_spec_rejects_deferred_workloads() {
+        // c2c-latency / dram-latency / minecraft-server are in scope but
+        // not yet implemented — from_spec returns None so the run loop
+        // can skip them with a warning rather than panic.
+        for name in ["c2c-latency", "dram-latency", "minecraft-server"] {
+            let spec = crate::config::WorkloadSpec {
+                name: name.to_string(),
+                duration_secs: 30,
+                cores: "0-15".to_string(),
+            };
+            assert!(from_spec(&spec, std::path::Path::new("bin")).is_none(), "{name} should be None");
+        }
+    }
+}
\ No newline at end of file