josie / alder-tools

Reframe scope: record scores, not sensors (adlermon owns sensors)

- Deleted src/sensors.rs + --dump mode; adlerbench no longer reads sysfs.
  adlermon watches clocks/temps/vCore concurrently; adlerbench drives
  benchmarks and records scores.
- config.rs: dropped vcore_limit + temp_crit (no sensor thresholds to
  flag when there are no sensor readings).
- workload.rs: added Score enum (None / SevenZip{rating_mips, r_u_mips,
  usage_pct, compress_mips, decompress_mips} / YCruncher{tests:
  Vec<YCruncherTest{tag, passed, bits_per_sec}>}) + score() method on the
  Workload trait. StressNg returns Score::None (stability only, per user
  direction). Added parse_7zip_score (Avr+Tot footer) + parse_ycruncher
  scores (per-algorithm Test Speed lines) + strip_ansi helper that
  CONVERTS \r to \n (y-cruncher uses bare \r for live-console line
  overwrites; dropping collapses the progress block into one line).
- report.rs: schema v2. Dropped peak_clock_khz, peak_vcore, peak_pkg_temp,
  peak_pkg_power, samples[] (the whole per-tick trace). Added per-kind
  score field (JSON object, null for stress-ng). One flush at finish()
  (no incremental per-tick rewrite — no samples to accumulate).
- run.rs: dropped per-tick sensors::snapshot() + prev_energy_uj +
  last_sample + SampleTaken TickOutcome variant. finish_current_workload
  calls w.score() alongside w.wait() and feeds both to report.finish().
- main.rs: --headless + --smoke-workload print the score via format_score.
- test-fixtures/: real captured 7z b + y-cruncher FFTv4 stdout committed;
  parser tests include_str! them so the formats are pinned.
- 43/43 tests pass; clippy clean; live --headless verified (7z Clean
  with score=65620 MIPS, all sub-fields populated).

369b7d34f6965094acb76fd9c404f85255db7e67
josie <administrator@josie-c.com> · 2026-09-01T21:24 · browse files at this commit

parents: 9ceada7

diff --git a/adlerbench.conf b/adlerbench.conf
index 2ebee21..4a07d04 100644
--- a/adlerbench.conf
+++ b/adlerbench.conf
@@ -6,10 +6,6 @@
 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
diff --git a/src/config.rs b/src/config.rs
index c660335..8cdfa5b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -28,13 +28,8 @@ pub struct Config {
     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).
+    /// Cooldown between workloads, seconds (idle 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.
@@ -49,8 +44,6 @@ impl Default for 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,
@@ -99,8 +92,6 @@ fn apply_line(cfg: &mut Config, line: &str) {
     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),
@@ -223,12 +214,8 @@ mod tests {
         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]
@@ -264,8 +251,8 @@ mod tests {
         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
+        apply_line(&mut cfg, "poll_ms = notanumber");
+        assert_eq!(cfg.poll_ms, 250); // unchanged
     }
 
     #[test]
diff --git a/src/main.rs b/src/main.rs
index c614516..843aac6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,16 +1,11 @@
 mod config;
 mod report;
 mod run;
