Skip to main content

silicera_lab/
ui.rs

1//! Terminal lab UI — systems aesthetic, real measurements only.
2
3use std::io::{self, Write};
4use std::path::Path;
5use std::time::Duration;
6
7use crossterm::event::{self, Event, KeyCode, KeyModifiers};
8use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
9use crossterm::ExecutableCommand;
10use ratatui::backend::CrosstermBackend;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
15use ratatui::Terminal;
16use serde::Serialize;
17use silicera::brand::{BrandInfo, BRAND_LINE, NAME, RELEASE_LINE, VERSION};
18use silicera::hardware::HardwareInfo;
19use silicera::measure::{MeasurementConfig, MeasurementEngine, MeasurementSummary};
20
21use crate::bench::{CacheTarget, IntegerBench, MemoryBench};
22
23/// Row collected from a live measurement.
24#[derive(Clone, Serialize)]
25struct LiveRow {
26    name: String,
27    median_ns: f64,
28    cv: f64,
29    stability: String,
30}
31
32/// Exportable lab session summary (measured rows only).
33#[derive(Debug, Clone, Serialize)]
34pub struct LabSessionSummary {
35    /// Product name.
36    pub product: String,
37    /// Silicera version.
38    pub silicera_version: String,
39    /// Phase marker.
40    pub phase: String,
41    /// Brand line.
42    pub brand_line: String,
43    /// Host CPU brand string.
44    pub host_brand: String,
45    /// Fingerprint when supported.
46    pub fingerprint: Option<String>,
47    /// RFC3339 capture time.
48    pub captured_at: String,
49    /// Measured workloads from the last pass.
50    pub rows: Vec<LabSessionRow>,
51    /// Caveats.
52    pub caveats: Vec<String>,
53}
54
55/// One measured row in a lab session export.
56#[derive(Debug, Clone, Serialize)]
57pub struct LabSessionRow {
58    /// Workload name.
59    pub name: String,
60    /// Median nanoseconds.
61    pub median_ns: f64,
62    /// Coefficient of variation.
63    pub cv: f64,
64    /// Stability label.
65    pub stability: String,
66}
67
68impl LabSessionSummary {
69    /// Build from host + measured session rows.
70    pub fn from_rows(info: &HardwareInfo, rows: Vec<LabSessionRow>) -> Self {
71        let brand = BrandInfo::current();
72        Self {
73            product: brand.name.into(),
74            silicera_version: brand.version.into(),
75            phase: brand.phase.into(),
76            brand_line: brand.brand_line.into(),
77            host_brand: info.brand.clone(),
78            fingerprint: info.fingerprint.as_ref().map(|f| f.value.clone()),
79            captured_at: chrono::Utc::now().to_rfc3339(),
80            rows,
81            caveats: vec![
82                "Lab session exports contain real medians only — never fabricated speedups.".into(),
83                format!("{NAME}; not affiliated with AMD."),
84            ],
85        }
86    }
87
88    /// Pretty JSON.
89    pub fn to_json_pretty(&self) -> anyhow::Result<String> {
90        Ok(serde_json::to_string_pretty(self)?)
91    }
92
93    /// Write to path.
94    pub fn write_to(&self, path: &Path) -> anyhow::Result<()> {
95        if let Some(parent) = path.parent() {
96            std::fs::create_dir_all(parent)?;
97        }
98        std::fs::write(path, self.to_json_pretty()?)?;
99        Ok(())
100    }
101}
102
103/// Coefficient of variation from a measurement summary (shared helper).
104pub fn summary_cv(s: &MeasurementSummary) -> f64 {
105    if s.mean_ns > 0.0 {
106        s.stddev_ns / s.mean_ns
107    } else {
108        0.0
109    }
110}
111
112/// Run the interactive lab UI. Returns when the user quits.
113pub fn run_lab_ui(info: &HardwareInfo) -> anyhow::Result<()> {
114    let mut stdout = io::stdout();
115    enable_raw_mode()?;
116    stdout.execute(EnterAlternateScreen)?;
117    let backend = CrosstermBackend::new(stdout);
118    let mut terminal = Terminal::new(backend)?;
119
120    let mut rows: Vec<LiveRow> = Vec::new();
121    let mut status =
122        "press R to measure · E to export session JSON · Q to quit".to_string();
123    let mut measuring = false;
124
125    let result = loop {
126        terminal.draw(|f| draw(f, info, &rows, &status, measuring))?;
127
128        if event::poll(Duration::from_millis(200))? {
129            if let Event::Key(key) = event::read()? {
130                match key.code {
131                    KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
132                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
133                        break Ok(())
134                    }
135                    KeyCode::Char('e') | KeyCode::Char('E') if !measuring => {
136                        if rows.is_empty() {
137                            status = "nothing to export — run a measurement pass first (R)".into();
138                        } else {
139                            let path = Path::new("out/lab-session.json");
140                            let export_rows: Vec<LabSessionRow> = rows
141                                .iter()
142                                .map(|r| LabSessionRow {
143                                    name: r.name.clone(),
144                                    median_ns: r.median_ns,
145                                    cv: r.cv,
146                                    stability: r.stability.clone(),
147                                })
148                                .collect();
149                            match LabSessionSummary::from_rows(info, export_rows).write_to(path) {
150                                Ok(()) => {
151                                    status = format!(
152                                        "exported {} rows → {}",
153                                        rows.len(),
154                                        path.display()
155                                    );
156                                }
157                                Err(e) => status = format!("export error: {e}"),
158                            }
159                        }
160                    }
161                    KeyCode::Char('r') | KeyCode::Char('R') if !measuring => {
162                        measuring = true;
163                        status = "measuring…".into();
164                        terminal.draw(|f| draw(f, info, &rows, &status, measuring))?;
165                        match run_pass(info) {
166                            Ok(new_rows) => {
167                                rows = new_rows;
168                                status = format!(
169                                    "pass complete — {} workloads (real medians only) · E exports JSON",
170                                    rows.len()
171                                );
172                            }
173                            Err(e) => status = format!("measurement error: {e}"),
174                        }
175                        measuring = false;
176                    }
177                    _ => {}
178                }
179            }
180        }
181    };
182
183    disable_raw_mode()?;
184    io::stdout().execute(LeaveAlternateScreen)?;
185    let _ = io::stdout().flush();
186    result
187}
188
189fn run_pass(info: &HardwareInfo) -> anyhow::Result<Vec<LiveRow>> {
190    let eng = MeasurementEngine::new(MeasurementConfig {
191        warmup: 2,
192        iterations: 12,
193        ..Default::default()
194    });
195    let mut out = Vec::new();
196
197    for target in [CacheTarget::L1, CacheTarget::L2, CacheTarget::L3, CacheTarget::Dram] {
198        let bench = MemoryBench::for_target(&info.topology, target);
199        let s = eng.measure(|| {
200            let _ = bench.run();
201        })?;
202        out.push(LiveRow {
203            name: format!("memory {}", target.describe(&info.topology)),
204            median_ns: s.median_ns,
205            cv: summary_cv(&s),
206            stability: s.stability.label().into(),
207        });
208    }
209
210    let ib = IntegerBench { n: 99 };
211    let s = eng.measure(|| {
212        let _ = ib.run_baseline();
213    })?;
214    out.push(LiveRow {
215        name: "integer lcg".into(),
216        median_ns: s.median_ns,
217        cv: summary_cv(&s),
218        stability: s.stability.label().into(),
219    });
220
221    Ok(out)
222}
223
224fn draw(
225    f: &mut ratatui::Frame,
226    info: &HardwareInfo,
227    rows: &[LiveRow],
228    status: &str,
229    measuring: bool,
230) {
231    let area = f.area();
232    let chunks = Layout::default()
233        .direction(Direction::Vertical)
234        .constraints([
235            Constraint::Length(3),
236            Constraint::Length(4),
237            Constraint::Min(8),
238            Constraint::Length(2),
239        ])
240        .split(area);
241
242    let title = Paragraph::new(vec![
243        Line::from(Span::styled(
244            BRAND_LINE,
245            Style::default()
246                .fg(Color::White)
247                .add_modifier(Modifier::BOLD),
248        )),
249        Line::from(Span::styled(
250            format!("lab — {RELEASE_LINE} · v{VERSION} — live measurements"),
251            Style::default().fg(Color::Gray),
252        )),
253    ])
254    .block(Block::default().borders(Borders::BOTTOM));
255    f.render_widget(title, chunks[0]);
256
257    let fp = info
258        .fingerprint
259        .as_ref()
260        .map(|f| f.value.as_str())
261        .unwrap_or("(unsupported)");
262    let host = Paragraph::new(vec![
263        Line::from(format!("host  {}", info.brand)),
264        Line::from(format!("fp    {fp}")),
265        Line::from(format!(
266            "topo  cores={} threads={} domains={}",
267            info.topology.core_count(),
268            info.topology.thread_count(),
269            info.topology.domain_count()
270        )),
271    ]);
272    f.render_widget(host, chunks[1]);
273
274    let header = Row::new(vec!["workload", "median_ns", "cv", "stability"]).style(
275        Style::default()
276            .fg(Color::White)
277            .add_modifier(Modifier::BOLD),
278    );
279    let table_rows: Vec<Row> = rows
280        .iter()
281        .map(|r| {
282            Row::new(vec![
283                r.name.clone(),
284                format!("{:.0}", r.median_ns),
285                format!("{:.3}", r.cv),
286                r.stability.clone(),
287            ])
288        })
289        .collect();
290    let table = Table::new(
291        table_rows,
292        [
293            Constraint::Percentage(55),
294            Constraint::Percentage(15),
295            Constraint::Percentage(15),
296            Constraint::Percentage(15),
297        ],
298    )
299    .header(header)
300    .block(Block::default().borders(Borders::ALL).title("results"));
301    f.render_widget(table, chunks[2]);
302
303    let tip = if measuring { "…" } else { status };
304    let footer = Paragraph::new(tip).style(Style::default().fg(Color::DarkGray));
305    f.render_widget(footer, chunks[3]);
306
307    let _ = Rect::default();
308}