Skip to main content

rhdl_signatures/
paper.rs

1//! The modules of each paper's chip, as planner slots.
2//!
3//! [`slots`] lists every module this build has; the planner shows and sizes
4//! only the ones the chosen [`Mode`] uses (DSig modules only in the DSig
5//! paper). Module shares are derived from the sign and verify powers, so
6//! the starting powers here are placeholders.
7//!
8//! Which modules exist depends on the crate features (see the crate's
9//! "Features" section):
10//!
11//! | Feature | Slots it adds |
12//! |---|---|
13//! | `dsig` | DSig sign, DSig verify |
14//! | `ed25519` | Ed25519 sign, Ed25519 verify |
15//! | `sha512` | the SHA-512 line, from `rhdl_sha512_pipelined` |
16//! | `ed25519` without `sha512` | the SHA-512 line as the Ed25519 crate's [estimate](rhdl_ed25519_scalable_core::planner::Sha512Estimate) (same model, no AGPL crate) |
17//!
18//! A paper whose modules are missing is not offered by the TUI
19//! ([`App::has_paper`](rhdl_fpga_planner::tui::App::has_paper)).
20
21#[cfg(feature = "dsig")]
22use rhdl_dsig_scalable_core::planner::{Dsig, DsigVerify};
23#[cfg(feature = "ubft")]
24use rhdl_dsig_scalable_core::planner::{DsigUbftSign, DsigUbftVerify};
25#[cfg(feature = "ubft")]
26use rhdl_fpga_planner::ubft::UbftReplica;
27#[cfg(all(feature = "ed25519", not(feature = "sha512")))]
28use rhdl_ed25519_scalable_core::planner::Sha512Estimate;
29#[cfg(feature = "ed25519")]
30use rhdl_ed25519_scalable_core::planner::{Ed25519Sign, Ed25519Verify};
31use rhdl_fpga_planner::{Mode, Slot};
32#[cfg(feature = "sha512")]
33use rhdl_sha512_pipelined::planner::Sha512;
34
35/// Every module this build has, in display order.
36pub fn slots() -> Vec<Slot> {
37    #[allow(unused_mut)]
38    let mut slots = Vec::new();
39    #[cfg(feature = "dsig")]
40    {
41        slots.push(Slot::new(Box::new(Dsig::default()), 25.0));
42        slots.push(Slot::new(Box::new(DsigVerify::default()), 25.0));
43    }
44    #[cfg(feature = "ed25519")]
45    {
46        slots.push(Slot::new(Box::new(Ed25519Sign::default()), 25.0));
47        slots.push(Slot::new(Box::new(Ed25519Verify::default()), 25.0));
48    }
49    #[cfg(feature = "sha512")]
50    slots.push(Slot::new(Box::new(Sha512::default()), 0.0));
51    #[cfg(all(feature = "ed25519", not(feature = "sha512")))]
52    slots.push(Slot::new(Box::new(Sha512Estimate::default()), 0.0));
53    // The uBFT modes' modules: the replica once, then DSig's service (its
54    // signer certifies its own batches and its verifier checks certificates
55    // itself, so it needs no separate Ed25519 module).
56    #[cfg(feature = "ubft")]
57    {
58        slots.push(Slot::new(Box::new(UbftReplica::default()), 0.0));
59        slots.push(Slot::new(Box::new(DsigUbftSign::default()), 25.0));
60        slots.push(Slot::new(Box::new(DsigUbftVerify::default()), 25.0));
61        // Ed25519's service, without its own copy of the replica (index 0).
62        slots.extend(rhdl_ed25519_scalable_core::planner::ubft::slots().into_iter().skip(1));
63    }
64    slots
65}
66
67/// The papers this build can plan: the Ed25519 paper with `ed25519`, the
68/// DSig paper with `dsig`, and with `ubft` the two uBFT services (DSig,
69/// Ed25519).
70pub fn papers() -> Vec<Mode> {
71    #[allow(unused_mut)]
72    let mut papers = Vec::new();
73    #[cfg(feature = "dsig")]
74    papers.push(Mode::Dsig);
75    #[cfg(feature = "ed25519")]
76    papers.push(Mode::Ed25519);
77    #[cfg(feature = "ubft")]
78    {
79        papers.push(Mode::UbftDsig);
80        papers.push(Mode::UbftEd25519);
81    }
82    papers
83}
84
85/// The paper the TUI opens on.
86pub const DEFAULT_MODE: Mode = Mode::Ed25519;
87
88#[cfg(all(test, feature = "ed25519"))]
89mod ed25519_tests {
90    use super::*;
91    use rhdl_fpga_planner::tui::{App, snapshot};
92
93    /// Every paper of this build plans, balances and renders, and the TUI
94    /// offers exactly the papers the features compiled in.
95    #[test]
96    fn this_builds_papers_balance_and_render() {
97        let app = App::new("U280 SIGNATURE PLANNER", DEFAULT_MODE, slots());
98        assert_eq!(app.papers(), papers());
99        for mode in papers() {
100            let app = App::new("U280 SIGNATURE PLANNER", mode, slots());
101            let text = snapshot(&app, 140, 44);
102            println!("{text}");
103            let d = app.plan().delivered;
104            assert!(d.sign.rate > 0.0 && d.verify.rate > 0.0, "{mode:?}: sign {:?} verify {:?}", d.sign, d.verify);
105        }
106    }
107
108    /// Balanced rates at budget `(lut %, dsp %)` in `mode`.
109    pub(super) fn balanced(mode: Mode, lut: f64, dsp: f64) -> (f64, f64) {
110        let mut app = App::new("T", mode, slots());
111        app.budget.lut_percent = lut;
112        app.budget.dsp_percent = dsp;
113        app.balance();
114        let d = app.plan().delivered;
115        (d.sign.rate, d.verify.rate)
116    }
117
118    /// More LUTs or more DSPs never lower the balanced rates (within 2 %,
119    /// for the coarse steps of real builds), and doubling both raises them,
120    /// for every paper of this build. Prints the table (run with
121    /// `--nocapture`). The uBFT modes are left out: their modules are the
122    /// smallest builds on purpose (a slot needs one signature), so a
123    /// bigger budget does not raise them.
124    #[test]
125    fn rates_scale_with_luts_and_dsps() {
126        for mode in papers().into_iter().filter(|m| !m.is_ubft()) {
127            let mut grid = Vec::new();
128            for lut in [50.0, 100.0, 200.0] {
129                let row: Vec<(f64, f64)> = [50.0, 100.0, 200.0].iter().map(|&dsp| balanced(mode, lut, dsp)).collect();
130                println!(
131                    "{mode:?} LUT {lut:>3}%: {}",
132                    row.iter().map(|(s, v)| format!("{:5.2}M/{:5.2}M", s / 1e6, v / 1e6)).collect::<Vec<_>>().join("  ")
133                );
134                grid.push(row);
135            }
136            let min = |x: (f64, f64)| x.0.min(x.1);
137            for i in 0..3 {
138                for j in 0..3 {
139                    if i > 0 {
140                        assert!(min(grid[i][j]) >= 0.98 * min(grid[i - 1][j]), "{mode:?} LUT step at {i},{j}: {grid:?}");
141                    }
142                    if j > 0 {
143                        assert!(min(grid[i][j]) >= 0.98 * min(grid[i][j - 1]), "{mode:?} DSP step at {i},{j}: {grid:?}");
144                    }
145                }
146            }
147            assert!(min(grid[2][2]) > 1.3 * min(grid[1][1]), "{mode:?}: 200/200 vs 100/100 {grid:?}");
148        }
149    }
150
151    /// The SHA-512 line on auto (the default) never limits the chip: for
152    /// every paper and budget the balanced plan is not SHA-bound, and the
153    /// line is no bigger than it needs (well under the old 65k at 100 %).
154    #[test]
155    fn the_auto_sha_line_never_limits() {
156        // A uBFT chip has no SHA-512 line (its services carry their own
157        // hash lanes), so only the papers are checked.
158        for mode in papers().into_iter().filter(|m| !m.is_ubft()) {
159            for (lut, dsp) in [(50.0, 50.0), (100.0, 100.0), (200.0, 200.0)] {
160                let mut app = App::new("T", mode, slots());
161                assert!(app.sha_auto());
162                app.budget.lut_percent = lut;
163                app.budget.dsp_percent = dsp;
164                app.balance();
165                let p = app.plan();
166                let (k, _) = app.sha_lut_knob().unwrap();
167                let line = p.parts[k].1.as_ref().map_or("none".to_string(), |x| x.detail.clone());
168                println!("{mode:?} LUT {lut} DSP {dsp}: {:.2}M/{:.2}M, SHA allowance {} ({line})", p.delivered.sign.rate / 1e6, p.delivered.verify.rate / 1e6, p.guaranteed[k].lut);
169                assert!(!p.delivered.sha_bound, "{mode:?} {lut}/{dsp}: SHA-bound with {line}");
170                if lut == 100.0 {
171                    assert!(p.guaranteed[k].lut < 40_000, "{mode:?}: auto line {} LUT", p.guaranteed[k].lut);
172                }
173            }
174        }
175    }
176
177    /// The LUT split row sets the SHA-512 line by hand (leaving auto) in
178    /// 0.5 % steps of the budget, the split always sums to 100, the LUTs go
179    /// to or from the engines, and the line can grow far past the default.
180    #[test]
181    fn lut_split_moves_the_sha_line() {
182        use rhdl_fpga_planner::tui::Control;
183        // Papers only: a uBFT chip has no SHA-512 line.
184        for mode in papers().into_iter().filter(|m| !m.is_ubft()) {
185            let mut app = App::new("T", mode, slots());
186            let (k, j) = app.sha_lut_knob().expect("a SHA line");
187            let before = app.slots[k].component.knobs()[j].value;
188            let rest = app.budget.rest(&app.slots).lut;
189            app.selected = app.controls().iter().position(|c| *c == Control::ShaSplit).unwrap();
190            app.adjust(10.0);
191            assert!(!app.sha_auto(), "moving the split makes the size manual");
192            let after = app.slots[k].component.knobs()[j].value;
193            assert!(after - before >= 20_000.0, "{mode:?}: {before} -> {after}");
194            assert_eq!(app.budget.rest(&app.slots).lut + (after - before) as u32, rest);
195            let (sha, dsig, ed) = app.lut_split();
196            assert!((sha + dsig + ed - 100.0).abs() < 1e-6, "{mode:?}: {sha} + {dsig} + {ed}");
197            // +50 more steps of 0.5 % (~120k more): the allowance grows, and
198            // the line is the largest one-core line (the arbiter drives one
199            // core): a full pipeline, one block per clock.
200            app.adjust(50.0);
201            let big = app.slots[k].component.knobs()[j].value;
202            assert!(big >= after + 90_000.0, "{mode:?}: allowance {after} -> {big}");
203            let line = app.plan().parts[k].1.clone().expect("a line");
204            assert!((line.rate - 205e6).abs() < 1.0, "{mode:?}: {}", line.detail);
205            assert!(app.plan().total.fits(&app.budget.chip()));
206        }
207    }
208
209    /// Without DSig the TUI offers only the Ed25519 paper: P stays put and
210    /// says why, and the headline shows one paper.
211    #[cfg(not(feature = "dsig"))]
212    #[test]
213    fn ed25519_only_build_offers_one_paper() {
214        let mut app = App::new("T", Mode::Dsig, slots());
215        assert_eq!(app.mode, Mode::Ed25519, "a DSig request opens the Ed25519 paper");
216        app.switch_paper();
217        assert_eq!(app.mode, Mode::Ed25519);
218        let text = snapshot(&app, 140, 44);
219        assert!(!text.contains("DSig paper:") && !text.contains("P switches"), "{text}");
220    }
221}
222
223#[cfg(all(test, feature = "dsig"))]
224mod dsig_tests {
225    use super::*;
226    use rhdl_fpga_planner::tui::{App, snapshot};
227
228    /// DSig grows slice by slice as LUTs grow (reaching 4 slices well before
229    /// 200 %), and the screen shows both papers' rates at an ordinary size.
230    #[test]
231    fn dsig_slices_follow_luts_and_rates_are_visible() {
232        // DSig's "table rounds" knob (AES rounds per Haraka lane in T-tables:
233        // 0 = v3 routed, 6 = v5 synthesized, between interpolated), set
234        // separately for the signer and the verify units (7 = auto: the
235        // fastest count in the share). Printed for a few
236        // combinations: tables cost BRAM, LUT S-boxes cost LUTs.
237        for (sign_rounds, verify_rounds) in [(7.0, 7.0), (6.0, 6.0), (6.0, 0.0), (3.0, 3.0)] {
238            println!("table rounds: sign {sign_rounds}, verify {verify_rounds}");
239            for lut in [100, 150, 200] {
240                let mut app = App::new("T", Mode::Dsig, slots());
241                for (slot, rounds) in [(0, sign_rounds), (1, verify_rounds)] {
242                    let k = app.slots[slot].component.knobs().iter().position(|k| k.name == "table rounds").expect("table rounds knob");
243                    app.slots[slot].component.set_knob(k, rounds);
244                }
245                app.budget.lut_percent = lut as f64;
246                app.balance();
247                let p = app.plan();
248                let d = &p.delivered;
249                let detail = |k: usize| p.parts[k].1.as_ref().map_or("-".to_string(), |x| x.detail.split(',').next().unwrap_or("").to_string());
250                println!(
251                    "  LUT {lut}%: {:.2}M DSig sign / {:.2}M DSig verify | {} | {} | chip {} LUT {} BRAM36",
252                    d.sign.rate / 1e6,
253                    d.verify.rate / 1e6,
254                    detail(0),
255                    detail(1),
256                    p.total.lut,
257                    p.total.bram36
258                );
259            }
260        }
261        println!("v3 LUT S-box lanes (routed):");
262        let mut slices_seen = Vec::new();
263        for lut in (100..=200).step_by(10) {
264            let mut app = App::new("T", Mode::Dsig, slots());
265            app.budget.lut_percent = lut as f64;
266            app.balance();
267            let p = app.plan();
268            let d = &p.delivered;
269            let detail = |k: usize| p.parts[k].1.as_ref().map_or("-".to_string(), |x| x.detail.split(';').next().unwrap_or("").to_string());
270            let slices: u32 = p.parts[0].1.as_ref().and_then(|x| x.detail.split_whitespace().next()?.parse().ok()).unwrap_or(0);
271            println!(
272                "LUT {lut}%: {:.2}M DSig sign / {:.2}M DSig verify | sign: {} | verify: {} | EdDSA {:.1} %",
273                d.sign.rate / 1e6,
274                d.verify.rate / 1e6,
275                detail(0),
276                detail(1),
277                app.used.share
278            );
279            slices_seen.push(slices);
280        }
281        assert!(slices_seen.windows(2).all(|w| w[1] + 1 >= w[0]), "slices drop as LUTs grow: {slices_seen:?}");
282        assert!(slices_seen.iter().any(|&n| n >= 4), "never reaches 4 slices: {slices_seen:?}");
283        let app = App::new("T", Mode::Ed25519, slots());
284        let text = snapshot(&app, 120, 40);
285        assert!(text.contains("DSig paper:") && text.contains("Ed25519 paper:"), "{text}");
286        println!("{}", text.lines().take(3).collect::<Vec<_>>().join("\n"));
287    }
288
289    /// The DSig hash and HBSS knobs work from the TUI: listed under both DSig
290    /// modules, either row setting both (both knobs show the new value) and
291    /// changing the DSig sign build; HORS says it is not implemented. The
292    /// EdDSA background reports what DSig needs. Which chain hash is cheaper
293    /// depends on the budget and table rounds; the DSig crate's
294    /// `planner::tests::rate_scales_slice_by_slice_and_with_the_hash` owns
295    /// that comparison.
296    #[test]
297    fn dsig_scheme_knobs_reach_both_modules() {
298        use rhdl_fpga_planner::tui::Control;
299        let mut app = App::new("T", Mode::Dsig, slots());
300        let knob = |app: &App, name: &str| {
301            app.controls()
302                .into_iter()
303                .filter(|c| matches!(c, Control::Knob(k, j) if app.slots[*k].component.knobs()[*j].name == name))
304                .collect::<Vec<_>>()
305        };
306        for name in ["HBSS", "chain hash", "Winternitz w", "batch K"] {
307            assert_eq!(knob(&app, name).len(), 2, "{name} under DSig sign and DSig verify");
308        }
309        // The planner reports how much EdDSA the DSig chip needs, and every
310        // split sums to 100.
311        let u = app.used;
312        println!("EdDSA background needed {:.1} % (sign {:.1} % of it)", u.needed_share, u.needed_sign);
313        assert!(u.needed_share > 0.0 && u.needed_share < 50.0, "{u:?}");
314        assert!((app.powers.sign + app.powers.verify - 100.0).abs() < 1e-9);
315        let p: f64 = app.slots.iter().filter(|s| s.component.fixed().is_none()).map(|s| s.power).sum();
316        assert!((p - 100.0).abs() < 1e-6, "module powers sum to {p}");
317        // The chain hash reaches the DSig sign plan: it changes. Whether
318        // BLAKE3 is cheaper depends on the budget (with less room the
319        // planner moves Haraka S-boxes into block-RAM tables, and a Haraka
320        // slice is then smaller than a BLAKE3 one), so that comparison
321        // lives in the DSig crate's own test with fixed table rounds.
322        let build = |app: &App| app.plan().parts[0].1.as_ref().map(|p| (p.cost, p.detail.clone()));
323        let before = build(&app);
324        app.selected = app.controls().iter().position(|c| *c == knob(&app, "chain hash")[0]).unwrap();
325        app.adjust(1.0); // Haraka-512 -> BLAKE3, on both modules.
326        // Both modules' chain-hash knobs moved, not just the signer's.
327        for c in knob(&app, "chain hash") {
328            let Control::Knob(k, j) = c else { unreachable!() };
329            assert_eq!(app.slots[k].component.knobs()[j].show(), "BLAKE3", "{}", app.slots[k].component.name());
330        }
331        let after = build(&app);
332        assert!(before.is_some() && after.is_some(), "a DSig sign build before and after");
333        assert_ne!(after, before, "the chain hash changes the DSig sign build");
334
335        app.selected = app.controls().iter().position(|c| *c == knob(&app, "HBSS")[0]).unwrap();
336        app.adjust(1.0); // W-OTS+ -> HORS
337        let text = snapshot(&app, 160, 50);
338        assert!(text.contains("HORS is not implemented"), "{text}");
339    }
340}
341
342#[cfg(all(test, feature = "dsig"))]
343mod usage {
344    use super::*;
345    use rhdl_fpga_planner::tui::App;
346
347    #[test]
348    /// Every balanced plan fits the budget, for both papers, with and
349    /// without DSig's URAM T-tables; prints each module's build and what the
350    /// chip uses (run with `--nocapture`).
351    fn every_plan_fits_the_budget() {
352        // DSig's "table RAM": block RAM only, or then URAM (the default);
353        // and its "S-box RAM": LUT only (the default), or fill what the
354        // tables leave.
355        for (mode, uram, sbox) in [(Mode::Ed25519, false, false), (Mode::Dsig, false, false), (Mode::Dsig, true, false), (Mode::Dsig, true, true)] {
356            for (lut, dsp) in [(50.0, 50.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)] {
357                let mut app = App::new("T", mode, slots());
358                if mode == Mode::Dsig {
359                    for k in 0..2 {
360                        let j = app.slots[k].component.knobs().iter().position(|x| x.name == "table RAM").unwrap();
361                        app.slots[k].component.set_knob(j, if uram { 1.0 } else { 0.0 });
362                        if let Some(j) = app.slots[k].component.knobs().iter().position(|x| x.name == "S-box RAM") {
363                            app.slots[k].component.set_knob(j, if sbox { 0.0 } else { 1.0 });
364                        }
365                    }
366                }
367                app.budget.lut_percent = lut;
368                app.budget.dsp_percent = dsp;
369                app.balance();
370                let p = app.plan();
371                let c = app.budget.chip();
372                println!("{mode:?} uram-tables {uram} sbox-ram {sbox} LUT {lut} DSP {dsp}: {:.2}M/{:.2}M  used {}k/{}k LUT {}/{} DSP {}/{} BRAM {}/{} URAM  bg {:.1}",
373                    p.delivered.sign.rate/1e6, p.delivered.verify.rate/1e6, p.total.lut/1000, c.lut/1000, p.total.dsp, c.dsp, p.total.bram36, c.bram36, p.total.uram, c.uram, app.used.share);
374                for (k, (sh, pl)) in p.parts.iter().enumerate() {
375                    if let Some(pl) = pl {
376                        println!("   {:<16} cap {:>7.2}M  cost {:>6} LUT {:>4} DSP {:>4} BRAM {:>3} URAM  (share {} LUT {} DSP) {}", app.slots[k].component.name(), pl.rate/1e6, pl.cost.lut, pl.cost.dsp, pl.cost.bram36, pl.cost.uram, sh.lut, sh.dsp, pl.detail.chars().take(70).collect::<String>());
377                    }
378                }
379                assert!(p.total.fits(&c), "over budget: {:?} > {c:?}", p.total);
380            }
381        }
382    }
383
384    #[test]
385    /// A small budget still signs: at 50 % LUTs and DSPs one DSig slice no
386    /// longer fits sign's guaranteed share, and before the planner's floor
387    /// for a starved side (`floor_starved`) verify took sign's unused share
388    /// too (0 signatures/s, 17.57M verifications/s).
389    /// Both sides deliver once the budget holds each side's smallest build:
390    /// the Ed25519 paper at 50 %, the DSig paper at 75 %. Why not the DSig
391    /// paper at 50 % (242k LUT): with measured costs its smallest signer
392    /// (~122k with the Ed25519 certificate signer), smallest verifier (a
393    /// verify unit plus the 82k 1,1,1,1 Ed25519 verifier) and the SHA line
394    /// need more than that, so a plan there must starve a side.
395    fn both_sides_deliver_once_their_minimums_fit() {
396        for mode in papers() {
397            let mut app = App::new("T", mode, slots());
398            // A uBFT mode starts from the whole device (the replica's
399            // footprint already counts the shell) and keeps it.
400            if !mode.is_ubft() {
401                let percent = if mode == Mode::Dsig { 75.0 } else { 50.0 };
402                app.budget.lut_percent = percent;
403                app.budget.dsp_percent = percent;
404            }
405            app.balance();
406            let p = app.plan();
407            assert!(p.delivered.sign.rate > 0.0 && p.delivered.verify.rate > 0.0, "{mode:?}: {:?}", p.delivered);
408        }
409    }
410
411    #[test]
412    /// Every build flag the TUI writes is a TOML value: a number, a bool or
413    /// a quoted string (`measure/export_build.py` parses the file with
414    /// `tomllib`), and every module's table says whether it is buildable.
415    fn build_flags_are_toml_values() {
416        for mode in papers() {
417            for lut in [50.0, 100.0, 200.0] {
418                let mut app = App::new("T", mode, slots());
419                app.budget.lut_percent = lut;
420                app.balance();
421                let p = app.plan();
422                for (k, (_, plan)) in p.parts.iter().enumerate() {
423                    let Some(plan) = plan else { continue };
424                    let c = &app.slots[k].component;
425                    let flags = c.build_flags(plan);
426                    for (key, v) in &flags {
427                        let ok = v.parse::<f64>().is_ok() || v == "true" || v == "false" || (v.len() >= 2 && v.starts_with('"') && v.ends_with('"'));
428                        assert!(ok, "{}: {key} = {v} is not a TOML value", c.name());
429                    }
430                    if c.name().starts_with("DSig") {
431                        assert!(flags.iter().any(|(key, _)| key == "buildable"), "{} has no buildable flag", c.name());
432                    }
433                }
434            }
435        }
436    }
437}
438
439#[cfg(all(test, feature = "ubft"))]
440mod ubft_tests {
441    use super::*;
442    use rhdl_fpga_planner::tui::{App, snapshot};
443    use rhdl_fpga_planner::ubft::{quorum_verifies, serial_slot_crypto_us};
444
445    /// Both uBFT papers plan their service beside the one replica, deliver
446    /// both sides on the whole device and render the serial crypto model.
447    /// This is a model contract, not a matched baseline performance test.
448    #[test]
449    fn ubft_papers_plan_beside_the_replica() {
450        assert!(papers().contains(&Mode::UbftDsig) && papers().contains(&Mode::UbftEd25519));
451        // The replica is in the slot list once.
452        let replicas = slots().iter().filter(|s| s.component.name().starts_with("uBFT replica")).count();
453        assert_eq!(replicas, 1);
454        for mode in [Mode::UbftDsig, Mode::UbftEd25519] {
455            let app = App::new("U280 SIGNATURE PLANNER", mode, slots());
456            println!("{}", snapshot(&app, 140, 44));
457            let p = app.plan();
458            let d = &p.delivered;
459            assert!(d.sign.rate > 0.0 && d.verify.rate > 0.0, "{mode:?}: {d:?}");
460            for n in [3, 7] { // Within the default capacity of eight.
461                let projected = serial_slot_crypto_us(d, n);
462                assert!(projected.is_finite() && projected > 0.0);
463                assert_eq!(projected, d.sign.latency_us + f64::from(quorum_verifies(n)) * d.verify.latency_us);
464            }
465            if mode == Mode::UbftDsig {
466                assert_eq!(app.used.share, 0.0);
467                for (slot, (_, part)) in app.slots.iter().zip(&p.parts) {
468                    if matches!(slot.component.role(), rhdl_fpga_planner::Role::Ed25519Sign | rhdl_fpga_planner::Role::Ed25519Verify) {
469                        assert!(part.is_none(), "external certificate core was counted twice");
470                    }
471                }
472            }
473        }
474    }
475}