-mod sensors;
 mod workload;
 
 fn main() {
     let cfg = config::load();
     let args: Vec<String> = std::env::args().collect();
-    if args.iter().any(|a| a == "--dump") {
-        dump_sensors();
-        return;
-    }
     if let Some(idx) = args.iter().position(|a| a == "--smoke-workload") {
         if let Some(name) = args.get(idx + 1) {
             smoke_workload(name, &cfg);
@@ -27,18 +22,8 @@ fn main() {
     }
 }
 
-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
+/// per workload (verdict + score) 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;
@@ -50,40 +35,22 @@ fn headless_run(cfg: config::Config) {
         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_ms = config::load().poll_ms;
     let poll = std::time::Duration::from_millis(poll_ms);
-    let mut summaries: Vec<String> = Vec::new();
+    let mut count = 0usize;
     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!();
+            Ok(run::TickOutcome::WorkloadFinished { idx, name, verdict, score, report_path }) => {
                 println!("[{idx}] finished {name}: {:?}", verdict);
+                println!("  score:  {}", format_score(&score));
                 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);
+                count += 1;
             }
             Ok(run::TickOutcome::SweepDone) => {
-                println!("sweep complete: {} workloads", summaries.len());
+                println!("sweep complete: {count} workloads");
                 break;
             }
             Ok(run::TickOutcome::Idle) => {}
@@ -96,6 +63,35 @@ fn headless_run(cfg: config::Config) {
     }
 }
 
+/// One-line score formatter for the headless + smoke modes. Mirrors what
+/// `report::human_summary` does for the score, but standalone so the
+/// headless loop can print it without reconstructing a Report.
+fn format_score(s: &workload::Score) -> String {
+    use workload::Score;
+    match s {
+        Score::None => "(no score)".to_string(),
+        Score::SevenZip { rating_mips, r_u_mips, usage_pct, compress_mips, decompress_mips } => {
+            format!(
+                "rating={rating_mips} MIPS  r/u={r_u_mips}  usage={usage_pct}%  \
+                 compress={compress_mips} MIPS  decompress={decompress_mips} MIPS"
+            )
+        }
+        Score::YCruncher { tests } => {
+            if tests.is_empty() {
+                return "(no tests recorded)".to_string();
+            }
+            tests
+                .iter()
+                .map(|t| {
+                    let gbps = t.bits_per_sec / 1e9;
+                    format!("{}={} {:.2} Gb/s", t.tag, if t.passed { "pass" } else { "FAIL" }, gbps)
+                })
+                .collect::<Vec<_>>()
+                .join("  ")
+        }
+    }
+}
+
 fn smoke_workload(name: &str, cfg: &config::Config) {
     use std::thread;
     use std::time::Duration;
@@ -132,39 +128,11 @@ fn smoke_workload(name: &str, cfg: &config::Config) {
                 let _ = w.stop();
             }
             let verdict = w.wait();
+            let score = w.score();
             println!("smoke: verdict = {:?}", verdict);
+            println!("smoke: score   = {}", format_score(&score));
         }
         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/report.rs b/src/report.rs
index 08cd224..4b040ab 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,16 +1,15 @@
 //! 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.
+//! (`./runs/` by default). Schema v2: run metadata, workload verdict,
+//! and the perf score. No sensor fields — adlermon owns sensors; this
+//! report only records what the workload itself produced.
 //!
-//! 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).
+//! Incremental flush: the whole file is rewritten on each call. A v2
+//! report is small (no per-tick samples), so we only flush once at
+//! `finish()` — partial runs leave a JSON file with verdict=null +
+//! score=null, which is parseable.
 //!
 //! Zero-dep: hand-rolled JSON. The schema is flat enough that serde_json
-//! would be one dep for little gain; revisit if the schema nests.
+//! would be one dep for little gain; revisit if the schema nests deeper.
 
 #![allow(dead_code)] // consumed by run.rs + main.rs — not yet wired
 
@@ -19,12 +18,11 @@ use std::io::{self, Write};
 use std::path::{Path, PathBuf};
 use std::time::{SystemTime, UNIX_EPOCH};
 
-use crate::sensors::Snapshot;
-use crate::workload::Verdict;
+use crate::workload::{Score, 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.
+/// feeds it the verdict + score when the workload completes, and calls
+/// `finish()` to flush.
 pub struct Report {
     pub schema_version: u32,
     pub workload: String,
@@ -34,19 +32,11 @@ pub struct Report {
     /// 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>,
+    /// Set by `finish()`. None for stability-only workloads (stress-ng),
+    /// or until the workload completes.
+    pub score: Option<Score>,
     /// Path the report will be written to (set at construction).
     path: PathBuf,
 }
@@ -65,43 +55,25 @@ impl Report {
         let safe = workload.replace(['/', ' ', ':'], "_");
         let path = report_dir.join(format!("{started}-{safe}.json"));
         Ok(Report {
-            schema_version: 1,
+            schema_version: 2,
             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,
+            score: 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<()> {
+    /// Mark the run complete + flush the final state with the verdict +
+    /// score. Called once, after the workload has exited and its score
+    /// has been parsed. The score is stored verbatim — `Score::None` is
+    /// kept (so `human_summary` can distinguish "stability-only workload"
+    /// from "not finished"), and `json_score` renders it as `null`.
+    pub fn finish(&mut self, verdict: Verdict, score: Score) -> io::Result<()> {
         self.verdict = Some(verdict);
+        self.score = Some(score);
         self.flush()
     }
 
@@ -121,7 +93,7 @@ impl Report {
     /// 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);
+        let mut out = String::with_capacity(1024);
         out.push('{');
         out.push_str(&format!("\"schema_version\":{},", self.schema_version));
         out.push_str(&format!("\"workload\":{},", json_str(&self.workload)));
@@ -129,21 +101,8 @@ impl Report {
         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.push_str(&format!("\"score\":{}", json_score(&self.score)));
+        out.push('}');
         out
     }
 }
@@ -168,23 +127,6 @@ fn json_str(s: &str) -> String {
     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 {
@@ -197,22 +139,40 @@ fn json_verdict(v: &Option<Verdict>) -> String {
     }
 }
 
-/// 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(",")
-    )
+/// JSON-encode a Score. None (no score / not finished) → `null`.
+/// Otherwise an object with `kind` + the per-kind fields.
+fn json_score(s: &Option<Score>) -> String {
+    match s {
+        None => "null".to_string(),
+        Some(Score::None) => "null".to_string(),
+        Some(Score::SevenZip {
+            rating_mips,
+            r_u_mips,
+            usage_pct,
+            compress_mips,
+            decompress_mips,
+        }) => format!(
+            "{{\"kind\":\"7zip\",\"rating_mips\":{rating_mips},\"r_u_mips\":{r_u_mips},\
+             \"usage_pct\":{usage_pct},\"compress_mips\":{compress_mips},\
+             \"decompress_mips\":{decompress_mips}}}"
+        ),
+        Some(Score::YCruncher { tests }) => {
+            let mut out = String::from("{\"kind\":\"y-cruncher\",\"tests\":[");
+            for (i, t) in tests.iter().enumerate() {
+                if i > 0 {
+                    out.push(',');
+                }
+                out.push_str(&format!(
+                    "{{\"tag\":{},\"passed\":{},\"bits_per_sec\":{}}}",
+                    json_str(&t.tag),
+                    t.passed,
+                    t.bits_per_sec
+                ));
+            }
+            out.push_str("]}");
+            out
+        }
+    }
 }
 
 /// Infer the ISA class from the workload name. Used for the report's
@@ -238,31 +198,30 @@ pub fn human_summary(r: &Report) -> 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());
+    let score = match &r.score {
+        None => "(no score)".to_string(),
+        Some(Score::None) => "(stability only)".to_string(),
+        Some(Score::SevenZip { rating_mips, .. }) => format!("rating={rating_mips} MIPS"),
+        Some(Score::YCruncher { tests }) => {
+            if tests.len() == 1 {
+                let t = &tests[0];
+                let gbps = t.bits_per_sec / 1e9;
+                format!("{} {:.2} Gb/s", t.tag, gbps)
+            } else {
+                format!("{} tests", tests.len())
+            }
+        }
+    };
     format!(
-        "{:<18} {:<8} peak_clk={:<12} vCore={:<10} pkg={:<10} temp={:<10} verdict={}",
-        r.workload, r.isa_class, peak_clk, peak_v, peak_w, peak_t, verdict
+        "{:<18} {:<8} {:<24} verdict={}",
+        r.workload, r.isa_class, score, verdict
     )
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::workload::YCruncherTest;
 
     /// Unique dir per test (by test name) so tests don't race on a shared
     /// directory when one test's `remove_dir_all` wipes another's parent.
@@ -271,27 +230,18 @@ mod tests {
     }
 
     fn sample_report() -> Report {
-        let mut r = Report::new(&test_dir("sample_report"), "y-cruncher-avx2", "60s cores=0-15")
+        let mut r = Report::new(&test_dir("sample_report"), "y-cruncher-avx2", "60s cores=0-15 FFTv4")
             .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,
-        })
+        r.finish(
+            Verdict::Clean,
+            Score::YCruncher {
+                tests: vec![YCruncherTest {
+                    tag: "FFTv4".to_string(),
+                    passed: true,
+                    bits_per_sec: 8.94e8,
+                }],
+            },
+        )
         .unwrap();
         r
     }
@@ -315,14 +265,6 @@ mod tests {
         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");
@@ -333,94 +275,94 @@ mod tests {
     }
 
     #[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));
+    fn json_score_null_for_none() {
+        assert_eq!(json_score(&None), "null");
+        assert_eq!(json_score(&Some(Score::None)), "null");
     }
 
     #[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,
-        })
+    fn json_score_7zip_round_trips() {
+        let s = Score::SevenZip {
+            rating_mips: 66259,
+            r_u_mips: 4653,
+            usage_pct: 1431,
+            compress_mips: 5346,
+            decompress_mips: 3961,
+        };
+        let j = json_score(&Some(s));
+        assert!(j.contains("\"kind\":\"7zip\""));
+        assert!(j.contains("\"rating_mips\":66259"));
+        assert!(j.contains("\"r_u_mips\":4653"));
+        assert!(j.contains("\"usage_pct\":1431"));
+        assert!(j.contains("\"compress_mips\":5346"));
+        assert!(j.contains("\"decompress_mips\":3961"));
+    }
+
+    #[test]
+    fn json_score_ycruncher_round_trips() {
+        let s = Score::YCruncher {
+            tests: vec![
+                YCruncherTest { tag: "FFTv4".to_string(), passed: true, bits_per_sec: 8.94e8 },
+                YCruncherTest { tag: "BKT".to_string(), passed: true, bits_per_sec: 9.49e9 },
+            ],
+        };
+        let j = json_score(&Some(s));
+        assert!(j.contains("\"kind\":\"y-cruncher\""));
+        assert!(j.contains("\"tag\":\"FFTv4\""));
+        assert!(j.contains("\"passed\":true"));
+        assert!(j.contains("\"bits_per_sec\":894000000"));
+        assert!(j.contains("\"tag\":\"BKT\""));
+    }
+
+    #[test]
+    fn finish_stores_score_and_verdict() {
+        let mut r = Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
+        r.finish(
+            Verdict::Clean,
+            Score::SevenZip {
+                rating_mips: 1000,
+                r_u_mips: 500,
+                usage_pct: 1500,
+                compress_mips: 700,
+                decompress_mips: 300,
+            },
+        )
         .unwrap();
-        // 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));
+        assert_eq!(r.verdict, Some(Verdict::Clean));
+        assert!(matches!(r.score, Some(Score::SevenZip { .. })));
     }
 
     #[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]]"));
+    fn finish_keeps_none_score_for_summary() {
+        let mut r = Report::new(&test_dir("finish_keeps_none_score_for_summary"), "test", "test").unwrap();
+        r.finish(Verdict::Clean, Score::None).unwrap();
+        // Stored as Some(Score::None) so human_summary can distinguish
+        // "stability only" from "not finished". JSON still renders null.
+        assert_eq!(r.score, Some(Score::None));
+        assert!(r.to_json().contains("\"score\":null"));
     }
 
     #[test]
-    fn to_json_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\":[]"));
+    fn to_json_has_schema_v2() {
+        let r = sample_report();
+        let j = r.to_json();
+        assert!(j.contains("\"schema_version\":2"));
+        assert!(!j.contains("\"samples\"")); // v1 field, gone
+        assert!(!j.contains("\"peak_clock_khz\"")); // v1 field, gone
     }
 
     #[test]
-    fn to_json_with_verdict() {
-        let mut r = sample_report();
-        r.finish(Verdict::Clean).unwrap();
-        let json = r.to_json();
-        assert!(json.contains("\"verdict\":{\"kind\":\"clean\"}"));
+    fn to_json_is_valid_structure() {
+        let r = sample_report();
+        let j = r.to_json();
+        assert!(j.starts_with('{'));
+        assert!(j.ends_with('}'));
+        assert!(j.contains("\"workload\":\"y-cruncher-avx2\""));
+        assert!(j.contains("\"isa_class\":\"avx2\""));
+        assert!(j.contains("\"verdict\":{\"kind\":\"clean\"}"));
+        assert!(j.contains("\"kind\":\"y-cruncher\""));
+        assert!(j.contains("\"tag\":\"FFTv4\""));
+        assert!(j.contains("\"bits_per_sec\":894000000"));
     }
 
     #[test]
@@ -428,42 +370,52 @@ mod tests {
         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();
+        r.finish(Verdict::Clean, Score::None).unwrap();
         let written = fs::read_to_string(r.path()).unwrap();
         assert!(written.contains("\"workload\":\"flush-test\""));
         assert!(written.contains("\"verdict\":{\"kind\":\"clean\"}"));
-        assert!(written.contains("\"t\":0.0"));
-        // Path includes the started timestamp + sanitized name
+        assert!(written.contains("\"score\":null"));
         assert!(r.path().file_name().unwrap().to_str().unwrap().contains("flush-test.json"));
     }
 
     #[test]
     fn human_summary_formats_one_line() {
-        let mut r = sample_report();
-        r.finish(Verdict::Clean).unwrap();
+        let r = sample_report();
         let s = human_summary(&r);
         assert!(s.contains("y-cruncher-avx2"));
         assert!(s.contains("avx2"));
         assert!(s.contains("CLEAN"));
-        assert!(s.contains("peak_clk="));
-        assert!(s.contains("vCore="));
+        assert!(s.contains("FFTv4"));
+        assert!(s.contains("Gb/s"));
     }
 
     #[test]
-    fn human_summary_handles_unreadable_sensors() {
-        let r = Report::new(&test_dir("human_summary_handles_unreadable_sensors"), "test", "test").unwrap();
+    fn human_summary_for_7zip() {
+        let mut r = Report::new(&test_dir("human_summary_for_7zip"), "7zip-bench", "60s").unwrap();
+        r.finish(
+            Verdict::Clean,
+            Score::SevenZip {
+                rating_mips: 66259,
+                r_u_mips: 4653,
+                usage_pct: 1431,
+                compress_mips: 5346,
+                decompress_mips: 3961,
+            },
+        )
+        .unwrap();
         let s = human_summary(&r);
-        assert!(s.contains("unreadable"));
+        assert!(s.contains("66259 MIPS"));
+        assert!(s.contains("mixed"));
+    }
+
+    #[test]
+    fn human_summary_for_stability_only() {
+        let mut r = Report::new(&test_dir("human_summary_for_stability_only"), "stress-ng-cpu", "60s").unwrap();
+        r.finish(Verdict::Clean, Score::None).unwrap();
+        let s = human_summary(&r);
+        assert!(s.contains("(stability only)"));
+        assert!(s.contains("integer"));
+        assert!(s.contains("CLEAN"));
     }
 
     #[test]
diff --git a/src/run.rs b/src/run.rs
index 94be5b1..d4e1251 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -1,9 +1,10 @@
 //! 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.
+//! workload.start() → wait for exit (polling is_running) → workload.stop()
+//! if needed → workload.wait() for verdict → workload.score() for the
+//! perf number → flush report. Front-end-agnostic: the TUI drives it by
+//! calling `tick()` every `poll_ms`; `--headless` does the same without
+//! rendering.
 //!
 //! State machine (poll-driven, no async):
 //!   Cooldown(remaining) ── elapsed → Running(idx)
@@ -18,8 +19,7 @@ use std::time::{Duration, Instant};
 
 use crate::config::Config;
 use crate::report::Report;
-use crate::sensors;
-use crate::workload::{self, Verdict, Workload};
+use crate::workload::{self, Score, 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.
@@ -28,23 +28,20 @@ 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.
+    /// elapsed and we stopped it). The verdict + score + report path are
+    /// included for the TUI's result table + stdout summary.
     WorkloadFinished {
         idx: usize,
         name: String,
         verdict: Verdict,
+        score: Score,
         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.
+    /// no exit was detected). The caller can just render the existing state.
     Idle,
 }
 
@@ -64,12 +61,6 @@ pub struct Run {
     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>,
 }
@@ -112,8 +103,6 @@ impl Run {
                 remaining: 0,
             },
             state_start: Instant::now(),
-            last_sample: Instant::now(),
-            prev_energy_uj: None,
             report: None,
         }
     }
@@ -128,8 +117,8 @@ impl Run {
         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.
+    /// One tick of the state machine. Call every `poll_ms`. Polls the
+    /// workload's `is_running()` to detect completion + advances state.
     pub fn tick(&mut self) -> io::Result<TickOutcome> {
         let elapsed = self.state_start.elapsed();
         match &mut self.state {
@@ -153,20 +142,7 @@ impl Run {
                     // 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 })
+                Ok(TickOutcome::Idle)
             }
             State::Done => Ok(TickOutcome::SweepDone),
         }
@@ -190,8 +166,6 @@ impl Run {
         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(),
@@ -206,18 +180,20 @@ impl Run {
                 if w.is_running() {
                     let _ = w.stop();
                 }
-                w.wait()
+                let v = w.wait();
+                (v, w.score())
             } else {
-                Verdict::Error("no workload".to_string())
+                (Verdict::Error("no workload".to_string()), Score::None)
             }
         };
+        let (verdict, score) = verdict;
         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())?;
+            r.finish(verdict.clone(), score.clone())?;
         }
         let idx = self.current;
         let name = self.cfg.workloads[idx].name.clone();
@@ -239,6 +215,7 @@ impl Run {
             idx,
             name,
             verdict,
+            score,
             report_path,
         })
     }
