1use 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#[derive(Clone, Serialize)]
25struct LiveRow {
26 name: String,
27 median_ns: f64,
28 cv: f64,
29 stability: String,
30}
31
32#[derive(Debug, Clone, Serialize)]
34pub struct LabSessionSummary {
35 pub product: String,
37 pub silicera_version: String,
39 pub phase: String,
41 pub brand_line: String,
43 pub host_brand: String,
45 pub fingerprint: Option<String>,
47 pub captured_at: String,
49 pub rows: Vec<LabSessionRow>,
51 pub caveats: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize)]
57pub struct LabSessionRow {
58 pub name: String,
60 pub median_ns: f64,
62 pub cv: f64,
64 pub stability: String,
66}
67
68impl LabSessionSummary {
69 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 pub fn to_json_pretty(&self) -> anyhow::Result<String> {
90 Ok(serde_json::to_string_pretty(self)?)
91 }
92
93 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
103pub 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
112pub 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}