1#![doc = include_str!("../docs/tuner.md")]
2
3use std::io;
4
5use ratatui::{
6 Frame, Terminal,
7 backend::Backend,
8 crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
9 layout::{Constraint, Layout, Rect},
10 style::{Color, Modifier, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, Gauge, Paragraph, Wrap},
13};
14
15use crate::{
16 BASE_DSP, BASE_LUT, BUDGET_BRAM36, Budget, ChipPlan, Context, Cost, Delivered, Mode, Powers, Role, SHA512_BLOCKS, Share, Side, Slot,
17 U280_DSP, U280_LUT, U280_URAM, build_config, plan_chip, ubft,
18};
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Control {
23 Mode,
25 Lut,
27 Dsp,
29 Bram,
31 Uram,
33 ShaSplit,
35 SignPower,
37 VerifyPower,
39 Background,
41 EdSplit,
43 Mix,
45 BestCase,
47 Module(usize),
49 Knob(usize, usize),
51 Limit(usize, usize),
53}
54
55pub struct App {
57 pub title: String,
59 pub mode: Mode,
61 pub budget: Budget,
63 pub cx: Context,
65 pub powers: Powers,
67 pub slots: Vec<Slot>,
69 pub selected: usize,
71 pub status: String,
73 pub config_path: String,
75 pub balanced: bool,
77 pub bg: crate::Background,
79 pub used: crate::BackgroundUsed,
81 pub headline: [Option<Delivered>; 4],
84 pub other_budget: Budget,
88}
89
90const COLORS: [Color; 6] = [Color::Blue, Color::Magenta, Color::Green, Color::Yellow, Color::Cyan, Color::LightRed];
92
93fn kilo(n: u32) -> String {
95 if n >= 10_000 { format!("{:.1}k", n as f64 / 1e3) } else { n.to_string() }
96}
97
98fn micros(us: f64) -> String {
100 if us >= 1_000.0 {
101 format!("{:.2}ms", us / 1e3)
102 } else if us >= 10.0 {
103 format!("{us:.0}us")
104 } else {
105 format!("{us:.2}us")
106 }
107}
108
109fn rate(x: f64) -> String {
111 if x >= 1e6 {
112 format!("{:.2}M", x / 1e6)
113 } else if x >= 1e3 {
114 format!("{:.1}k", x / 1e3)
115 } else {
116 format!("{x:.0}")
117 }
118}
119
120impl App {
122 pub fn new(title: &str, mode: Mode, slots: Vec<Slot>) -> App {
124 let mut app = App {
125 title: title.into(),
126 mode,
127 budget: Budget::default(),
128 cx: Context::default(),
129 powers: Powers::default(),
130 slots,
131 selected: 0,
132 status: String::new(),
133 config_path: "build-config.toml".into(),
134 balanced: false,
135 bg: crate::Background::default(),
136 used: crate::BackgroundUsed::default(),
137 headline: [None, None, None, None],
138 other_budget: if mode.is_ubft() { Budget::default() } else { Budget::whole_device() },
139 };
140 if mode.is_ubft() {
141 app.budget = Budget::whole_device();
142 }
143 if !app.has_paper(mode) {
146 if let Some(&m) = app.papers().iter().find(|m| m.is_ubft() == mode.is_ubft()) {
147 app.mode = m;
148 }
149 }
150 let first = app.mode;
152 for m in app.papers() {
153 if m != first {
154 app.enter(m);
155 app.balance();
156 }
157 }
158 app.enter(first);
159 app.balance();
160 app
161 }
162
163 pub fn has_paper(&self, mode: Mode) -> bool {
168 let role = if mode.paper() == Mode::Dsig { Role::DsigSign } else { Role::Ed25519Sign };
169 self.slots.iter().any(|s| s.component.role() == role && s.component.ubft() == mode.is_ubft())
170 }
171
172 pub fn papers(&self) -> Vec<Mode> {
174 Mode::ALL.into_iter().filter(|&m| self.has_paper(m)).collect()
175 }
176
177 fn shares_now(&self) -> Vec<Share> {
179 crate::shares_with(&self.budget, &self.slots, &crate::allowances_in(self.mode, &self.slots))
180 }
181
182 fn fixed_now(&self) -> Cost {
184 crate::allowances_in(self.mode, &self.slots).into_iter().flatten().fold(Cost::default(), |a, f| a.add(f, 1))
185 }
186
187 fn enter(&mut self, mode: Mode) {
189 if mode.is_ubft() != self.mode.is_ubft() {
190 std::mem::swap(&mut self.budget, &mut self.other_budget);
191 }
192 self.mode = mode;
193 }
194
195 pub fn controls(&self) -> Vec<Control> {
197 let mut c = vec![Control::Mode, Control::Lut, Control::Dsp, Control::Bram, Control::Uram];
198 if self.sha_lut_knob().is_some() {
199 c.push(Control::ShaSplit);
200 }
201 c.extend([Control::SignPower, Control::VerifyPower]);
202 if self.mode == Mode::Dsig {
203 c.push(Control::Background);
204 c.push(Control::EdSplit);
205 }
206 c.push(Control::Mix);
207 if self.mode.paper() == Mode::Dsig {
208 c.push(Control::BestCase);
209 }
210 for (k, s) in self.slots.iter().enumerate() {
211 if !self.mode.admits(&*s.component) {
212 continue;
213 }
214 c.push(Control::Module(k));
215 for j in 0..s.component.knobs().len() {
218 c.push(Control::Knob(k, j));
219 }
220 if s.component.fixed().is_none() {
221 for i in 0..4 {
222 c.push(Control::Limit(k, i));
223 }
224 }
225 }
226 c
227 }
228
229 pub fn sha_lut_knob(&self) -> Option<(usize, usize)> {
231 let k = self
232 .slots
233 .iter()
234 .position(|s| s.component.role() == Role::Sha512 && s.component.fixed().is_some() && self.mode.admits(&*s.component))?;
235 let j = self.slots[k].component.knobs().iter().position(|x| x.name.contains("LUT"))?;
236 Some((k, j))
237 }
238
239 pub fn sha_auto(&self) -> bool {
242 self.slots
243 .iter()
244 .any(|s| s.component.role() == Role::Sha512 && s.component.knobs().iter().any(|k| k.name == "size" && k.value < 0.5))
245 }
246
247 pub fn lut_split(&self) -> (f64, f64, f64) {
250 let luts = self.budget.luts().max(1) as f64;
251 let sha = (100.0 * self.fixed_now().lut as f64 / luts).min(100.0);
252 let rest = 100.0 - sha;
253 if self.mode.paper() == Mode::Dsig {
254 let b = self.used.share;
255 (sha, rest * (100.0 - b) / 100.0, rest * b / 100.0)
256 } else {
257 (sha, 0.0, rest)
258 }
259 }
260
261 fn sharers(&self, name: &str) -> Vec<String> {
263 self.slots
264 .iter()
265 .filter(|s| self.mode.admits(&*s.component) && s.component.knobs().iter().any(|x| x.shared && x.name == name))
266 .map(|s| s.component.name())
267 .collect()
268 }
269
270 fn label(&self, c: Control) -> String {
272 let b = &self.budget;
273 match c {
274 Control::Mode => format!("Paper {}", self.mode.name()),
275 Control::Lut => format!("LUT budget {:5.1} % = {} of {}", b.lut_percent, kilo(b.luts()), kilo(BASE_LUT)),
276 Control::Dsp => format!("DSP budget {:5.1} % = {} of {} (dev {})", b.dsp_percent, b.dsps(), BASE_DSP, U280_DSP),
277 Control::Bram => format!("BRAM budget {:5.1} % = {} of {} BRAM36 (dynamic region)", b.bram_percent, b.brams(), BUDGET_BRAM36),
278 Control::Uram => format!("URAM budget {:5.1} % = {} of {}", b.uram_percent, b.urams(), U280_URAM),
279 Control::ShaSplit => {
280 let (sha, dsig, ed) = self.lut_split();
281 let size = if self.sha_auto() { " auto" } else { "" };
282 if self.mode.paper() == Mode::Dsig {
283 format!("LUT split SHA{size} {sha:.1}% | DSig {dsig:.1}% | Ed25519 {ed:.1}%")
284 } else {
285 format!("LUT split SHA{size} {sha:.1}% ({}) | Ed25519 {ed:.1}%", kilo(self.fixed_now().lut))
286 }
287 }
288 Control::SignPower if self.mode.paper() == Mode::Dsig => {
289 format!("HBSS sign {:5.1} % of the foreground", self.powers.sign)
290 }
291 Control::VerifyPower if self.mode.paper() == Mode::Dsig => {
292 format!("HBSS verify {:5.1} % of the foreground (sum 100)", self.powers.verify)
293 }
294 Control::SignPower => format!("Sign power {:5.1} % of the budget", self.powers.sign),
295 Control::VerifyPower => format!("Verify power{:5.1} % of the budget (sign + verify = 100)", self.powers.verify),
296 Control::Background => {
297 let u = &self.used;
298 let mode = if self.bg.share.is_some() { "manual" } else { "auto" };
299 format!(
300 "EdDSA background {:5.1} % of non-SHA ({mode}), HBSS {:.1} %; needed {:.1} %",
301 u.share,
302 100.0 - u.share,
303 u.needed_share
304 )
305 }
306 Control::EdSplit => {
307 let u = &self.used;
308 let mode = if self.bg.sign.is_some() { "manual" } else { "auto" };
309 format!(" within EdDSA: sign {:5.1} % / verify {:.1} % ({mode}); best {:.1} %", u.sign, 100.0 - u.sign, u.needed_sign)
310 }
311 Control::Mix => format!("Mix (for B) {:.2} verifies per signature", self.cx.mix),
312 Control::BestCase => format!(
313 "Best case {:5.1} % (root cached: {:.1} %)",
314 self.cx.best_case,
315 100.0 * self.cx.cache_hit(self.plan().delivered.batch)
316 ),
317 Control::Module(k) => {
318 let s = &self.slots[k];
319 match s.component.fixed() {
320 Some(f) => {
321 let why = if s.component.role() == Role::Sha512 { "LUT split" } else { "fixed" };
322 format!("{} {} LUT {} DSP ({why})", s.component.name(), kilo(f.lut), f.dsp)
323 }
324 None => {
325 let g = self.shares_now()[k];
327 format!(
328 "{} {:4.1} % = {} LUT {} DSP {} BRAM {} URAM",
329 s.component.name(),
330 s.power,
331 kilo(g.lut),
332 g.dsp,
333 g.bram36,
334 g.uram
335 )
336 }
337 }
338 }
339 Control::Limit(k, i) => {
340 let r = crate::Resource::ALL[i];
341 let g = r.of(&self.shares_now()[k]);
342 match self.slots[k].limits[i] {
343 Some(pct) => format!(" {:<4} share {pct:5.1} % = {} (manual cap)", r.name(), kilo(g)),
344 None => format!(" {:<4} share auto ({}; Right sets a cap)", r.name(), kilo(g)),
345 }
346 }
347 Control::Knob(k, j) => {
348 let knob = &self.slots[k].component.knobs()[j];
349 let n = if knob.shared { self.sharers(&knob.name).len() } else { 0 };
350 let note = if n > 1 { format!(" (sets all {n})") } else { String::new() };
351 format!(" {:<18} {}{note}", knob.name, knob.show())
352 }
353 }
354 }
355
356 pub fn adjust(&mut self, steps: f64) {
358 let Some(&c) = self.controls().get(self.selected) else {
359 return;
360 };
361 match c {
362 Control::Mode => {
363 self.switch_paper();
364 return;
365 }
366 Control::Lut => {
367 self.budget.lut_percent = (self.budget.lut_percent + steps).clamp(0.0, 100.0 * U280_LUT as f64 / BASE_LUT as f64)
368 }
369 Control::Dsp => {
370 self.budget.dsp_percent = (self.budget.dsp_percent + steps).clamp(0.0, 100.0 * U280_DSP as f64 / BASE_DSP as f64)
371 }
372 Control::Bram => self.budget.bram_percent = (self.budget.bram_percent + steps).clamp(0.0, 100.0),
373 Control::Uram => self.budget.uram_percent = (self.budget.uram_percent + steps).clamp(0.0, 100.0),
374 Control::SignPower => {
376 self.powers.sign = (self.powers.sign + steps).clamp(0.0, 100.0);
377 self.powers.verify = 100.0 - self.powers.sign;
378 }
379 Control::VerifyPower => {
380 self.powers.verify = (self.powers.verify + steps).clamp(0.0, 100.0);
381 self.powers.sign = 100.0 - self.powers.verify;
382 }
383 Control::Background => {
386 let next = self.bg.share.unwrap_or(self.used.share) + 0.5 * steps;
387 self.bg.share = if next < 0.0 { None } else { Some(next.min(100.0)) };
388 }
389 Control::EdSplit => {
390 let next = self.bg.sign.unwrap_or(self.used.sign) + 0.5 * steps;
391 self.bg.sign = if next < 0.0 { None } else { Some(next.min(100.0)) };
392 }
393 Control::ShaSplit => {
395 let Some((k, j)) = self.sha_lut_knob() else {
396 return;
397 };
398 let knob = self.slots[k].component.knobs()[j].clone();
399 let step = (0.005 * self.budget.luts() as f64 / knob.step.max(1.0)).round().max(1.0) * knob.step.max(1.0);
400 let value = (knob.value + step * steps).clamp(knob.min, knob.max);
401 let was_auto = self.sha_auto();
402 self.slots[k].component.set_knob(j, value);
403 if was_auto {
404 self.status = "SHA-512 line set by hand; its \"size\" knob returns it to auto.".into();
405 }
406 if value >= knob.max && steps > 0.0 {
407 self.status = format!("The SHA-512 line is at its largest allowance ({} LUT).", kilo(knob.max as u32));
408 }
409 }
410 Control::Mix => self.cx.mix = ((self.cx.mix + 0.05 * steps) * 100.0).round().clamp(0.0, 1_000.0) / 100.0,
411 Control::BestCase => self.cx.best_case = (self.cx.best_case + steps).clamp(0.0, 100.0),
412 Control::Module(_) => return,
413 Control::Limit(k, i) => {
416 let r = crate::Resource::ALL[i];
417 let total = r.of(&self.budget.rest_with(&crate::allowances_in(self.mode, &self.slots))).max(1) as f64;
418 let now = self.slots[k].limits[i].unwrap_or(100.0 * r.of(&self.shares_now()[k]) as f64 / total);
419 let next = ((now + 0.5 * steps) * 2.0).round() / 2.0;
420 self.slots[k].limits[i] = if next < 0.0 { None } else { Some(next.min(100.0)) };
421 }
422 Control::Knob(k, j) => {
423 let knob = self.slots[k].component.knobs()[j].clone();
424 let value = knob.moved(steps);
425 self.slots[k].component.set_knob(j, value);
426 if knob.shared {
428 for other in 0..self.slots.len() {
429 if other == k {
430 continue;
431 }
432 let found = self.slots[other].component.knobs().iter().position(|x| x.shared && x.name == knob.name);
433 if let Some(i) = found {
434 self.slots[other].component.set_knob(i, value);
435 }
436 }
437 }
438 }
439 }
440 self.used = crate::apply_layout(self.mode, &self.budget, &self.cx, &mut self.slots, self.powers, self.bg).1;
442 self.balanced = false;
443 self.note_headline();
444 }
445
446 fn note_headline(&mut self) {
448 let d = self.plan().delivered;
449 let i = Mode::ALL.iter().position(|&m| m == self.mode).unwrap();
450 self.headline[i] = Some(d);
451 }
452
453 pub fn switch_paper(&mut self) {
456 let papers = self.papers();
457 let at = papers.iter().position(|&m| m == self.mode).unwrap_or(0);
458 let next = papers[(at + 1) % papers.len().max(1)];
459 if next == self.mode {
460 self.status = format!("This build has only the {}; build with the `dsig` feature (`cargo tuner`) for both.", self.mode.name());
461 return;
462 }
463 self.enter(next);
464 self.selected = 0;
465 self.balance();
466 }
467
468 pub fn balance(&mut self) {
470 self.powers = crate::balance_mix_with(self.mode, &self.budget, &self.cx, &mut self.slots, self.bg);
471 self.used = crate::apply_layout(self.mode, &self.budget, &self.cx, &mut self.slots, self.powers, self.bg).1;
472 self.balanced = true;
473 self.note_headline();
474 let d = self.plan().delivered;
475 self.status = format!(
476 "Balanced for {:.2} verifies per signature: sign power {:.1} %, verify power {:.1} % -> {} {}/s and {} {}/s.",
477 self.cx.mix,
478 self.powers.sign,
479 self.powers.verify,
480 rate(d.sign.rate),
481 self.mode.op(Side::Sign),
482 rate(d.verify.rate),
483 self.mode.op(Side::Verify)
484 );
485 }
486
487 pub fn write_config(&mut self) {
489 let text = build_config(self.mode, &self.budget, &self.cx, &self.slots);
490 if self.mode.is_ubft() {
491 if let Err(error) = crate::build_apply::UbftBuild::from_toml(&text) {
492 self.status = format!("Plan not written: {error}");
493 return;
494 }
495 }
496 self.status = match std::fs::write(&self.config_path, text) {
497 Ok(()) if self.mode.is_ubft() => format!("Wrote {}; plan only. Apply with apply_ubft_plan.", self.config_path),
498 Ok(()) => format!("Wrote {}; model record only (no validated apply path for this mode).", self.config_path),
499 Err(e) => format!("Could not write {}: {e}", self.config_path),
500 };
501 }
502
503 pub fn key(&mut self, code: KeyCode, shift: bool) -> bool {
505 let n = self.controls().len();
506 let steps = if shift { 5.0 } else { 1.0 };
507 match code {
508 KeyCode::Char('q') | KeyCode::Esc => return false,
509 KeyCode::Down | KeyCode::Tab => self.selected = (self.selected + 1) % n,
510 KeyCode::Up => self.selected = (self.selected + n - 1) % n,
511 KeyCode::Left => self.adjust(-steps),
512 KeyCode::Right => self.adjust(steps),
513 KeyCode::Char('b') | KeyCode::Char('B') => self.balance(),
514 KeyCode::Char('p') | KeyCode::Char('P') => self.switch_paper(),
515 KeyCode::Char('w') | KeyCode::Char('W') => self.write_config(),
516 _ => {}
517 }
518 self.selected = self.selected.min(self.controls().len() - 1);
519 true
520 }
521
522 pub fn plan(&self) -> ChipPlan {
524 plan_chip(self.mode, &self.budget, &self.cx, &self.slots)
525 }
526}
527
528pub fn next_step(slot: &Slot, share: &Share, cx: &Context, rate_now: f64) -> Option<(u32, f64)> {
533 (1..=64u32).find_map(|k| {
534 let wider = Share { lut: share.lut + 1_000 * k, ..*share };
535 crate::plan_slot(slot, &wider, cx).filter(|p| p.rate > rate_now * (1.0 + 1e-6) + 1.0).map(|p| (1_000 * k, p.rate))
536 })
537}
538
539fn ratio(used: u32, limit: u32) -> f64 {
542 if limit == 0 { if used == 0 { 0.0 } else { 1.0 } } else { (used as f64 / limit as f64).clamp(0.0, 1.0) }
543}
544
545fn gauge_row(frame: &mut Frame, area: Rect, used: Cost, limit: Share, color: Color) {
548 let cols = Layout::horizontal([Constraint::Ratio(1, 4); 4]).split(area);
549 for (i, (name, u, l)) in
550 [("LUT", used.lut, limit.lut), ("DSP", used.dsp, limit.dsp), ("BRAM", used.bram36, limit.bram36), ("URAM", used.uram, limit.uram)]
551 .into_iter()
552 .enumerate()
553 {
554 let full = l > 0 && u as f64 >= 0.97 * l as f64;
555 frame.render_widget(
556 Gauge::default()
557 .ratio(ratio(u, l))
558 .label(format!("{name} {}/{}", kilo(u), kilo(l)))
559 .gauge_style(Style::default().fg(if full { Color::Red } else { color })),
560 cols[i],
561 );
562 }
563}
564
565pub fn draw(frame: &mut Frame, app: &App) {
567 let plan = app.plan();
568 let areas =
569 Layout::vertical([Constraint::Length(2), Constraint::Min(8), Constraint::Length(2), Constraint::Length(1)]).split(frame.area());
570 let head = |m: Mode| {
573 let i = Mode::ALL.iter().position(|&x| x == m).unwrap();
574 let d = if m == app.mode { Some(&plan.delivered) } else { app.headline[i].as_ref() };
575 let Some(d) = d else {
576 return format!("{}: not planned", m.short());
577 };
578 if m.is_ubft() {
579 let n = ubft::UBFT_REPLICAS[0];
580 format!("{}: slot crypto serial model {} at n={n} (no measured speedup)", m.short(), micros(ubft::serial_slot_crypto_us(d, n)))
581 } else {
582 format!("{}: {}/s sign, {}/s verify", m.short(), rate(d.sign.rate), rate(d.verify.rate))
583 }
584 };
585 frame.render_widget(
586 Paragraph::new(vec![
587 Line::from(Span::styled(
588 format!(
589 "{} {} MHz now: {}{}",
590 app.title,
591 app.cx.mhz,
592 app.mode.name(),
593 if app.papers().len() > 1 { " (P switches paper)" } else { "" }
594 ),
595 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
596 )),
597 Line::from({
598 let style =
599 |m: Mode| Style::default().fg(if app.mode == m { Color::Green } else { Color::DarkGray }).add_modifier(Modifier::BOLD);
600 let mut spans = Vec::new();
603 for m in app.papers() {
604 if !spans.is_empty() {
605 spans.push(Span::raw(" "));
606 }
607 spans.push(Span::styled(head(m), style(m)));
608 }
609 if !app.has_paper(Mode::Dsig) {
610 spans.push(Span::raw(" (no DSig in this build: `cargo tuner` builds both papers)"));
611 }
612 spans
613 }),
614 ]),
615 areas[0],
616 );
617 let cols = Layout::horizontal([Constraint::Percentage(42), Constraint::Percentage(58)]).split(areas[1]);
618
619 let controls = app.controls();
621 let block = Block::default().borders(Borders::ALL).title(" Controls ");
622 let inner = block.inner(cols[0]);
623 frame.render_widget(block, cols[0]);
624 let height = inner.height as usize;
625 let first = app.selected.saturating_sub(height.saturating_sub(1));
626 let lines: Vec<Line> = controls
627 .iter()
628 .enumerate()
629 .skip(first)
630 .take(height)
631 .map(|(i, &c)| {
632 let style = if i == app.selected {
633 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
634 } else if matches!(c, Control::Module(_) | Control::Mode | Control::SignPower | Control::VerifyPower | Control::Background) {
635 Style::default().add_modifier(Modifier::BOLD)
636 } else {
637 Style::default()
638 };
639 Line::from(Span::styled(format!("{} {}", if i == app.selected { ">" } else { " " }, app.label(c)), style))
640 })
641 .collect();
642 frame.render_widget(Paragraph::new(lines), inner);
643
644 let d = &plan.delivered;
646 let shown: Vec<usize> = (0..app.slots.len()).filter(|&k| app.mode.admits(&*app.slots[k].component)).collect();
647 let roles: Vec<Role> = Role::OPS.iter().copied().filter(|r| d.need(*r) > 0.0).collect();
648 let ubft_rows = if app.mode.is_ubft() { 3 + ubft::UBFT_REPLICAS.len() } else { 0 };
649 let summary_height = 2 + 2 + roles.len() + d.services.len() + usize::from(d.batch_mismatch) + ubft_rows;
650 let mut rows = vec![Constraint::Length(summary_height as u16)];
651 rows.extend(shown.iter().map(|_| Constraint::Length(4)));
652 rows.push(Constraint::Length(3));
653 rows.push(Constraint::Min(0));
654 let right = Layout::vertical(rows).split(cols[1]);
655
656 let block = Block::default().borders(Borders::ALL).title(if app.balanced {
657 " Delivered (balanced for the mix) "
658 } else {
659 " Delivered (B balances) "
660 });
661 let inner = block.inner(right[0]);
662 frame.render_widget(block, right[0]);
663 let mut lines = Vec::new();
664 for (side, color) in [(Side::Sign, Color::Green), (Side::Verify, Color::Magenta)] {
665 let s = d.side(side);
666 lines.push(Line::from(vec![
667 Span::styled(format!("{:>8}/s {}", rate(s.rate), app.mode.op(side)), Style::default().fg(color).add_modifier(Modifier::BOLD)),
668 Span::raw(format!(" latency {} limited by {}", micros(s.latency_us), s.bottleneck)),
669 ]));
670 }
671 for role in &roles {
672 let capacity: f64 = shown
673 .iter()
674 .filter(|&&k| app.slots[k].component.role() == *role)
675 .filter_map(|&k| plan.parts[k].1.as_ref().map(|p| p.rate))
676 .sum();
677 lines.push(Line::from(format!(
678 " {:<15} {:>8}/s of {:>8}/s capacity {:>7} latency ({:.4} per {})",
679 role.name(),
680 rate(d.of(*role)),
681 rate(capacity),
682 micros(d.latency_of(*role)),
683 d.need(*role),
684 role.side().map_or("", |s| s.name())
685 )));
686 }
687 for s in &d.services {
688 let text = format!(
689 " {:<15} {:>8}/s of {:>8}/s supply{}",
690 s.name,
691 rate(s.demand),
692 rate(s.supply),
693 if d.sha_bound { " <- limits the chip" } else { "" }
694 );
695 lines.push(Line::from(Span::styled(text, Style::default().fg(if d.sha_bound { Color::Red } else { Color::Reset }))));
696 }
697 if d.batch_mismatch {
698 lines.push(Line::from(Span::styled(
699 format!(" DSig sign and verify use different batch sizes; using the signer's K = {}", d.batch),
700 Style::default().fg(Color::Red),
701 )));
702 }
703 if app.mode.is_ubft() {
705 lines.push(Line::from(Span::styled(
706 format!(
707 " target: {:.0}x completed operations over both baselines on card (not measured; failed runs are not divisors)",
708 ubft::UBFT_TARGET_SPEEDUP
709 ),
710 Style::default().fg(Color::Yellow),
711 )));
712 lines.push(Line::from(Span::styled(
713 " uBFT slot crypto SERIAL MODEL (sign + 2f sequential verifies; excludes network/recovery)",
714 Style::default().add_modifier(Modifier::BOLD),
715 )));
716 lines.push(Line::from(" HLS baseline: unmatched 1-byte input; no ratio."));
717 lines.push(Line::from(" Replica BRAM/URAM allowance: unknown."));
718 let capacity = app.slots.iter().filter(|slot| slot.component.ubft() && slot.component.role() == Role::Other)
719 .flat_map(|slot| slot.component.knobs()).find(|knob| knob.name == "replica capacity (N)")
720 .map_or(16, |knob| knob.value as u32);
721 for n in ubft::UBFT_REPLICAS {
722 if n > capacity { continue; }
723 let t = ubft::serial_slot_crypto_us(d, n);
724 lines.push(Line::from(format!(
725 " n = {n:<2} ({} verifies) {:>9} projected crypto time; no system ratio",
726 ubft::quorum_verifies(n),
727 micros(t)
728 )));
729 }
730 }
731 frame.render_widget(Paragraph::new(lines), inner);
732
733 for (row, &k) in shown.iter().enumerate() {
734 let slot = &app.slots[k];
735 let color = COLORS[k % COLORS.len()];
736 let (share, p) = &plan.parts[k];
737 let lent = slot.component.fixed().is_none() && *share != plan.guaranteed[k];
738 let area = right[row + 1];
739 let side = slot.component.role().side().map_or(String::new(), |s| format!(" ({} side)", s.name()));
740 let block = Block::default().borders(Borders::ALL).title(format!(" {}{side} ", slot.component.name()));
741 let inner = block.inner(area);
742 frame.render_widget(block, area);
743 let parts = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
744 match p {
745 Some(p) => {
746 frame.render_widget(
747 Paragraph::new(Line::from(vec![
748 Span::styled(format!("{} {}/s", rate(p.rate), p.unit), Style::default().fg(color).add_modifier(Modifier::BOLD)),
749 Span::raw(format!(
750 " {} lat {} idle / {} loaded{}{} [{}; limit {}; {}]",
751 p.detail,
752 micros(p.idle_latency_us(app.cx.mhz)),
753 micros(p.loaded_latency_us(app.cx.mhz)),
754 if d.sha_bound && p.demands.iter().any(|f| f.service == SHA512_BLOCKS) { ", SHA-limited" } else { "" },
755 if lent { ", uses resources other modules left free" } else { "" },
756 if p.measured { "measured" } else { "model" },
757 p.limit,
758 if slot.component.fixed().is_some() {
759 "fixed allowance".to_string()
760 } else {
761 match next_step(slot, share, &app.cx, p.rate) {
762 Some((lut, r)) => {
763 format!("next: +{} LUT -> {}/s", kilo(lut), rate(r))
764 }
765 None => "next: not within +64k LUT".to_string(),
766 }
767 }
768 )),
769 ])),
770 parts[0],
771 );
772 gauge_row(frame, parts[1], p.cost, *share, color);
773 }
774 None => frame.render_widget(
775 Paragraph::new(if let Some(why) = slot.component.unavailable() {
776 why
777 } else if slot.power > 0.0 || slot.component.fixed().is_some() {
778 let first = next_step(slot, share, &app.cx, 0.0)
779 .map_or("its first build needs more than +64k LUT (or another resource)".to_string(), |(lut, r)| {
780 format!("its first build needs +{} LUT ({}/s)", kilo(lut), rate(r))
781 });
782 format!("Nothing fits a {:.1} % share ({} LUT): {first}.", slot.power, kilo(share.lut))
783 } else {
784 "Off (no share).".to_string()
785 })
786 .style(Style::default().fg(Color::DarkGray)),
787 parts[0],
788 ),
789 }
790 }
791 let n = shown.len();
792 let block = Block::default().borders(Borders::ALL).title(" Whole chip (budget + SHA line) ");
793 let inner = block.inner(right[n + 1]);
794 frame.render_widget(block, right[n + 1]);
795 gauge_row(frame, inner, plan.total, app.budget.chip(), Color::Cyan);
796
797 frame.render_widget(Paragraph::new(app.status.as_str()).wrap(Wrap { trim: true }), areas[2]);
798 frame.render_widget(
799 Paragraph::new("Up/Down select Left/Right adjust (Shift x5) B balance P switch paper W write build flags Q quit"),
800 areas[3],
801 );
802}
803
804pub fn run_on<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
806 loop {
807 terminal.draw(|f| draw(f, app))?;
808 if let Event::Key(key) = event::read()? {
809 if key.kind == KeyEventKind::Press && !app.key(key.code, key.modifiers.contains(KeyModifiers::SHIFT)) {
810 return Ok(());
811 }
812 }
813 }
814}
815
816pub fn run(title: &str, mode: Mode, slots: Vec<Slot>) -> io::Result<()> {
818 let mut app = App::new(title, mode, slots);
819 let mut terminal = ratatui::init();
820 let result = run_on(&mut terminal, &mut app);
821 ratatui::restore();
822 result
823}
824
825pub fn snapshot(app: &App, width: u16, height: u16) -> String {
827 let mut t = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
828 t.draw(|f| draw(f, app)).unwrap();
829 let buf = t.backend().buffer().clone();
830 let mut out = String::new();
831 for y in 0..buf.area.height {
832 let line: String = (0..buf.area.width).map(|x| buf[(x, y)].symbol().to_string()).collect();
833 out.push_str(line.trim_end());
834 out.push('\n');
835 }
836 out
837}
838
839#[cfg(test)]
841mod tests {
842 use super::*;
843 use crate::{Component, Knob, Plan};
844
845 struct SharedKnob(f64);
847 impl Component for SharedKnob {
848 fn name(&self) -> String {
849 "shared".into()
850 }
851 fn role(&self) -> Role {
852 Role::Ed25519Sign
853 }
854 fn knobs(&self) -> Vec<Knob> {
855 vec![Knob { value: self.0, ..Knob::choice("scheme", 0, &["a", "b"]).shared() }]
856 }
857 fn set_knob(&mut self, _: usize, value: f64) {
858 self.0 = value;
859 }
860 fn plan(&self, _: &Share, _: &Context) -> Option<Plan> {
861 None
862 }
863 }
864
865 #[test]
868 fn renders_both_modes_with_separate_sides() {
869 let mut app = App::new("TEST", Mode::Ed25519, crate::demo::chip());
870 let ed = snapshot(&app, 130, 40);
871 assert!(ed.contains("Ed25519 signatures") && ed.contains("Ed25519 verifications"), "{ed}");
872 let before = app.plan().delivered;
873 app.selected = app.controls().iter().position(|c| *c == Control::SignPower).unwrap();
874 app.adjust(10.0);
875 let after = app.plan().delivered;
876 assert!(after.sign.rate >= before.sign.rate && after.verify.rate <= before.verify.rate);
877 assert_eq!(app.powers.sign + app.powers.verify, 100.0);
878 app.selected = 0;
879 app.adjust(1.0); assert_eq!(app.mode, Mode::Dsig);
881 let ds = snapshot(&app, 130, 44);
882 assert!(ds.contains("DSig signatures") && ds.contains("DSig verifications") && ds.contains("Ed25519 sign"), "{ds}");
883 app.selected = app.controls().iter().position(|c| *c == Control::Background).unwrap();
886 app.adjust(4.0);
887 let b = app.bg.share.expect("manual");
888 let p: Vec<f64> = app.slots.iter().map(|s| s.power).collect();
889 assert!((p[0] + p[1] - b).abs() < 1e-9 && (p[2] + p[3] - (100.0 - b)).abs() < 1e-9, "{b} {p:?}");
891 let mut shared = App::new("TEST", Mode::Ed25519, crate::demo::chip());
893 for k in [0, 1] {
894 shared.slots[k] = Slot::new(Box::new(SharedKnob(0.0)), 0.0);
895 }
896 let rows: Vec<Control> = shared.controls().into_iter().filter(|c| matches!(c, Control::Knob(_, _))).collect();
897 assert_eq!(rows.len(), 2 + 2, "the shared knob under both modules plus the SHA line's two: {rows:?}");
898 shared.selected = shared.controls().iter().position(|c| *c == Control::Knob(0, 0)).unwrap();
899 shared.adjust(1.0);
900 assert_eq!(shared.slots[1].component.knobs()[0].value, 1.0);
901 for (w, h) in [(80, 24), (50, 16)] {
902 assert!(snapshot(&app, w, h).contains("TEST"));
903 }
904 println!("{ed}\n{ds}");
905 }
906
907 #[test]
912 fn ubft_mode_is_offered_and_rendered() {
913 let mut app = App::new("TEST", Mode::Ed25519, crate::demo::ubft_chip());
914 assert_eq!(app.papers(), [Mode::Dsig, Mode::Ed25519, Mode::UbftEd25519]);
915 app.selected = 0;
916 app.adjust(1.0); assert_eq!(app.mode, Mode::UbftEd25519);
918 assert_eq!(app.budget, Budget::whole_device());
919 let u = snapshot(&app, 150, 44);
920 assert!(u.contains("slot crypto") && u.contains("HLS baseline") && u.contains("uBFT replica") && u.contains("n = 7"), "{u}");
921 assert!(!u.contains("DSig sign "), "{u}");
922 assert!(!u.contains("x faster") && u.contains("MODEL") && u.contains("unknown"), "{u}");
923 app.adjust(1.0); assert_eq!(app.mode, Mode::Dsig);
925 assert_eq!(app.budget, Budget::default());
926 println!("{u}");
927 }
928}