@@ -295,6 +272,9 @@ mod tests {
                 Verdict::Clean
             }
         }
+        fn score(&mut self) -> Score {
+            Score::None
+        }
     }
 
     fn mock_cfg(workloads: Vec<WorkloadSpec>) -> Config {
@@ -302,8 +282,6 @@ mod tests {
             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,
@@ -322,8 +300,6 @@ mod tests {
             current: 0,
             state: State::Cooldown { remaining: 0 },
             state_start: Instant::now(),
-            last_sample: Instant::now(),
-            prev_energy_uj: None,
             report: None,
         }
     }
@@ -339,7 +315,6 @@ mod tests {
             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;
@@ -349,7 +324,6 @@ mod tests {
                     started += 1;
                     assert!(name == "mock-a" || name == "mock-b");
                 }
-                TickOutcome::SampleTaken { .. } => {}
                 TickOutcome::WorkloadFinished { name, verdict, .. } => {
                     finished += 1;
                     assert!(name == "mock-a" || name == "mock-b");
@@ -380,12 +354,9 @@ mod tests {
         ]);
         let mut verdict = None;
         for _ in 0..10 {
-            match run.tick().unwrap() {
-                TickOutcome::WorkloadFinished { verdict: v, .. } => {
-                    verdict = Some(v);
-                    break;
-                }
-                _ => {}
+            if let TickOutcome::WorkloadFinished { verdict: v, .. } = run.tick().unwrap() {
+                verdict = Some(v);
+                break;
             }
         }
         // duration_secs=0 → first Running tick sees elapsed >= 0 → stops.
@@ -406,9 +377,6 @@ mod tests {
     #[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
deleted file mode 100644
index 1332f27..0000000
--- a/src/sensors.rs
+++ /dev/null
@@ -1,337 +0,0 @@
-//! 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.
-    pub(crate) 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
index 2b9f885..0441c61 100644
--- a/src/workload.rs
+++ b/src/workload.rs
@@ -1,14 +1,14 @@
-//! 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.
+//! Workload abstraction for the sweep. A Workload is something the run
+//! loop can start, let run for a duration, stop, and read a stability
+//! verdict + a perf score 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.
+//!    comes from the exit code, the score comes from 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 verdict + score come 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.
@@ -19,10 +19,11 @@ 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.
+/// What a workload reports when it's done. The stability verdict is one
+/// half of 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 stability question cares about. The other
+/// half is the [`Score`], returned by [`Workload::score`].
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Verdict {
     /// Workload self-check passed. The chip was stable for this run.
@@ -38,6 +39,43 @@ pub enum Verdict {
     Error(String),
 }
 
+/// The perf score a workload emits. Stability is the verdict's job; this
+/// is the comparative-perf number across configs. Per-kind so the TUI +
+/// report can render each workload's natural metrics without forcing a
+/// one-size-fits-all number. Stress tests (stress-ng-cpu) report `None`
+/// — they exist for stability, not for a score.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Score {
+    /// No score (stress-ng-cpu and other pure stability workloads).
+    None,
+    /// 7-Zip benchmark. `rating_mips` is the headline comparative number
+    /// (the `Tot: ... MIPS` column); the rest is the breakdown.
+    SevenZip {
+        rating_mips: u64,
+        r_u_mips: u64,
+        usage_pct: u64,
+        compress_mips: u64,
+        decompress_mips: u64,
+    },
+    /// y-cruncher stress. One entry per algorithm that ran. Each entry's
+    /// `bits_per_sec` is the test speed (e.g. 8.94e8); `passed` mirrors
+    /// the verdict but per-test so a partial run still records what
+    /// completed. For the configured FFTv4-only sweep there's one entry.
+    YCruncher { tests: Vec<YCruncherTest> },
+}
+
+/// One y-cruncher algorithm result.
+#[derive(Clone, Debug, PartialEq)]
+pub struct YCruncherTest {
+    /// Algorithm tag, e.g. "FFTv4", "BKT", "N63".
+    pub tag: String,
+    /// Whether this algorithm's self-check passed.
+    pub passed: bool,
+    /// Test speed in bits/sec (normalized from the `<m> * 10^<e>` form
+    /// y-cruncher prints).
+    pub bits_per_sec: f64,
+}
+
 /// The common surface the run loop drives. See the module doc for the two
 /// shapes behind this trait.
 pub trait Workload {
@@ -49,7 +87,7 @@ pub trait Workload {
     /// 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.
+    /// tick to decide whether to keep waiting 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
@@ -61,6 +99,9 @@ pub trait Workload {
     /// done (either `is_running()` went false or the run loop's own
     /// duration timer expired and it called `stop()`).
     fn wait(&mut self) -> Verdict;
+    /// Parse the perf score from captured stdout. Called after `wait()`.
+    /// Implementations that don't produce a score return `Score::None`.
+    fn score(&mut self) -> Score;
 }
 
 // ---------------------------------------------------------------------------
@@ -242,6 +283,9 @@ impl Workload for StressNg {
             Err(e) => Verdict::Error(format!("wait: {e}")),
         }
     }
+    fn score(&mut self) -> Score {
+        Score::None
+    }
 }
 
 // ---------------------------------------------------------------------------
@@ -391,6 +435,12 @@ impl Workload for YCruncher {
             Err(e) => Verdict::Error(format!("wait: {e}")),
         }
     }
+    fn score(&mut self) -> Score {
+        let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
+        Score::YCruncher {
+            tests: parse_ycruncher_scores(&stdout),
+        }
+    }
 }
 
 // ---------------------------------------------------------------------------
@@ -486,6 +536,10 @@ impl Workload for SevenZip {
             Err(e) => Verdict::Error(format!("wait: {e}")),
         }
     }
+    fn score(&mut self) -> Score {
+        let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
+        parse_7zip_score(&stdout).unwrap_or(Score::None)
+    }
 }
 
 // ---------------------------------------------------------------------------
