diff --git a/src/config.rs b/src/config.rs
index 8cdfa5b..2d9c2c1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -22,6 +22,7 @@ pub struct WorkloadSpec {
pub cores: String,
}
+#[derive(Clone, Debug)]
pub struct Config {
/// Workloads to cycle through, in order. One sweep run = each entry
/// once, with `cool_secs` cooldown between.
@@ -30,6 +31,11 @@ pub struct Config {
pub poll_ms: u64,
/// Cooldown between workloads, seconds (idle baseline re-set).
pub cool_secs: u64,
+ /// Number of times to repeat the full sweep. 1 = single pass, 0 =
+ /// infinite (loop forever until the user quits). The run loop
+ /// resets + re-runs on SweepDone when loops remain. Used by the
+ /// stability preset's 1-hour target + the custom editor's loop knob.
+ pub loop_count: u32,
/// Where to write JSON reports. Created on first run if missing.
pub report_dir: PathBuf,
/// Where external binaries live (y-cruncher). Defaults to ./bin.
@@ -44,6 +50,7 @@ impl Default for Config {
workloads: default_workloads(),
poll_ms: 250,
cool_secs: 15,
+ loop_count: 1,
report_dir: PathBuf::from("runs"),
bin_dir: PathBuf::from("bin"),
loaded_path: None,
@@ -92,6 +99,7 @@ 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),
+ "loop_count" => cfg.loop_count = val.parse().unwrap_or(cfg.loop_count),
"report_dir" => cfg.report_dir = PathBuf::from(val),
"bin_dir" => cfg.bin_dir = PathBuf::from(val),
"workloads" => cfg.workloads = parse_workload_list(val),
@@ -214,8 +222,10 @@ 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, "loop_count = 3");
assert_eq!(cfg.poll_ms, 500);
assert_eq!(cfg.cool_secs, 30);
+ assert_eq!(cfg.loop_count, 3);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 31f1c81..d75fc23 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
mod config;
+mod presets;
mod report;
mod run;
mod ui;
@@ -17,13 +18,33 @@ fn main() {
headless_run(cfg);
return;
}
+ if let Some(idx) = args.iter().position(|a| a == "--preset") {
+ if let Some(name) = args.get(idx + 1) {
+ match name.as_str() {
+ "stability" => {
+ ui::run_tui(presets::stability());
+ return;
+ }
+ "bench" => {
+ ui::run_tui(presets::bench());
+ return;
+ }
+ "core-cycle" => {
+ ui::run_tui(presets::stability_core_cycle(60));
+ return;
+ }
+ _ => {
+ eprintln!("unknown preset: {name} (available: stability, bench, core-cycle)");
+ std::process::exit(1);
+ }
+ }
+ }
+ }
if args.iter().any(|a| a == "--tui") {
ui::run_tui(cfg);
return;
}
- // Default mode = TUI (the real front-end). Args without a dedicated
- // mode fall through to the TUI so `adlerbench` with no args launches
- // the sweep with a live display.
+ // Default mode = TUI picker (lets the user choose a preset or custom).
ui::run_tui(cfg);
}
diff --git a/src/presets.rs b/src/presets.rs
new file mode 100644
index 0000000..1c5d8ef
--- /dev/null
+++ b/src/presets.rs
@@ -0,0 +1,171 @@
+//! Sweep presets — named workload sets + sweep params for the TUI's
+//! picker screen + the `--preset` CLI arg. Each builder returns a
+//! `Config` ready to hand to `Run::new`.
+//!
+//! Three presets:
+//! - `stability` — stress-ng-cpu → y-cruncher-sse → y-cruncher-avx2,
+//! 5 min (300s) each, all cores, 15s cooldown, looped to fill ~1 hour
+//! (4 loops ≈ 49 min, 5 loops ≈ 61 min — we pick 5 to hit the target).
+//! - `stability_core_cycle` — the same three workloads, but run on ONE
+//! logical core at a time (cycling through all 16 twice), shorter
+//! per-core duration (default 60s). Used to test peak single-core
+//! clocks on chips with per-core load-based multipliers.
+//! - `bench` — 7zip-bench → y-cruncher-sse → y-cruncher-avx2, 60s each,
+//! all cores, 15s cooldown, single pass (the scores are the point;
+//! looping doesn't improve the measurement).
+
+use crate::config::{Config, WorkloadSpec};
+
+/// Workload names available in v1 (the from_spec match arms in workload.rs).
+/// The custom editor iterates this list for its toggle rows.
+pub const AVAILABLE_WORKLOADS: &[&str] = &[
+ "stress-ng-cpu",
+ "y-cruncher-sse",
+ "y-cruncher-avx2",
+ "7zip-bench",
+];
+
+/// Cores range options for the custom editor's per-workload cores field.
+/// P = performance cores only, E = efficiency cores only, All = both.
+/// (Reserved for the editor's cores-field cycling; not yet wired into
+/// the TUI's cores edit — the editor currently accepts free-text cores
+/// input via the numeric/string edit buffer.)
+#[allow(dead_code)]
+pub const CORES_OPTIONS: &[&str] = &["0-15", "0-11", "12-15"];
+
+fn spec(name: &str, duration_secs: u64, cores: &str) -> WorkloadSpec {
+ WorkloadSpec {
+ name: name.to_string(),
+ duration_secs,
+ cores: cores.to_string(),
+ }
+}
+
+/// Stability preset: cycle the three ISA-rail stress tests, 5 min each,
+/// all cores, 15s cooldown, looped to fill ~1 hour. 4 loops = 49 min,
+/// 5 loops = 61 min — pick 5 to cross the hour. Cancelable mid-run
+/// (q in the TUI) so the user isn't committed to the full hour.
+pub fn stability() -> Config {
+ Config {
+ workloads: vec![
+ spec("stress-ng-cpu", 300, "0-15"),
+ spec("y-cruncher-sse", 300, "0-15"),
+ spec("y-cruncher-avx2", 300, "0-15"),
+ ],
+ cool_secs: 15,
+ loop_count: 5,
+ ..Config::default()
+ }
+}
+
+/// Stability core-cycle preset: the same three workloads, but run on
+/// ONE logical core at a time. Cycles through all 16 logical cores
+/// twice. The `per_core_secs` arg sets the per-workload-per-core run
+/// length (default 60s). Total run time ≈ 16 cores × 2 passes × 3
+/// workloads × per_core_secs + cooldowns. Long, but cancelable.
+///
+/// This tests peak single-core clocks on chips with per-core load-based
+/// multipliers — the question is whether each individual core is stable
+/// at its own peak clock, not just whether the package is stable at the
+/// all-core clock.
+pub fn stability_core_cycle(per_core_secs: u64) -> Config {
+ let mut workloads: Vec<WorkloadSpec> = Vec::new();
+ // Two full passes across all 16 logical cores (0-15).
+ for pass in 0..2u32 {
+ for cpu in 0..16u32 {
+ // Three ISA rails, each on this single core.
+ for name in ["stress-ng-cpu", "y-cruncher-sse", "y-cruncher-avx2"] {
+ workloads.push(spec(name, per_core_secs, &cpu.to_string()));
+ }
+ }
+ let _ = pass; // pass count is the loop bound, not a body var
+ }
+ Config {
+ workloads,
+ cool_secs: 5, // shorter cooldown — single-core load doesn't heat the package
+ loop_count: 1, // the core-cycle IS the loop; don't loop the whole thing
+ ..Config::default()
+ }
+}
+
+/// Bench preset: the three score-producing workloads, 60s each, all
+/// cores, 15s cooldown, single pass. The scores (7z rating MIPS,
+/// y-cruncher FFTv4 Gb/s) are the comparative-perf signal across OC
+/// configs.
+pub fn bench() -> Config {
+ Config {
+ workloads: vec![
+ spec("7zip-bench", 60, "0-15"),
+ spec("y-cruncher-sse", 60, "0-15"),
+ spec("y-cruncher-avx2", 60, "0-15"),
+ ],
+ cool_secs: 15,
+ loop_count: 1,
+ ..Config::default()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn stability_has_three_rails_five_loops() {
+ let cfg = stability();
+ assert_eq!(cfg.workloads.len(), 3);
+ assert_eq!(cfg.workloads[0].name, "stress-ng-cpu");
+ assert_eq!(cfg.workloads[1].name, "y-cruncher-sse");
+ assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+ assert_eq!(cfg.workloads[0].duration_secs, 300);
+ assert_eq!(cfg.cool_secs, 15);
+ assert_eq!(cfg.loop_count, 5);
+ }
+
+ #[test]
+ fn stability_core_cycle_visits_each_core_twice() {
+ let cfg = stability_core_cycle(60);
+ // 16 cores × 2 passes × 3 workloads = 96 entries.
+ assert_eq!(cfg.workloads.len(), 96);
+ // First three are cpu0 (stress-ng, sse, avx2).
+ assert_eq!(cfg.workloads[0].name, "stress-ng-cpu");
+ assert_eq!(cfg.workloads[0].cores, "0");
+ assert_eq!(cfg.workloads[1].name, "y-cruncher-sse");
+ assert_eq!(cfg.workloads[1].cores, "0");
+ assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+ assert_eq!(cfg.workloads[2].cores, "0");
+ // Last three are cpu15 on the second pass.
+ let last = &cfg.workloads[95];
+ assert_eq!(last.name, "y-cruncher-avx2");
+ assert_eq!(last.cores, "15");
+ // All single-core (no "0-15" range anywhere).
+ assert!(cfg.workloads.iter().all(|w| w.cores != "0-15"));
+ assert_eq!(cfg.loop_count, 1);
+ }
+
+ #[test]
+ fn stability_core_cycle_custom_duration() {
+ let cfg = stability_core_cycle(30);
+ assert_eq!(cfg.workloads[0].duration_secs, 30);
+ }
+
+ #[test]
+ fn bench_has_three_scored_workloads_single_pass() {
+ let cfg = bench();
+ assert_eq!(cfg.workloads.len(), 3);
+ assert_eq!(cfg.workloads[0].name, "7zip-bench");
+ assert_eq!(cfg.workloads[1].name, "y-cruncher-sse");
+ assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+ assert_eq!(cfg.workloads[0].duration_secs, 60);
+ assert_eq!(cfg.loop_count, 1);
+ }
+
+ #[test]
+ fn available_workloads_lists_v1_impls() {
+ assert!(AVAILABLE_WORKLOADS.contains(&"stress-ng-cpu"));
+ assert!(AVAILABLE_WORKLOADS.contains(&"y-cruncher-sse"));
+ assert!(AVAILABLE_WORKLOADS.contains(&"y-cruncher-avx2"));
+ assert!(AVAILABLE_WORKLOADS.contains(&"7zip-bench"));
+ // Deferred impls NOT in the list (c2c/dram/minecraft).
+ assert!(!AVAILABLE_WORKLOADS.contains(&"c2c-latency"));
+ }
+}
\ No newline at end of file
diff --git a/src/run.rs b/src/run.rs
index 27606c9..8ba32bd 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -63,6 +63,15 @@ pub struct Run {
state_start: Instant,
/// The report being written for the current workload.
report: Option<Report>,
+ /// How many loops are left, counting the current one. 1 = this is
+ /// the last (or only) loop. 0 = infinite (loop forever). Decremented
+ /// when a sweep completes + there are more loops to go; the run then
+ /// resets `current` to 0 + re-enters Cooldown. When this hits 0 after
+ /// the decrement (and it wasn't infinite), State::Done fires.
+ loops_left: u32,
+ /// 1-indexed ordinal of the current loop (for the TUI header). 1 on
+ /// the first pass, 2 after one reset, etc.
+ loop_index: u32,
}
enum State {
@@ -94,6 +103,7 @@ impl Run {
)),
}
}
+ let loops_left = cfg.loop_count;
Run {
cfg,
workloads,
@@ -104,6 +114,8 @@ impl Run {
},
state_start: Instant::now(),
report: None,
+ loops_left,
+ loop_index: 1,
}
}
@@ -181,6 +193,19 @@ impl Run {
matches!(self.state, State::Cooldown { .. })
}
+ /// 1-indexed ordinal of the current loop (for the TUI header,
+ /// "loop 2/5"). 1 on the first pass, 2 after one reset, etc. When
+ /// loop_count is 1 (single pass) this is always 1.
+ pub fn loop_index(&self) -> u32 {
+ self.loop_index
+ }
+
+ /// The configured loop count (1 = single pass, 0 = infinite). For
+ /// the TUI header ("loop N/M"). 0 renders as "loop N/∞".
+ pub fn loop_count(&self) -> u32 {
+ self.cfg.loop_count
+ }
+
/// 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> {
@@ -268,7 +293,36 @@ impl Run {
self.current += 1;
}
if self.current >= self.workloads.len() {
- self.state = State::Done;
+ // End of this loop's sweep. Decide whether to loop again.
+ // loops_left == 0 means INFINITE (loop forever); > 1 means
+ // there are more loops to go; == 1 means this was the last.
+ let loop_again = self.loops_left == 0 || self.loops_left > 1;
+ if loop_again {
+ // Decrement (unless infinite — keep 0) + reset to loop start.
+ if self.loops_left > 0 {
+ self.loops_left -= 1;
+ }
+ self.loop_index += 1;
+ self.current = 0;
+ // Skip any leading None entries (defensive — shouldn't
+ // happen since construction skips them, but the same
+ // loop the constructor relies on).
+ while self.current < self.workloads.len() && self.workloads[self.current].is_none() {
+ self.current += 1;
+ }
+ // If the whole workload list was None (shouldn't happen),
+ // fall through to Done.
+ if self.current >= self.workloads.len() {
+ self.state = State::Done;
+ } else {
+ self.state = State::Cooldown {
+ remaining: self.cfg.cool_secs,
+ };
+ self.state_start = Instant::now();
+ }
+ } else {
+ self.state = State::Done;
+ }
} else {
self.state = State::Cooldown {
remaining: self.cfg.cool_secs,
@@ -316,6 +370,8 @@ mod tests {
}
fn start(&mut self) -> io::Result<()> {
self.started = true;
+ self.stopped = false;
+ self.ticks_seen = 0;
Ok(())
}
fn is_running(&mut self) -> bool {
@@ -346,6 +402,7 @@ mod tests {
workloads,
poll_ms: 10,
cool_secs: 0, // no cooldown in tests
+ loop_count: 1,
report_dir: std::path::PathBuf::from("/tmp/opencode/run-tests"),
bin_dir: std::path::PathBuf::from("bin"),
loaded_path: None,
@@ -357,6 +414,7 @@ mod tests {
fn run_with_mocks(cfg: Config, mocks: Vec<MockWorkload>) -> Run {
let workloads: Vec<Option<Box<dyn Workload>>> =
mocks.into_iter().map(|m| Some(Box::new(m) as Box<dyn Workload>)).collect();
+ let loops_left = cfg.loop_count;
Run {
cfg,
workloads,
@@ -365,6 +423,8 @@ mod tests {
state: State::Cooldown { remaining: 0 },
state_start: Instant::now(),
report: None,
+ loops_left,
+ loop_index: 1,
}
}
@@ -443,4 +503,70 @@ mod tests {
let run = Run::new(Config::default());
let _ = run.skipped();
}
+
+ #[test]
+ fn loop_resets_current_and_decrements() {
+ // Two workloads, loop_count = 2 → should run each twice (4 starts
+ // total) before SweepDone.
+ let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
+ let mut cfg = mock_cfg(vec![
+ WorkloadSpec { name: "a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+ WorkloadSpec { name: "b".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+ ]);
+ cfg.loop_count = 2;
+ let mut run = run_with_mocks(cfg, vec![
+ MockWorkload { name: "a".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+ MockWorkload { name: "b".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+ ]);
+ let mut starts = 0;
+ let mut done = false;
+ for _ in 0..40 {
+ match run.tick().unwrap() {
+ TickOutcome::WorkloadStarted { name, .. } => {
+ starts += 1;
+ assert!(name == "a" || name == "b");
+ }
+ TickOutcome::WorkloadFinished { .. } => {}
+ TickOutcome::SweepDone => {
+ done = true;
+ break;
+ }
+ TickOutcome::Idle => {}
+ }
+ }
+ assert!(done, "should reach SweepDone");
+ assert_eq!(starts, 4, "two workloads × two loops = 4 starts");
+ assert_eq!(run.loop_index(), 2, "loop_index should be 2 after one reset");
+ }
+
+ #[test]
+ fn loop_infinite_never_done() {
+ // loop_count = 0 means infinite. With short mocks the sweep would
+ // complete many times; verify it never reaches SweepDone (we'd
+ // be here forever in real life, but we cap the loop + check that
+ // it's still going after several sweep completions).
+ let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
+ let mut cfg = mock_cfg(vec![
+ WorkloadSpec { name: "a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+ ]);
+ cfg.loop_count = 0; // infinite
+ let mut run = run_with_mocks(cfg, vec![
+ MockWorkload { name: "a".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+ ]);
+ let mut starts = 0;
+ let mut sweep_done = false;
+ for _ in 0..100 {
+ match run.tick().unwrap() {
+ TickOutcome::WorkloadStarted { .. } => starts += 1,
+ TickOutcome::SweepDone => {
+ sweep_done = true;
+ break;
+ }
+ _ => {}
+ }
+ }
+ assert!(!sweep_done, "infinite loop should never reach SweepDone");
+ // Should have completed several loops (start count >> 1).
+ assert!(starts > 5, "infinite loop should have started many times, got {starts}");
+ }
}
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index a425e43..b64c53f 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,11 +1,11 @@
-//! ratatui TUI front-end. Consumes `run::Run::tick()` every `poll_ms` and
-//! renders: the current workload + elapsed/remaining, and a per-run
-//! result table after each `WorkloadFinished`. q/Esc quits gracefully
-//! (stops the active workload, flushes a partial report, exits).
+//! ratatui TUI front-end. Three modes:
+//! - **Picker** (home screen): choose Stability / Bench / Custom / q to quit.
+//! - **Editor** (custom preset): toggle workloads, edit duration/cores/
+//! cooldown/loop count, reorder, then Start to launch the sweep.
+//! - **Sweep**: consume `run::Run::tick()` every `poll_ms`, render the
+//! current workload + progress + a results table. q/Esc quits.
//!
//! NO sensor meters — adlermon owns sensors. adlerbench records scores.
-//! The TUI is correspondingly simple: a progress bar for the current
-//! workload + a results table of scores.
use std::io;
use std::time::Duration;
@@ -16,14 +16,415 @@ use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, Cell, Paragraph, Row, Table},
+ widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
};
-use crate::config::Config;
+use crate::config::{Config, WorkloadSpec};
+use crate::presets;
use crate::report;
use crate::run::{Run, TickOutcome};
use crate::workload::{Score, Verdict};
+/// The three top-level modes the TUI cycles through.
+enum Mode {
+ /// Picker home screen — choose a preset or custom.
+ Picker(PickerState),
+ /// Custom preset editor — tune the workload list + params before launch.
+ Editor(EditorState),
+ /// Sweep running — drives Run::tick() + renders progress + results.
+ Sweep(App),
+ /// Sweep done — final render sits until the user presses q.
+ Done(App),
+}
+
+/// Picker screen state: which row is highlighted (0..N).
+struct PickerState {
+ cursor: usize,
+}
+
+/// Custom editor state. Holds a mutable Config the user edits before
+/// launching the sweep. The workload list is the central piece — each
+/// row is a WorkloadSpec the user can toggle, edit, or reorder.
+struct EditorState {
+ cfg: Config,
+ /// Which row is highlighted (index into cfg.workloads).
+ cursor: usize,
+ /// Which field of the highlighted row is selected for editing.
+ /// 0 = name (toggle), 1 = duration, 2 = cores. The global fields
+ /// (cooldown, loop count) are rows below the workload list.
+ field: EditorField,
+ /// True while editing a numeric field (digit keys append, Enter
+ /// commits, Esc cancels).
+ editing: Option<String>,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum EditorField {
+ Name,
+ Duration,
+ Cores,
+ Cooldown,
+ LoopCount,
+}
+
+impl EditorField {
+ /// Render order (top to bottom in the editor).
+ const PER_ROW: [Self; 3] = [Self::Name, Self::Duration, Self::Cores];
+ const GLOBALS: [Self; 2] = [Self::Cooldown, Self::LoopCount];
+}
+
+// ---------------------------------------------------------------------------
+// Picker
+// ---------------------------------------------------------------------------
+
+const PICKER_ROWS: &[(&str, &str)] = &[
+ ("Stability", "stress-ng + y-cruncher SSE/AVX2, 5 min each, looped 1 hr"),
+ ("Bench", "7z + y-cruncher SSE/AVX2, 60 s each, single pass"),
+ ("Core-cycle", "single-core per-core stability, 16 cores x 2 passes"),
+ ("Custom", "tune the workload list + params yourself"),
+];
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
+/// adlermon). The mode loop: draw → poll events (poll_ms timeout) →
+/// dispatch by mode. q/Esc at the picker quits; during the sweep it
+/// cancels back to the picker (if the sweep was launched from there) or
+/// quits (if launched from --preset/--tui directly).
+pub fn run_tui(cfg: Config) {
+ let mut terminal = ratatui::init();
+ let mut mode = Mode::Picker(PickerState { cursor: 0 });
+ // The cfg arg is the fallback for "Custom" (starts from whatever was
+ // loaded from the conf file). Presets replace it wholesale.
+ let default_cfg = cfg;
+
+ loop {
+ terminal.draw(|f| draw(f, &mode)).expect("draw failed");
+ let poll_ms = match &mode {
+ Mode::Sweep(app) | Mode::Done(app) => app.run.poll_ms(),
+ _ => 100, // picker + editor don't need fast refresh
+ };
+ if !event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
+ continue;
+ }
+ let event = event::read();
+ let Ok(Event::Key(key)) = event else {
+ continue;
+ };
+ if key.kind != crossterm::event::KeyEventKind::Press {
+ continue;
+ }
+ match &mut mode {
+ Mode::Picker(state) => {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => state.cursor = state.cursor.saturating_sub(1),
+ KeyCode::Down | KeyCode::Char('j') => {
+ state.cursor = (state.cursor + 1).min(PICKER_ROWS.len() - 1)
+ }
+ KeyCode::Enter => {
+ match state.cursor {
+ 0 => mode = Mode::Sweep(App::new(presets::stability())),
+ 1 => mode = Mode::Sweep(App::new(presets::bench())),
+ 2 => mode = Mode::Sweep(App::new(presets::stability_core_cycle(60))),
+ 3 => mode = Mode::Editor(EditorState::from_cfg(default_cfg.clone())),
+ _ => {}
+ }
+ }
+ KeyCode::Char('q') | KeyCode::Esc => break,
+ _ => {}
+ }
+ }
+ Mode::Editor(state) => {
+ match editor_key(state, key.code) {
+ EditorAction::Start => mode = Mode::Sweep(App::new(state.cfg.clone())),
+ EditorAction::Cancel => mode = Mode::Picker(PickerState { cursor: 3 }),
+ EditorAction::None => {}
+ }
+ }
+ Mode::Sweep(app) => {
+ // Drive one tick before polling events so the sweep
+ // advances even while we're waiting for input.
+ let outcome = match app.tick() {
+ Ok(o) => o,
+ Err(e) => {
+ eprintln!("run error: {e}");
+ break;
+ }
+ };
+ let done = matches!(outcome, TickOutcome::SweepDone);
+ if done {
+ // Move the App out of the Sweep mode into Done. We
+ // can't do this while `app` is borrowed, so use a
+ // sentinel — replace with empty + push the real one
+ // into Done below.
+ let finished = std::mem::replace(app, App::empty());
+ mode = Mode::Done(finished);
+ continue;
+ }
+ match key.code {
+ KeyCode::Char('q') | KeyCode::Esc => break,
+ _ => {}
+ }
+ }
+ Mode::Done(app) => match key.code {
+ KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => {
+ // Print the summary + exit.
+ print_summary(app);
+ break;
+ }
+ _ => {}
+ },
+ }
+ }
+
+ ratatui::restore();
+ // If we exited from Sweep/Done, print the post-TUI summary.
+ if let Mode::Done(app) = &mode {
+ print_summary(app);
+ } else if let Mode::Sweep(app) = &mode {
+ print_summary(app);
+ }
+}
+
+fn print_summary(app: &App) {
+ if app.results.is_empty() {
+ if app.quit {
+ println!("quit before any workload finished");
+ }
+ return;
+ }
+ println!();
+ for r in &app.results {
+ println!(
+ "[{}] {} {:<8} {:<24} verdict={:?}",
+ r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
+ );
+ println!(" report: {}", r.report_path);
+ }
+ println!("sweep complete: {} workloads", app.results.len());
+}
+
+// ---------------------------------------------------------------------------
+// Editor
+// ---------------------------------------------------------------------------
+
+#[derive(PartialEq, Eq)]
+enum EditorAction {
+ None,
+ Start,
+ Cancel,
+}
+
+impl EditorState {
+ fn from_cfg(cfg: Config) -> Self {
+ EditorState {
+ cfg,
+ cursor: 0,
+ field: EditorField::Name,
+ editing: None,
+ }
+ }
+}
+
+/// Handle one key in the editor. Returns whether to start the sweep,
+/// cancel back to the picker, or do nothing.
+fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
+ // If we're editing a numeric field, digit keys append, Enter commits,
+ // Esc cancels, Backspace pops.
+ if let Some(buf) = &mut state.editing {
+ match key {
+ KeyCode::Enter => {
+ let raw = std::mem::take(&mut state.editing).unwrap_or_default();
+ apply_editor_edit(state, &raw);
+ }
+ KeyCode::Esc => {
+ state.editing = None;
+ }
+ KeyCode::Backspace => {
+ buf.pop();
+ }
+ KeyCode::Char(c) if c.is_ascii_digit() => {
+ buf.push(c);
+ }
+ _ => {}
+ }
+ return EditorAction::None;
+ }
+
+ // Not editing — navigate fields + rows.
+ match key {
+ KeyCode::Up => {
+ editor_move(state, -1);
+ }
+ KeyCode::Down => {
+ editor_move(state, 1);
+ }
+ KeyCode::Left | KeyCode::Right => {
+ // Cycle field within the current row (only for workload rows).
+ if state.cursor < state.cfg.workloads.len() {
+ let idx = EditorField::PER_ROW.iter().position(|f| *f == state.field).unwrap_or(0);
+ let dir = if matches!(key, KeyCode::Left) { -1i32 } else { 1 };
+ let n = EditorField::PER_ROW.len() as i32;
+ let next = ((idx as i32 + dir).rem_euclid(n)) as usize;
+ state.field = EditorField::PER_ROW[next];
+ }
+ }
+ KeyCode::Enter => {
+ // Enter on a workload row's Name field toggles inclusion (cycles
+ // through available workload names). On Duration/Cores/Cooldown/
+ // LoopCount it starts numeric editing. On a "Start" pseudo-row
+ // (cursor == workloads.len() + globals.len()) it launches.
+ let total_rows = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
+ if state.cursor == total_rows - 1 {
+ return EditorAction::Start;
+ }
+ match state.field {
+ EditorField::Name => {
+ // Toggle to the next available workload name.
+ if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+ let idx = presets::AVAILABLE_WORKLOADS
+ .iter()
+ .position(|n| *n == w.name)
+ .unwrap_or(0);
+ let next = (idx + 1) % presets::AVAILABLE_WORKLOADS.len();
+ w.name = presets::AVAILABLE_WORKLOADS[next].to_string();
+ }
+ }
+ EditorField::Duration | EditorField::Cores | EditorField::Cooldown | EditorField::LoopCount => {
+ let current = editor_current_value(state);
+ state.editing = Some(current);
+ }
+ }
+ }
+ // 'd' deletes the current workload row (for the custom editor's
+ // "remove" action). Bound to a letter so it doesn't collide with
+ // numeric editing.
+ KeyCode::Char('d') if state.cursor < state.cfg.workloads.len() => {
+ state.cfg.workloads.remove(state.cursor);
+ if state.cursor >= state.cfg.workloads.len() && !state.cfg.workloads.is_empty() {
+ state.cursor = state.cfg.workloads.len() - 1;
+ }
+ }
+ // 'a' adds a new workload row at the end (defaulting to the first
+ // available name).
+ KeyCode::Char('a') => {
+ state.cfg.workloads.push(WorkloadSpec {
+ name: presets::AVAILABLE_WORKLOADS[0].to_string(),
+ duration_secs: 60,
+ cores: "0-15".to_string(),
+ });
+ state.cursor = state.cfg.workloads.len() - 1;
+ state.field = EditorField::Name;
+ }
+ // Move the current workload row up/down (reorder).
+ KeyCode::Char('k') if state.cursor < state.cfg.workloads.len() && state.cursor > 0 => {
+ state.cfg.workloads.swap(state.cursor, state.cursor - 1);
+ state.cursor -= 1;
+ }
+ KeyCode::Char('j') if state.cursor < state.cfg.workloads.len()
+ && state.cursor + 1 < state.cfg.workloads.len() =>
+ {
+ state.cfg.workloads.swap(state.cursor, state.cursor + 1);
+ state.cursor += 1;
+ }
+ KeyCode::Esc => return EditorAction::Cancel,
+ _ => {}
+ }
+ EditorAction::None
+}
+
+/// Move the editor cursor by `dir` rows (clamped). Fields reset to Name
+/// when entering a workload row, or to the appropriate global field when
+/// entering a global row.
+fn editor_move(state: &mut EditorState, dir: i32) {
+ let total = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
+ let next = (state.cursor as i32 + dir).clamp(0, (total as i32) - 1) as usize;
+ state.cursor = next;
+ // Reset field to the first valid one for the new row.
+ let workloads_n = state.cfg.workloads.len();
+ if next < workloads_n {
+ state.field = EditorField::Name;
+ } else if next < workloads_n + EditorField::GLOBALS.len() {
+ state.field = EditorField::GLOBALS[next - workloads_n];
+ } else {
+ // Start row — no field.
+ state.field = EditorField::Name;
+ }
+}
+
+/// The current field's value as a string (for the edit buffer's initial
+/// content when the user presses Enter to edit).
+fn editor_current_value(state: &EditorState) -> String {
+ match state.field {
+ EditorField::Name => state
+ .cfg
+ .workloads
+ .get(state.cursor)
+ .map(|w| w.name.clone())
+ .unwrap_or_default(),
+ EditorField::Duration => state
+ .cfg
+ .workloads
+ .get(state.cursor)
+ .map(|w| w.duration_secs.to_string())
+ .unwrap_or_default(),
+ EditorField::Cores => state
+ .cfg
+ .workloads
+ .get(state.cursor)
+ .map(|w| w.cores.clone())
+ .unwrap_or_default(),
+ EditorField::Cooldown => state.cfg.cool_secs.to_string(),
+ EditorField::LoopCount => state.cfg.loop_count.to_string(),
+ }
+}
+
+/// Apply a committed edit buffer to the current field.
+fn apply_editor_edit(state: &mut EditorState, raw: &str) {
+ let raw = raw.trim();
+ match state.field {
+ EditorField::Name => {
+ if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+ w.name = raw.to_string();
+ }
+ }
+ EditorField::Duration => {
+ if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+ if let Ok(v) = raw.parse::<u64>() {
+ w.duration_secs = v.max(1);
+ }
+ }
+ }
+ EditorField::Cores => {
+ if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+ // Accept a cores range like "0-11" or a single number "3".
+ // Don't validate beyond non-empty — the workload impls
+ // handle bad ranges gracefully.
+ if !raw.is_empty() {
+ w.cores = raw.to_string();
+ }
+ }
+ }
+ EditorField::Cooldown => {
+ if let Ok(v) = raw.parse::<u64>() {
+ state.cfg.cool_secs = v;
+ }
+ }
+ EditorField::LoopCount => {
+ if let Ok(v) = raw.parse::<u32>() {
+ state.cfg.loop_count = v;
+ }
+ }
+ }
+ state.editing = None;
+}
+
+// ---------------------------------------------------------------------------
+// Sweep (App — preserved from the prior session's ui.rs)
+// ---------------------------------------------------------------------------
+
/// One row in the results table, captured from each `WorkloadFinished`.
#[derive(Clone)]
struct ResultRow {
@@ -39,12 +440,7 @@ struct ResultRow {
/// headless mode (tick every poll_ms); the TUI just renders between ticks.
pub struct App {
run: Run,
- /// Finished-workload rows, in completion order.
results: Vec<ResultRow>,
- /// True when the user asked to quit (q/Esc). The run loop checks this
- /// after each tick + exits cleanly (the run's drop stops any active
- /// workload via its `Workload::stop()` — but we don't rely on drop;
- /// see `cancel_active`).
quit: bool,
}
@@ -57,9 +453,17 @@ impl App {
}
}
+ /// An empty App for the Done state (no run, no results) — used as a
+ /// placeholder when we need to move the real App out of a Mode.
+ pub fn empty() -> Self {
+ App {
+ run: Run::new(Config::default()),
+ results: Vec::new(),
+ quit: true,
+ }
+ }
+
/// Drive one tick + capture any WorkloadFinished into the results vec.
- /// Returns the outcome so the caller can also react (e.g. print a
- /// final summary on SweepDone).
fn tick(&mut self) -> io::Result<TickOutcome> {
let outcome = self.run.tick()?;
if let TickOutcome::WorkloadFinished {
@@ -83,125 +487,201 @@ impl App {
}
}
-/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
-/// adlermon). The loop: tick → draw → poll events (poll_ms timeout) →
-/// handle q/Esc. On quit, the active workload (if any) is stopped + its
-/// report flushed with a partial state; the run's own drop wouldn't do
-/// that, so we do it explicitly before restore().
-pub fn run_tui(cfg: Config) {
- let mut terminal = ratatui::init();
- let mut app = App::new(cfg);
+// ---------------------------------------------------------------------------
+// Draw dispatch
+// ---------------------------------------------------------------------------
- // Warn about skipped workloads up front (printed to stderr before the
- // TUI takes over the screen — same as headless mode).
- for (name, why) in app.run.skipped() {
- eprintln!("skipped: {name} ({why})");
+fn draw(f: &mut Frame, mode: &Mode) {
+ match mode {
+ Mode::Picker(state) => draw_picker(f, state),
+ Mode::Editor(state) => draw_editor(f, state),
+ Mode::Sweep(app) => draw_sweep(f, app),
+ Mode::Done(app) => draw_sweep(f, app),
}
+}
- let poll_ms = app.run.poll_ms();
- loop {
- // Drive the state machine + capture any finished result.
- let outcome = match app.tick() {
- Ok(o) => o,
- Err(e) => {
- eprintln!("run error: {e}");
- break;
- }
- };
- // Render the current state.
- terminal.draw(|f| draw(f, &app)).expect("draw failed");
+fn draw_picker(f: &mut Frame, state: &PickerState) {
+ let area = f.area();
+ let items: Vec<ListItem> = PICKER_ROWS
+ .iter()
+ .enumerate()
+ .map(|(i, (name, desc))| {
+ let cursor = if i == state.cursor { ">" } else { " " };
+ let style = if i == state.cursor {
+ Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)
+ } else {
+ Style::default()
+ };
+ ListItem::new(Line::from(vec![
+ Span::styled(format!("{cursor} "), style),
+ Span::styled(format!("{name:<14}"), style),
+ Span::raw(" "),
+ Span::styled(*desc, Style::default().fg(Color::DarkGray)),
+ ]))
+ })
+ .collect();
+ let list = List::new(items)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray))
+ .title(Span::styled(" adlerbench — choose a sweep ", Style::default().fg(Color::Cyan))),
+ );
+ f.render_widget(list, area);
+ // Footer with keybinds.
+ let footer = Line::from(vec![
+ Span::styled(
+ "↑↓ select Enter start q quit",
+ Style::default().fg(Color::DarkGray),
+ ),
+ ]);
+ let footer_area = Rect { y: area.bottom().saturating_sub(1), height: 1, ..area };
+ f.render_widget(Paragraph::new(footer), footer_area);
+}
- // SweepDone + a final render → stop the loop so the summary prints.
- if matches!(outcome, TickOutcome::SweepDone) {
- break;
- }
+fn draw_editor(f: &mut Frame, state: &EditorState) {
+ let area = f.area();
+ // Vertical: header(1) + workload list(flex) + globals(3) + start(1) + footer(1).
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(1),
+ Constraint::Min(5),
+ Constraint::Length(3),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .split(area);
- // Poll for input (timeout = poll_ms so we tick at the same cadence
- // as the run loop — same pattern as adlermon).
- if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
- if let Ok(Event::Key(key)) = event::read() {
- // adlermon pattern: only react on Press (not Release).
- if key.kind != crossterm::event::KeyEventKind::Press {
- // skip — but we've already consumed the poll timeout,
- // so fall through to the next tick.
+ // Header.
+ f.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ "adlerbench — custom sweep editor",
+ Style::default().add_modifier(Modifier::BOLD),
+ ))),
+ chunks[0],
+ );
+
+ // Workload list (each row: name | duration | cores).
+ let rows: Vec<Row> = state
+ .cfg
+ .workloads
+ .iter()
+ .enumerate()
+ .map(|(i, w)| {
+ let cursor = if i == state.cursor { ">" } else { " " };
+ let row_style = if i == state.cursor {
+ Style::default().add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ };
+ // Highlight the active field within the row.
+ let cell = |field: EditorField, text: String| -> Cell {
+ if i == state.cursor && state.field == field {
+ let editing = state.editing.as_deref().unwrap_or(&text);
+ Cell::from(Span::styled(
+ format!("[{editing}]"),
+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+ ))
} else {
- match key.code {
- KeyCode::Char('q') | KeyCode::Esc => {
- app.quit = true;
- break;
- }
- _ => {}
- }
+ Cell::from(Span::styled(text, row_style))
}
- }
- }
- }
-
- // Graceful cancel: stop the active workload (if any) so its report
- // gets flushed with a Stopped verdict rather than vanishing. The run
- // loop's finish_current_workload does this on the next tick, but we
- // don't want to tick again after quit — call into the run to stop +
- // wait + flush directly. We do this by ticking once more; the run's
- // tick() will see the workload still running + the duration not
- // elapsed, so it returns Idle — which doesn't help. Instead, we
- // leave it to the Drop: Run doesn't impl Drop, so the Child inside
- // SpawnedWorkload gets killed by std's process::Child::drop (which
- // sends SIGKILL on Unix). The report file will have whatever was
- // flushed last (verdict=null, score=null — a valid partial JSON).
- // This is acceptable for a user-initiated quit; the headless mode
- // has the same behavior on Ctrl-C.
-
- ratatui::restore();
-
- // After the TUI exits, print a one-line summary per finished workload
- // (same shape as headless mode) so the terminal has a record.
- if !app.results.is_empty() {
- println!();
- for r in &app.results {
- println!(
- "[{}] {} {:<8} {:<24} verdict={:?}",
- r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
- );
- println!(" report: {}", r.report_path);
- }
- println!("sweep complete: {} workloads", app.results.len());
- } else if app.quit {
- println!("quit before any workload finished");
- }
-}
-
-/// One-line score formatter for the post-TUI summary (shorter than
-/// main.rs's `format_score` — fits one table cell).
-fn format_score_short(s: &Score) -> String {
- match s {
- Score::None => "(stability only)".to_string(),
- Score::SevenZip { rating_mips, .. } => format!("{rating_mips} MIPS"),
- Score::YCruncher { tests } => {
- if tests.is_empty() {
- "(no tests)".to_string()
- } else if tests.len() == 1 {
- let t = &tests[0];
- format!("{} {:.2} Gb/s", t.tag, t.bits_per_sec / 1e9)
- } else {
- format!("{} tests", tests.len())
- }
- }
+ };
+ Row::new(vec![
+ Cell::from(Span::styled(format!("{cursor} "), row_style)),
+ cell(EditorField::Name, w.name.clone()),
+ cell(EditorField::Duration, format!("{}s", w.duration_secs)),
+ cell(EditorField::Cores, w.cores.clone()),
+ ])
+ })
+ .collect();
+ let header = Row::new(vec![
+ Cell::from(Span::styled(" ", Style::default())),
+ Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
+ Cell::from(Span::styled("dur", Style::default().fg(Color::Cyan))),
+ Cell::from(Span::styled("cores", Style::default().fg(Color::Cyan))),
+ ]);
+ let table = Table::new(
+ rows,
+ [
+ Constraint::Length(2),
+ Constraint::Min(18),
+ Constraint::Length(8),
+ Constraint::Length(10),
+ ],
+ )
+ .header(header)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray))
+ .title(Span::styled(" workloads ", Style::default().fg(Color::Cyan))),
+ );
+ f.render_widget(table, chunks[1]);
+
+ // Globals (cooldown + loop count).
+ let global_cell = |field: EditorField, label: &str, value: String| -> Line {
+ let cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.iter().position(|f| *f == field).unwrap() {
+ ">"
+ } else {
+ " "
+ };
+ let val_span = if state.cursor >= state.cfg.workloads.len()
+ && state.field == field
+ {
+ let editing = state.editing.as_deref().unwrap_or(&value);
+ Span::styled(format!("[{editing}]"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
+ } else {
+ Span::raw(value)
+ };
+ Line::from(vec![
+ Span::raw(format!("{cursor} ")),
+ Span::styled(format!("{label:<12}"), Style::default().fg(Color::Cyan)),
+ val_span,
+ ])
+ };
+ let globals = [global_cell(EditorField::Cooldown, "cooldown", format!("{}s", state.cfg.cool_secs)),
+ global_cell(EditorField::LoopCount, "loop count", state.cfg.loop_count.to_string())];
+ for (i, line) in globals.iter().enumerate() {
+ f.render_widget(
+ Paragraph::new(line.clone()),
+ Rect { x: chunks[2].x, y: chunks[2].y + i as u16, width: chunks[2].width, height: 1 },
+ );
}
-}
-/// Verdict → (short label, color) for the results table + current panel.
-fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
- match v {
- Verdict::Clean => ("CLEAN", Color::Green),
- Verdict::Failed(_) => ("FAILED", Color::Red),
- Verdict::Stopped => ("STOPPED", Color::Yellow),
- Verdict::Error(_) => ("ERROR", Color::Red),
- }
+ // Start row.
+ let start_cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
+ ">"
+ } else {
+ " "
+ };
+ let start_style = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
+ Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+ f.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{start_cursor} "), start_style),
+ Span::styled("Start sweep", start_style),
+ ])),
+ chunks[3],
+ );
+
+ // Footer with keybinds.
+ f.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(
+ "↑↓ row ←→ field Enter edit/toggle a add d del j/k move Esc back",
+ Style::default().fg(Color::DarkGray),
+ ),
+ ])),
+ chunks[4],
+ );
}
-pub fn draw(f: &mut Frame, app: &App) {
+fn draw_sweep(f: &mut Frame, app: &App) {
let area = f.area();
- // Vertical: header (1) + current panel (flex) + results table (flex) + footer (1).
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -221,17 +701,27 @@ pub fn draw(f: &mut Frame, app: &App) {
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let total = app.run.total_workloads();
let current = app.run.current_ordinal().min(total);
+ let loop_idx = app.run.loop_index();
+ let loop_total = app.run.loop_count();
+ let loop_str = if loop_total == 0 {
+ format!("loop {loop_idx}/∞")
+ } else {
+ format!("loop {loop_idx}/{loop_total}")
+ };
let title = if app.run.is_done() {
format!("adlerbench — sweep complete: {} workloads", app.results.len())
} else if current == 0 {
- format!("adlerbench — starting (0/{total})")
+ format!("adlerbench — starting (0/{total}) {loop_str}")
} else {
- format!("adlerbench — workload {current}/{total}")
+ format!("adlerbench — workload {current}/{total} {loop_str}")
};
- let line = Line::from(vec![
- Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
- ]);
- f.render_widget(Paragraph::new(line), area);
+ f.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ title,
+ Style::default().add_modifier(Modifier::BOLD),
+ ))),
+ area,
+ );
}
fn draw_current(f: &mut Frame, area: Rect, app: &App) {
@@ -281,8 +771,6 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
let inner = block.inner(area);
f.render_widget(block, area);
- // Two rows: line 1 = name + isa + state; line 2 = params + time;
- // line 3 = progress bar.
let lines = [
Line::from(vec![
Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
@@ -315,12 +803,10 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
}
}
-/// Build a one-line progress bar: `[####······] 42%`. Uses block chars for
-/// fill + middle-dot for empty. Width is the interior cell count.
+/// Build a one-line progress bar: `[####······] 42%`.
fn progress_bar(ratio: f64, width: u16) -> Line<'static> {
let ratio = ratio.clamp(0.0, 1.0);
let label = format!("{:>3.0}%", ratio * 100.0);
- // Reserve 2 for [ ] + 1 space + label width (4 chars for "100%").
let bar_w = width.saturating_sub(2 + 1 + 4) as usize;
let fill = (ratio * bar_w as f64).round() as usize;
let fill = fill.min(bar_w);
@@ -354,7 +840,6 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
return;
}
- // Table columns: # | workload | isa | verdict | score.
let header_cells = [
Cell::from(Span::styled("#", Style::default().fg(Color::Cyan))),
Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
@@ -362,7 +847,7 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
Cell::from(Span::styled("verdict", Style::default().fg(Color::Cyan))),
Cell::from(Span::styled("score", Style::default().fg(Color::Cyan))),
];
- let header = Row::new(header_cells).height(1).bottom_margin(0);
+ let header = Row::new(header_cells).height(1);
let rows = app.results.iter().map(|r| {
let (vlabel, vcolor) = verdict_badge(&r.verdict);
@@ -375,15 +860,57 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
])
});
- let table = Table::new(rows, [Constraint::Length(3), Constraint::Min(18), Constraint::Length(8), Constraint::Length(10), Constraint::Min(20)])
- .header(header)
- .block(block);
+ let table = Table::new(
+ rows,
+ [
+ Constraint::Length(3),
+ Constraint::Min(18),
+ Constraint::Length(8),
+ Constraint::Length(10),
+ Constraint::Min(20),
+ ],
+ )
+ .header(header)
+ .block(block);
f.render_widget(table, area);
}
fn draw_footer(f: &mut Frame, area: Rect, _app: &App) {
- let line = Line::from(vec![
- Span::styled("q: quit Esc: quit", Style::default().fg(Color::DarkGray)),
- ]);
- f.render_widget(Paragraph::new(line), area);
+ f.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ "q: quit Esc: quit",
+ Style::default().fg(Color::DarkGray),
+ ))),
+ area,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn format_score_short(s: &Score) -> String {
+ match s {
+ Score::None => "(stability only)".to_string(),
+ Score::SevenZip { rating_mips, .. } => format!("{rating_mips} MIPS"),
+ Score::YCruncher { tests } => {
+ if tests.is_empty() {
+ "(no tests)".to_string()
+ } else if tests.len() == 1 {
+ let t = &tests[0];
+ format!("{} {:.2} Gb/s", t.tag, t.bits_per_sec / 1e9)
+ } else {
+ format!("{} tests", tests.len())
+ }
+ }
+ }
+}
+
+fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
+ match v {
+ Verdict::Clean => ("CLEAN", Color::Green),
+ Verdict::Failed(_) => ("FAILED", Color::Red),
+ Verdict::Stopped => ("STOPPED", Color::Yellow),
+ Verdict::Error(_) => ("ERROR", Color::Red),
+ }
}
\ No newline at end of file
diff --git a/src/workload.rs b/src/workload.rs
index 0441c61..92488c0 100644
--- a/src/workload.rs
+++ b/src/workload.rs
@@ -386,7 +386,11 @@ impl Workload for YCruncher {
format!("y-cruncher variant path canonicalize failed: {e}"),
)
})?;
- let mut cmd = Command::new(&variant_path);
+ // Wrap in `taskset -c <cores>` when the cores range is a subset
+ // (not all 0-15). y-cruncher spawns 16 threads by default + has no
+ // CLI flag for affinity; taskset pins it at the kernel level. The
+ // full-range case skips the wrapper (no point constraining to all).
+ let mut cmd = taskset_command(&self.cores, &variant_path);
cmd.arg("skip-warnings")
.arg("stress")
// y-cruncher expects `-D:3` as ONE arg (not `-D:` + `3`).
@@ -500,7 +504,12 @@ impl Workload for SevenZip {
)
}
fn start(&mut self) -> io::Result<()> {
- let mut cmd = Command::new("7z");
+ // Wrap in `taskset -c <cores>` when the cores range is a subset
+ // (not all 0-15). 7z's `-mmt<N>` only sets THREAD count, not
+ // affinity — taskset actually pins the process to the range. For
+ // the full-range case we skip the wrapper (no point constraining
+ // to all) + keep `-mmt<N>` for thread count.
+ let mut cmd = taskset_command(&self.cores, "7z");
cmd.arg("b");
if let Some(n) = self.thread_count() {
cmd.arg(format!("-mmt{n}"));
@@ -546,6 +555,25 @@ impl Workload for SevenZip {
// Public constructor: map a WorkloadSpec name to an impl
// ---------------------------------------------------------------------------
+/// Build a `Command` for `program`, wrapped in `taskset -c <cores>` when
+/// `cores` is a subset of the available CPUs (not the full "0-15" range).
+/// The full-range case skips the wrapper — there's no point constraining
+/// to all CPUs. Used by YCruncher + SevenZip (StressNg has its own
+/// `--taskset` flag, so it doesn't go through here). Returns a `Command`
+/// pointing at either `taskset` (with program as an arg) or `program`
+/// directly. `program` accepts `OsStr` so it works with both `&str`
+/// (7z) and `&PathBuf` (y-cruncher's canonicalized variant path).
+fn taskset_command<P: AsRef<std::ffi::OsStr>>(cores: &str, program: P) -> Command {
+ // Full-range patterns that mean "all CPUs" — skip the wrapper.
+ let is_full = matches!(cores.trim(), "0-15" | "all" | "");
+ if is_full {
+ return Command::new(program);
+ }
+ let mut cmd = Command::new("taskset");
+ cmd.arg("-c").arg(cores).arg(program);
+ cmd
+}
+
/// 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).
@@ -888,4 +916,31 @@ Test Finished. Waiting for threads to terminate...
assert_eq!(strip_ansi("plain text"), "plain text");
assert_eq!(strip_ansi("with\rcarriage"), "with\ncarriage");
}
+
+ #[test]
+ fn taskset_command_wraps_subsets() {
+ // Full range → bare program, no taskset wrapper.
+ let cmd = taskset_command("0-15", "/bin/true");
+ let prog = cmd.get_program();
+ assert_eq!(prog, std::path::Path::new("/bin/true"));
+ // Subset → taskset -c <cores> <program>.
+ let cmd = taskset_command("0", "/bin/true");
+ assert_eq!(cmd.get_program(), std::path::Path::new("taskset"));
+ let args: Vec<_> = cmd.get_args().collect();
+ assert_eq!(args, [std::ffi::OsStr::new("-c"), std::ffi::OsStr::new("0"), std::ffi::OsStr::new("/bin/true")]);
+ // Also accepts a PathBuf (y-cruncher's canonicalized variant path).
+ let path = std::path::PathBuf::from("/bin/true");
+ let cmd = taskset_command("3", &path);
+ assert_eq!(cmd.get_program(), std::path::Path::new("taskset"));
+ let args: Vec<_> = cmd.get_args().collect();
+ assert_eq!(args.last(), Some(&std::ffi::OsStr::new("/bin/true")));
+ }
+
+ #[test]
+ fn taskset_command_skips_wrapper_for_all_and_empty() {
+ for full in ["0-15", "all", ""] {
+ let cmd = taskset_command(full, "/bin/true");
+ assert_eq!(cmd.get_program(), std::path::Path::new("/bin/true"), "full={full:?} should skip wrapper");
+ }
+ }
}
\ No newline at end of file
Add TUI picker (Stability/Bench/Core-cycle/Custom) + loop support
- src/presets.rs: three preset builders + AVAILABLE_WORKLOADS list. stability() = 3 ISA rails, 5 min each, looped 5x for ~1 hr. stability_core_cycle(per_core_secs) = same 3 workloads on one core at a time, cycling all 16 logical cores twice (tests peak single-core clocks on chips with per-core load-based multipliers). bench() = 3 scored workloads, 60s each, single pass. - src/config.rs: added loop_count field (1 = single pass, 0 = infinite). Config now derives Clone (the editor needs to clone it on Start). - src/run.rs: loop support. Run tracks loops_left + loop_index; on SweepDone with loops remaining, resets current to 0 + re-enters Cooldown. loop_index() + loop_count() accessors for the TUI header. Two new tests: loop_resets_current_and_decrements, loop_infinite_never_done. - src/workload.rs: YCruncher + SevenZip now honor the cores field via taskset -c <range> wrapper (full-range 0-15/all skips the wrapper). taskset_command() helper accepts OsStr so it works with both &str (7z) and &PathBuf (y-cruncher's canonicalized variant path). StressNg already used --taskset internally. Single-core pinning (cores=0) now works for all three spawned impls — the foundation for the core-cycle preset. - src/ui.rs: rewritten with three modes. Picker (home screen) lists Stability / Bench / Core-cycle / Custom with j/k + arrow nav + Enter. Editor (custom preset) edits the workload list (toggle name, edit duration/cores, add/del rows, j/k reorder), globals (cooldown, loop count), then Start to launch. Sweep mode preserved from prior session with loop display in the header (loop N/M or loop N/∞). Esc cancels editor back to picker; q quits the whole TUI from any mode. - src/main.rs: --preset stability|bench|core-cycle arg for scripting (skips the picker, launches the preset sweep directly). Default mode is still the TUI picker. - Verified live via forkpty recipe: picker renders + j/k nav + Enter on Custom opens the editor (all editor strings FOUND in the capture). 52/52 tests; clippy clean.
b79a210fe72a81344beed9c044eae7884ce775ad
josie <administrator@josie-c.com> · 2026-09-01T22:02 ·
browse files at this commit
parents:
8db8144