Skip to main content

rhdl_fpga_planner/
tui.rs

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/// One row of the control list.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Control {
23    /// Which paper.
24    Mode,
25    /// Global LUT percentage.
26    Lut,
27    /// Global DSP percentage.
28    Dsp,
29    /// BRAM percentage of the device.
30    Bram,
31    /// URAM percentage of the device.
32    Uram,
33    /// The SHA-512 line's part of the LUT split (sets its LUT allowance).
34    ShaSplit,
35    /// Sign side's power.
36    SignPower,
37    /// Verify side's power.
38    VerifyPower,
39    /// EdDSA background's % of the budget (DSig paper).
40    Background,
41    /// Ed25519 sign's % of the background (DSig paper).
42    EdSplit,
43    /// Verifications per signature the B key balances for.
44    Mix,
45    /// Best-case expectation (DSig paper).
46    BestCase,
47    /// Header of slot `k` (shows its derived share).
48    Module(usize),
49    /// Knob `j` of slot `k`.
50    Knob(usize, usize),
51    /// Manual share of resource `i` ([`crate::Resource::ALL`]) for slot `k`.
52    Limit(usize, usize),
53}
54
55/// The planner's state.
56pub struct App {
57    /// Title line.
58    pub title: String,
59    /// Which paper.
60    pub mode: Mode,
61    /// Resource budget.
62    pub budget: Budget,
63    /// Workload.
64    pub cx: Context,
65    /// Side powers.
66    pub powers: Powers,
67    /// Components.
68    pub slots: Vec<Slot>,
69    /// Selected control.
70    pub selected: usize,
71    /// Status line.
72    pub status: String,
73    /// Where W writes the build flags.
74    pub config_path: String,
75    /// The powers are the balanced split for the current settings.
76    pub balanced: bool,
77    /// The DSig paper's background setting (auto or manual).
78    pub bg: crate::Background,
79    /// The background in use, with what the planner would choose.
80    pub used: crate::BackgroundUsed,
81    /// Each mode's delivered rates as last planned ([`Mode::ALL`] order),
82    /// for the headline that shows every paper.
83    pub headline: [Option<Delivered>; 4],
84    /// The budget of the modes on the other side of the uBFT line: the
85    /// papers plan an engine budget, the uBFT modes the whole device
86    /// ([`Budget::whole_device`]). Switching across swaps them.
87    pub other_budget: Budget,
88}
89
90/// Card and gauge colors, one per slot in order.
91const COLORS: [Color; 6] = [Color::Blue, Color::Magenta, Color::Green, Color::Yellow, Color::Cyan, Color::LightRed];
92
93/// `123456` as `"123.5k"`.
94fn kilo(n: u32) -> String {
95    if n >= 10_000 { format!("{:.1}k", n as f64 / 1e3) } else { n.to_string() }
96}
97
98/// Microseconds, scaled.
99fn 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
109/// Operations per second, scaled.
110fn 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
120/// Controls, key handling and planning for the TUI.
121impl App {
122    /// A planner for `slots`, balanced for the default settings.
123    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        // A paper whose modules were not compiled in (a build without the
144        // `dsig` feature) is not offered; open on one that is.
145        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        // Balance the other papers first so the headline shows them all.
151        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    /// Whether this chip has the modules of `mode`'s paper: a paper's
164    /// signing module decides it (Ed25519 signing for the Ed25519 paper,
165    /// DSig signing for the DSig paper; a uBFT module of that role for a
166    /// uBFT mode).
167    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    /// The papers this chip can plan, in display order ([`Mode::ALL`]).
173    pub fn papers(&self) -> Vec<Mode> {
174        Mode::ALL.into_iter().filter(|&m| self.has_paper(m)).collect()
175    }
176
177    /// Each slot's guaranteed share in the current mode.
178    fn shares_now(&self) -> Vec<Share> {
179        crate::shares_with(&self.budget, &self.slots, &crate::allowances_in(self.mode, &self.slots))
180    }
181
182    /// The fixed allowances of the current mode, summed.
183    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    /// Make `mode` current, swapping budgets when it crosses the uBFT line.
188    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    /// Every control, in display order.
196    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            // Shared knobs appear under every module that has them; either
216            // row sets all of them.
217            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    /// The SHA-512 line's LUT allowance knob: (slot, knob index).
230    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    /// Whether the SHA-512 line sizes itself to demand (its "size" knob on
240    /// auto).
241    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    /// The LUT split in % of the LUT budget: (SHA line, DSig foreground,
248    /// Ed25519). The Ed25519 paper has no DSig part. Sums to 100.
249    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    /// The other modules that share knob `name` (shown in its label).
262    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    /// The text of one control row.
271    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                        // The guaranteed share in real units.
326                        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    /// Move the selected control by `steps`.
357    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            // The two powers always sum to 100 %.
375            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            // 0.5 % per press from the value in use; below 0 returns to
384            // auto.
385            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            // 0.5 % of the LUT budget per press, in the knob's own steps.
394            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            // 0.5 % per press, starting from the current share; below 0
414            // returns to auto.
415            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                // A shared knob takes the same value in every module with it.
427                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        // Re-plan with the new settings.
441        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    /// Record this paper's delivered rates for the headline.
447    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    /// Switch to the next paper this build has ([`papers`](Self::papers),
454    /// wrapping around) and balance it.
455    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    /// Choose the side powers for the mix (what "100 %" means).
469    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    /// Write the build flags of the current plan.
488    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    /// Handle one key; `false` to quit.
504    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    /// The current plan.
523    pub fn plan(&self) -> ChipPlan {
524        plan_chip(self.mode, &self.budget, &self.cx, &self.slots)
525    }
526}
527
528/// The next faster build of `slot` with more LUTs and everything else as
529/// in `share`: `(extra LUTs, its capacity)`, probing in 1k steps up to 64k.
530/// Builds come in whole blocks (a field machine is ~11k LUTs), so a rate
531/// that stays flat while LUTs grow is waiting for this step.
532pub 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
539/// Gauge fill for `used` of `limit` (full when the limit is zero but
540/// something is used).
541fn 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
545/// Four gauges (LUT, DSP, BRAM, URAM) of `used` against `limit`; a gauge
546/// turns red at 97 %.
547fn 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
565/// Draw one frame.
566pub 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    // Every paper's headline (the others as last planned): rates for the
571    // papers, a slot's crypto time for the uBFT modes.
572    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                // Only the papers compiled into this build (the Ed25519-only
601                // tuner has no DSig paper to show).
602                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    // Controls, scrolled to keep the selection visible.
620    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    // Right: the two sides, one card per module in the mode, the chip.
645    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    // A uBFT slot: one share signed, 2f peers' shares verified.
704    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
804/// Run the planner on any backend until Q.
805pub 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
816/// Run the planner in the terminal.
817pub 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
825/// Render `app` into text (for tests and documentation).
826pub 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/// TUI rendering and key handling tests.
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use crate::{Component, Knob, Plan};
844
845    /// A module with one shared choice knob and nothing to plan.
846    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    /// Both papers render at several sizes; sign and verify are reported
866    /// separately; moving power from verify to sign moves the rates.
867    #[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); // Mode -> DSig, rebalanced.
880        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        // Every split sums to 100: foreground + background, HBSS sign +
884        // verify, Ed25519 sign + verify; a manual background is applied.
885        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        // demo::chip order: Ed sign, Ed verify, DSig sign, DSig verify.
890        assert!((p[0] + p[1] - b).abs() < 1e-9 && (p[2] + p[3] - (100.0 - b)).abs() < 1e-9, "{b} {p:?}");
891        // A shared knob is listed once and sets every module that has it.
892        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    /// A chip with a uBFT service offers its mode after the papers; the
908    /// mode plans on the whole device and its headline is a slot's crypto
909    /// time with explicit baseline and resource limitations. Switching back restores the
910    /// papers' budget.
911    #[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); // Ed25519 -> uBFT+Ed25519.
917        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); // -> DSig paper, back on the engine budget.
924        assert_eq!(app.mode, Mode::Dsig);
925        assert_eq!(app.budget, Budget::default());
926        println!("{u}");
927    }
928}