@@ -516,6 +570,155 @@ pub fn from_spec(
     }
 }
 
+// ---------------------------------------------------------------------------
+// Score parsers
+// ---------------------------------------------------------------------------
+
+/// Strip ANSI CSI escape sequences (`\x1b[ ... m` and similar) + convert
+/// the `\r` that y-cruncher emits for live-console line overwrites into
+/// `\n` so the score parser can split lines cleanly. Not a full ANSI
+/// stripper — enough for y-cruncher's color codes, which are all SGR
+/// (`\x1b[...m`).
+fn strip_ansi(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    let mut chars = s.chars().peekable();
+    while let Some(c) = chars.next() {
+        if c == '\x1b' {
+            // Skip `\x1b[ ... <final byte>`. Final byte is in the range
+            // 0x40-0x7E per the CSI spec; SGR ends in `m`.
+            if chars.peek() == Some(&'[') {
+                chars.next();
+                for c in chars.by_ref() {
+                    if (c as u32) >= 0x40 && (c as u32) <= 0x7E {
+                        break;
+                    }
+                }
+                continue;
+            }
+            // Lone ESC — drop it.
+            continue;
+        }
+        if c == '\r' {
+            // y-cruncher uses bare `\r` to overwrite the live console
+            // line for progress updates. Convert to `\n` so each
+            // overwriting pass becomes its own line for the parser.
+            out.push('\n');
+            continue;
+        }
+        out.push(c);
+    }
+    out
+}
+
+/// Parse y-cruncher's `Running <TAG>: Passed  Test Speed:  <m> * 10^<exp>
+/// bits / sec` lines. Each test prints twice (once padded for the live
+/// console, once clean) — dedup by tag, keep the first. Tests that
+/// didn't run (Disabled in the menu) don't appear, so the returned vec
+/// only has tests that actually executed.
+fn parse_ycruncher_scores(stdout: &str) -> Vec<YCruncherTest> {
+    let plain = strip_ansi(stdout);
+    let mut tests: Vec<YCruncherTest> = Vec::new();
+    for line in plain.lines() {
+        // Match the clean form: "Running FFTv4: Passed  Test Speed:  8.94 * 10^08  bits / sec"
+        // Skip the "live console" form (has trailing whitespace padding
+        // before "Running" repeats) by requiring the line to END with
+        // "bits / sec" (no trailing spaces).
+        let trimmed = line.trim_end();
+        if !trimmed.ends_with("bits / sec") {
+            continue;
+        }
+        let Some(rest) = trimmed.strip_prefix("Running ") else {
+            continue;
+        };
+        let Some((tag_part, speed_part)) = rest.split_once(": ") else {
+            continue;
+        };
+        if !speed_part.starts_with("Passed") && !speed_part.starts_with("failed") {
+            continue;
+        }
+        let passed = speed_part.starts_with("Passed");
+        // Speed_part looks like: "Passed  Test Speed:  8.94 * 10^08  bits / sec"
+        //                       "failed  Test Speed:  <m> * 10^<exp>  bits / sec"
+        let Some(speed_idx) = speed_part.find("Test Speed:") else {
+            continue;
+        };
+        let after = speed_part[speed_idx + "Test Speed:".len()..].trim();
+        // after = "<m> * 10^<exp>  bits / sec"
+        let Some((mantissa_exp, _)) = after.split_once("bits / sec") else {
+            continue;
+        };
+        let mantissa_exp = mantissa_exp.trim();
+        // mantissa_exp = "8.94 * 10^08"
+        let Some((m_str, e_str)) = mantissa_exp.split_once(" * 10^") else {
+            continue;
+        };
+        let Ok(m) = m_str.trim().parse::<f64>() else {
+            continue;
+        };
+        let Ok(e) = e_str.trim().parse::<i32>() else {
+            continue;
+        };
+        let bits_per_sec = m * 10f64.powi(e);
+        let tag = tag_part.trim().to_string();
+        // Dedup by tag — y-cruncher prints each test's result twice.
+        if tests.iter().any(|t| t.tag == tag) {
+            continue;
+        }
+        tests.push(YCruncherTest { tag, passed, bits_per_sec });
+    }
+    tests
+}
+
+/// 7z `Avr:` line tuple: (compress speed_kibs, usage, r_u_mips, rating,
+/// decompress speed_kibs, usage, r_u_mips, rating).
+type AvrTuple = (u64, u64, u64, u64, u64, u64, u64, u64);
+
+/// Parse 7-Zip's `7z b` stdout. The footer has:
+///   `Avr:     70556  1379   5346  73766  |     674420  1483   3961  58751`
+///   `Tot:            1431   4653  66259`
+/// `Tot:` = usage_pct, r_u_mips, rating_mips.
+/// `Avr:` = compress (speed_kibs, usage, r_u, rating) | decompress (same).
+/// We want compress r_u (5346) + decompress r_u (3961) from Avr, and all
+/// three from Tot. Returns None if either line is missing/malformed.
+fn parse_7zip_score(stdout: &str) -> Option<Score> {
+    let mut avr: Option<AvrTuple> = None;
+    let mut tot: Option<(u64, u64, u64)> = None;
+    for line in stdout.lines() {
+        let line = line.trim();
+        if line.starts_with("Avr:") {
+            // Avr: <c_speed> <c_usage> <c_ru> <c_rating> | <d_speed> <d_usage> <d_ru> <d_rating>
+            let rest = line.strip_prefix("Avr:").unwrap_or("").trim();
+            let Some((left, right)) = rest.split_once('|') else {
+                continue;
+            };
+            let lc: Vec<u64> = left.split_whitespace().filter_map(|s| s.parse().ok()).collect();
+            let rc: Vec<u64> = right.split_whitespace().filter_map(|s| s.parse().ok()).collect();
+            if lc.len() >= 4 && rc.len() >= 4 {
+                avr = Some((
+                    lc[0], lc[1], lc[2], lc[3],
+                    rc[0], rc[1], rc[2], rc[3],
+                ));
+            }
+        } else if line.starts_with("Tot:") {
+            // Tot: <usage> <r_u> <rating>
+            let rest = line.strip_prefix("Tot:").unwrap_or("").trim();
+            let nums: Vec<u64> = rest.split_whitespace().filter_map(|s| s.parse().ok()).collect();
+            if nums.len() >= 3 {
+                tot = Some((nums[0], nums[1], nums[2]));
+            }
+        }
+    }
+    let (_c_speed, _c_usage, c_ru, _c_rating, _d_speed, _d_usage, d_ru, _d_rating) = avr?;
+    let (usage_pct, r_u_mips, rating_mips) = tot?;
+    Some(Score::SevenZip {
+        rating_mips,
+        r_u_mips,
+        usage_pct,
+        compress_mips: c_ru,
+        decompress_mips: d_ru,
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -597,4 +800,92 @@ mod tests {
             assert!(from_spec(&spec, std::path::Path::new("bin")).is_none(), "{name} should be None");
         }
     }
+
+    // --- score parser tests ---
+
+    /// Real y-cruncher FFTv4 stdout (single-algorithm run). Has ANSI
+    /// color codes + \r line noise + duplicate lines (live + log form).
+    #[test]
+    fn parse_ycruncher_fftv4_single_algorithm() {
+        let stdout = include_str!("../test-fixtures/yc-avx2-FFTv4.txt");
+        let tests = parse_ycruncher_scores(stdout);
+        assert_eq!(tests.len(), 1, "dedup by tag should leave one FFTv4 entry");
+        assert_eq!(tests[0].tag, "FFTv4");
+        assert!(tests[0].passed);
+        // 8.94 * 10^08 = 894_000_000
+        assert!((tests[0].bits_per_sec - 8.94e8).abs() < 1.0);
+    }
+
+    #[test]
+    fn parse_ycruncher_handles_plain_ascii() {
+        // No ANSI codes — just the clean log line.
+        let stdout = "Running FFTv4: Passed  Test Speed:  8.55 * 10^08  bits / sec\n";
+        let tests = parse_ycruncher_scores(stdout);
+        assert_eq!(tests.len(), 1);
+        assert_eq!(tests[0].tag, "FFTv4");
+        assert!(tests[0].passed);
+        assert!((tests[0].bits_per_sec - 8.55e8).abs() < 1.0);
+    }
+
+    #[test]
+    fn parse_ycruncher_dedups_repeated_tags() {
+        // y-cruncher prints each test's result twice (live console + log).
+        let stdout = "Running FFTv4: Passed  Test Speed:  1.01 * 10^09  bits / sec\n\
+                       Running FFTv4: Passed  Test Speed:  1.01 * 10^09  bits / sec\n";
+        let tests = parse_ycruncher_scores(stdout);
+        assert_eq!(tests.len(), 1);
+    }
+
+    #[test]
+    fn parse_ycruncher_skips_non_score_lines() {
+        let stdout = "\
+Running from console...
+Iteration: 0  Total Elapsed Time: 0.554 seconds ( 0.009 minutes )
+Running FFTv4:
+Running FFTv4: Passed  Test Speed:  8.94 * 10^08  bits / sec
+Test Finished. Waiting for threads to terminate...
+";
+        let tests = parse_ycruncher_scores(stdout);
+        assert_eq!(tests.len(), 1);
+        assert_eq!(tests[0].tag, "FFTv4");
+    }
+
+    #[test]
+    fn parse_7zip_real_stdout() {
+        let stdout = include_str!("../test-fixtures/7z-default.txt");
+        let score = parse_7zip_score(stdout).expect("7z footer has Avr + Tot");
+        match score {
+            Score::SevenZip {
+                rating_mips,
+                r_u_mips,
+                usage_pct,
+                compress_mips,
+                decompress_mips,
+            } => {
+                assert_eq!(rating_mips, 66259, "Tot Rating MIPS");
+                assert_eq!(r_u_mips, 4653, "Tot R/U MIPS");
+                assert_eq!(usage_pct, 1431, "Tot Usage %");
+                assert_eq!(compress_mips, 5346, "Avr compress R/U MIPS");
+                assert_eq!(decompress_mips, 3961, "Avr decompress R/U MIPS");
+            }
+            other => panic!("expected SevenZip, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn parse_7zip_returns_none_when_footer_missing() {
+        assert!(parse_7zip_score("no footer here\njust junk").is_none());
+    }
+
+    #[test]
+    fn strip_ansi_removes_sgr_codes() {
+        let s = "\x1b[01;32mPassed\x1b[01;37m  Test Speed";
+        assert_eq!(strip_ansi(s), "Passed  Test Speed");
+    }
+
+    #[test]
+    fn strip_ansi_passes_plain_text() {
+        assert_eq!(strip_ansi("plain text"), "plain text");
+        assert_eq!(strip_ansi("with\rcarriage"), "with\ncarriage");
+    }
 }
\ No newline at end of file
diff --git a/test-fixtures/7z-default.txt b/test-fixtures/7z-default.txt
new file mode 100644
index 0000000..13900f8
--- /dev/null
+++ b/test-fixtures/7z-default.txt
@@ -0,0 +1,28 @@
+
+7-Zip (z) 26.02 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-06-25
+ 64-bit locale=en_US.utf8 Threads:16 OPEN_MAX:524288
+
+Compiler:  ver:15.3.0 GCC 15.3.0 : AVX2
+Linux : 7.2.2-tkg-bore-llvm : #1 SMP PREEMPT_DYNAMIC TKG Sat Aug 29 11:21:27 CDT 2026 : x86_64
+PageSize:4KB HPS:2MB THPS:2MB THP:always hwcap:2 hwcap2:2
+12th Gen Intel(R) Core(TM) i5-12600KF
+(90672)  (LP)
+
+1T CPU Freq (MHz):  2336  4470  4815  4833  4791  4830  4811
+8T CPU Freq (MHz): 673% 3827   677% 3832  
+16T CPU Freq (MHz): 1056% 2846   1118% 3019  
+
+RAM size:   31926 MB (LP),  # CPU hardware threads:  16
+RAM usage:   3559 MB (LP),  # Benchmark threads:     16
+
+                       Compressing  |                  Decompressing
+Dict     Speed Usage    R/U Rating  |      Speed Usage    R/U Rating
+         KiB/s     %   MIPS   MIPS  |      KiB/s     %   MIPS   MIPS
+
+22:      82943  1408   5730  80688  |     666900  1422   4000  56866
+23:      71101  1363   5316  72444  |     694654  1523   3947  60093
+24:      66357  1367   5219  71348  |     676704  1498   3963  59375
+25:      61821  1379   5118  70585  |     659420  1491   3934  58669
+----------------------------------  | ------------------------------
+Avr:     70556  1379   5346  73766  |     674420  1483   3961  58751
+Tot:            1431   4653  66259
diff --git a/test-fixtures/yc-avx2-FFTv4.txt b/test-fixtures/yc-avx2-FFTv4.txt
new file mode 100644
index 0000000..a1872f8
--- /dev/null
+++ b/test-fixtures/yc-avx2-FFTv4.txt
@@ -0,0 +1,79 @@
+Running from console...
+
+
+Checking processor/OS features...
+
+Required Features:
+    x64, ABM, BMI1, BMI2,
+    SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2,
+    AVX, FMA3, AVX2
+
+
+
+Reading Hardware Topology...
+
+Logical Cores:
+    0-15
+
+Physical Cores:
+    0-9
+
+
+Unable to read or parse "/sys/devices/system/node/".
+Thread and node affinities may not function correctly.
+
+Press ENTER to continue . . .NUMA Node 0:
+    0-15
+
+Scheduling Group 0:
+    0-15
+
+
+
+Component Stress Tester
+
+  1   Logical Cores:      16
+  2   Memory:             24.7 GiB  ( 1.54 GiB per thread )
+  3   NUMA Mode:          Local - Memory allocated from local thread.
+  4/5 Time Limit:         5 seconds per test / 5 seconds total
+  6   Stop on Error:      Enabled
+
+ 7/8  Enable All Tests / Disable All Tests
+ 9/10 Load/Save Configuration File
+
+  #  Tag   Test Name                   Mem/Thread  Component      CPU------Mem
+ 11  BKT   Basecase + Karatsuba          Disabled  Scalar Integer  -|--------
+ 12  BBP   BBP Digit Extraction          Disabled  AVX2 Float      |---------
+ 13  SFTv4 Small In-Cache FFTv4          Disabled  AVX2 Float      -|--------
+ 14  SNT   Small In-Cache N63            Disabled  AVX2 Integer    --|-------
+ 15  SVT   Small In-Cache VT3            Disabled  AVX2 Float      --|-------
+ 16  FFTv4 Fast Fourier Transform (v4)    246 MiB  AVX2 Float      ---------|
+ 17  N63   Classic NTT (v2)              Disabled  AVX2 Integer    ---|------
+ 18  VT3   Vector Transform (v3)         Disabled  AVX2 Float      ----|-----
+
+  0   Start Stress-Testing!
+
+
+Allocating Memory...
+  Core   0:   246 MiB  
+  Core   1:   246 MiB  
+  Core   2:   246 MiB  
+  Core   3:   246 MiB  
+  Core   5:   246 MiB  
+  Core   4:   246 MiB  
+  Core   6:   246 MiB  
+  Core   7:   246 MiB  
+  Core   8:   246 MiB  
+  Core   9:   246 MiB  
+  Core  11:   246 MiB  
+  Core  10:   246 MiB  
+  Core  15:   246 MiB  
+  Core  12:   246 MiB  
+  Core  13:   246 MiB  
+  Core  14:   246 MiB  
+
+Iteration: 0  Total Elapsed Time: 0.554 seconds  ( 0.009 minutes )
+
Running FFTv4:                                                                 
Running FFTv4: Passed  Test Speed:  8.94 * 10^08  bits / sec                   
                                                                               
Running FFTv4: Passed  Test Speed:  8.94 * 10^08  bits / sec
+
+Test Finished. Waiting for threads to terminate...
+