Skip to main content

rhdl_dsig_scalable_core/
planner.rs

1//! DSig as a component of the shared chip planner and TUI
2//! ([`rhdl_fpga_planner`]), next to Ed25519 Sign/Verify and the shared SHA-512.
3//!
4//! # The problem
5//!
6//! DSig is not built alone. The comparison paper puts a DSig signer and
7//! verifier, an Ed25519 signer and verifier and a shared SHA-512 line on one
8//! chip, and asks "with this much area, how many operations per second does
9//! each get?" The shared chip planner (`rhdl_fpga_planner`, a sibling crate
10//! with a terminal UI, or TUI) answers that by giving each design a *share*
11//! of the chip (LUTs, DSPs, block RAM, UltraRAM) and asking it for a *plan*.
12//! So DSig must speak the planner's language: the [`Component`] trait.
13//!
14//! # The reasoning
15//!
16//! [`crate::config`] already knows what DSig costs and how fast it is. This
17//! module does not repeat those measurements; it imports them and adds the
18//! knobs the comparison wants to explore. Some of those choices have never
19//! been synthesized, so their costs are **estimates**, and every [`Plan`]
20//! says whether it rests on measurement (`measured`).
21//!
22//! Two lessons shaped the plan search:
23//!
24//! * **A share is not the device.** Moving S-boxes into spare RAM must use
25//!   the RAM in DSig's share, not the whole chip's, or every partial share
26//!   fails the RAM check ([`config::Build::fill_within`]).
27//! * **Small shares still deserve an answer.** The smallest *built* engine
28//!   has two slices (~207k LUT with S-boxes in RAM). A one-slice engine is
29//!   offered as a **model** so a small share still plans something.
30//!
31//! # The decision
32//!
33//! Two components:
34//!
35//! * [`Dsig`], the signer we build. Its plan is the largest slice count that
36//!   fits the share.
37//! * [`DsigVerify`], a **model** of a verifier that is not built yet, so the
38//!   paper can compare sign and verify on the same footing.
39//!
40//! What the verifier model counts, step by step (nothing here is built):
41//!
42#![doc = concat!("<div style=\"overflow-x:auto\">", include_str!("../docs/images/dsig-verify.svg"), "</div>")]
43//!
44//! ```text
45//!   rhdl_fpga_planner (TUI)
46//!       │ Share {lut, dsp, bram36, uram}, Context {mhz, ...}
47//!       ▼
48//!   Dsig::plan ── for slices in 1..=16 ─────▶ Dsig::cost(slices, share) ──▶ fits?
49//!       │                                         │ yes: keep, try next size
50//!       │                                         │ no : stop, record the limit
51//!       ▼                                         ▼
52//!   Plan {cost, rate = slices·f/period (own core: min with K·f/895), demands, ...}
53//!       │
54//!       ▼
55//!   Dsig::build_flags ──▶ DSIG_SLICES, DSIG_CHAIN, DSIG_SBOX_PLACEMENT
56//! ```
57//!
58//! The cost model of one slice is taken apart into lanes so a knob can swap a
59//! lane:
60//!
61//! ```text
62//!   slice = slice_rest + factory BLAKE3 lane + 3 × chain lane
63//!   slice_rest = SLICE_LUT − 3 × HARAKA_LANE_LUT − BLAKE3_LANE_LUT
64//!   kernel = slices × slice + glue (+ Ed25519 core when DSig has its own)
65//! ```
66//!
67//! # Knobs that the signer and verifier share
68//!
69//! A verifier can only check what the signer made, so the scheme knobs
70//! (HBSS, chain hash, SHA lanes, `w`, `K`) are [shared](Knob::shared): the
71//! TUI lists them once, under DSig sign, and sets both modules.
72//!
73//! # Where the certificates come from
74//!
75//! Every batch of `K` keys is certified by one Ed25519 signature. Two ways:
76//!
77//! * **the chip's Ed25519 sign module** (default): DSig sends its batch roots
78//!   to the paper's Ed25519 signer, which the planner sizes for `1/K` of
79//!   DSig's rate. DSig's own cost then has no Ed25519 core.
80//! * **inside DSig (as built)**: the hardware built today carries its own
81//!   small Ed25519 signer (`ED25519_LUT`), capping DSig at `K × f / 895`.
82//!   The plan [covers](Plan::covers) Ed25519 signing, so the chip's Ed25519
83//!   sign module is not needed; counting both would count the signer twice.
84//!
85//! Knobs of [`Dsig`] (and, where they apply, [`DsigVerify`]):
86//!
87//! | Knob | Choices | What it changes | Status |
88//! |---|---|---|---|
89//! | HBSS | W-OTS+, HORS | the hash-based one-time scheme | W-OTS+ built; HORS **not implemented** (no plan) |
90//! | chain hash | Haraka-512, BLAKE3, SHA-512 | the lane in every slice (`chain-*` feature) | built, simulated |
91//! | SHA lanes | own, shared line | SHA-512 chains on DSig's own pipelines, or as clients of the shared line | own: built; shared: **model** |
92//! | Winternitz w | 4, 16 | the HBSS: chain length and chain count | 4 built; 16 **model** |
93//! | batch K | 128, 64, 256 | keys per Ed25519 certificate | 128 built; others **model** |
94//! | S-box RAM | fill, LUT only | Haraka S-boxes in the share's spare UltraRAM / block RAM | built |
95//! | BLAKE3 adders | LUT, DSP | 32-bit adders of every BLAKE3 lane in DSP48E2s | **planned**, estimated |
96//! | Ed25519 certificates | chip's Ed25519 sign module, inside DSig | who signs batch roots (above) | inside: built |
97//! | message | 16 B (profile 1), 64 B (profile 2) | signed message length, `DSIG_MESSAGE_BYTES`: profile 2 signs uBFT's whole 48-byte signed context; one more digest compression per signature (an idle timetable residue, so the same rate) | 16: built, measured; 64: simulated, cost **estimated** |
98//!
99//! Slice counts: any of 1 to 16. 2, 4 and 8 are built (`DSIG_SLICES=N`), and 2 is measured on the card (build v3: 786,432 signatures
100//! at 6,019,465/s, 34.06 cycles per signature); the others are a **model**
101//! with the same per-slice cost and `slices × f / key period` rate.
102//!
103//! Costs: measured (routed `slices-2` link and Vivado OOC) for the Haraka lane,
104//! the BLAKE3 lane, the slice and the Ed25519 core; **estimates** for the
105//! SHA-512 lane, the DSP-adder BLAKE3 lane, the one-slice build and S-box
106//! savings (see [`config`](crate::config)).
107
108use crate::config::{
109    self, DEVICE_URAM, KERNEL_BRAM36, ED25519_BRAM36, ED25519_CYCLES_PER_SIGN, ED25519_DSP, ED25519_LUT,
110    GLUE_BRAM36, GLUE_LUT, LANES_PER_SLICE, LUTS_PER_LUT_SBOX, SBOXES_PER_LANE, SLICE_BRAM36, SLICE_LUT,
111    SLICE_URAM,
112};
113use rhdl_fpga_planner::{BuildFlags, Component, Context, Cost, Flow, Knob, Plan, Role, SHA512_BLOCKS, Share};
114
115// ------------------------------------------------------------- lane costs
116//
117// A slice is split into lanes here so the "chain hash" knob can swap one lane
118// kind for another. Measured lanes come from Vivado out-of-context (OOC)
119// place-and-route; the rest are marked estimates.
120
121/// Haraka chain-step lane, Vivado OOC routed.
122///
123/// Unit: CLB LUTs per lane, all S-boxes in LUTs. A slice has three.
124pub const HARAKA_LANE_LUT: f64 = 23_902.0;
125/// BLAKE3 compression lane, Vivado OOC routed.
126///
127/// Unit: CLB LUTs per lane. Each slice's key factory has one, the
128/// certificate unit has one more, and with the BLAKE3 chain hash it also
129/// replaces each Haraka lane.
130pub const BLAKE3_LANE_LUT: f64 = 16_422.0;
131
132/// Kernel CLB LUTs of build v5 after **synthesis** (not routed): `slices-4`,
133/// T-tables in AES rounds 3–8 (`DSIG_TABLE_MASK=0x1f8`), Ed25519 inside.
134///
135/// For build v3, synthesis (301,465) and routing (296,868) agreed within 2 %.
136pub const V5_KERNEL_LUT_SYNTH: f64 = 358_614.0;
137
138/// One Haraka chain-step lane with T-tables in rounds 3–8 (**from
139/// synthesis**): what is left of a v5 slice after the parts a v3 slice also
140/// has.
141///
142/// ```text
143/// v5 slice  = (358,614 − Ed25519 25,383 − glue 20,564) / 4      = 78,167
144/// rest      = v3 slice 127,502 − 3 × 23,902 − BLAKE3 16,422      = 39,374
145/// lane      = (78,167 − 39,374 − 16,422) / 3                     ≈  7,457
146/// ```
147///
148/// Unit: CLB LUTs per lane (versus 23,902 with LUT S-boxes).
149pub const HARAKA_TABLE_LANE_LUT: f64 =
150    ((V5_KERNEL_LUT_SYNTH - ED25519_LUT - GLUE_LUT) / 4.0 - (SLICE_LUT - 3.0 * HARAKA_LANE_LUT - BLAKE3_LANE_LUT)
151        - BLAKE3_LANE_LUT)
152        / 3.0;
153
154/// BRAM36 tiles of one T-table lane: 176 dual-port BRAM18 ROMs (32 per
155/// table round in rounds 3–6, 24 in rounds 7–8 where 16 bytes are never
156/// observed). Build v5 synthesized 2,135 RAMB18 for its 12 lanes and caches.
157pub const HARAKA_TABLE_LANE_BRAM36: f64 = 88.0;
158
159/// T-table lookups per AES round that can become a table round, rounds 3
160/// to 8 in order (rounds 7 and 8 have 16 bytes nobody observes).
161const TABLE_ROUND_LOOKUPS: [f64; 6] = [64.0, 64.0, 64.0, 64.0, 48.0, 48.0];
162
163/// Choices of the "table rounds" knob: how many AES rounds of every Haraka
164/// lane, counting up from round 3, use T-tables (`DSIG_TABLE_MASK`).
165const TABLE_ROUNDS: [&str; 8] = [
166    "0 (LUT S-boxes; v3, routed)",
167    "1: r3 (interp.)",
168    "2: r3–4 (interp.)",
169    "3: r3–5 (interp.)",
170    "4: r3–6 (interp.)",
171    "5: r3–7 (interp.)",
172    "6: r3–8 (v5, synth)",
173    "auto (fastest in share)",
174];
175
176/// The "table rounds" value meaning *auto*: [`Component::plan`] tries 0..=6
177/// inside its share and keeps the fastest.
178pub const AUTO_TABLE_ROUNDS: u32 = 7;
179
180/// The best of several fixed-table-rounds plans: highest rate, then fewer
181/// BRAM36 (block RAM is what a T-table chip runs out of), then fewer LUTs.
182///
183/// This is what *auto* does. Because each component chooses inside the share
184/// the planner gave it, lending LUTs or block RAM to DSig automatically moves
185/// the choice toward more or fewer table rounds.
186fn best_over_table_rounds(plans: impl Iterator<Item = Plan>) -> Option<Plan> {
187    plans.reduce(|best, p| {
188        let better = p.rate > best.rate
189            || (p.rate == best.rate
190                && (p.cost.bram36, p.cost.lut) < (best.cost.bram36, best.cost.lut));
191        if better { p } else { best }
192    })
193}
194
195/// Table lookups (S-boxes replaced by T-table reads) in one lane's first
196/// `rounds` table rounds, counting up from round 3.
197#[must_use]
198pub fn table_lookups(rounds: u32) -> f64 {
199    TABLE_ROUND_LOOKUPS.iter().take(rounds as usize).sum()
200}
201
202/// LUTs and BRAM36 of one Haraka lane with its first `rounds` table rounds
203/// (0..=6, from round 3).
204///
205/// The two ends are builds: 0 is the routed v3 lane ([`HARAKA_LANE_LUT`]),
206/// 6 the lane derived from v5's synthesis ([`HARAKA_TABLE_LANE_LUT`]). In
207/// between, the LUT saving is **interpolated** per table lookup (352 lookups
208/// save 16,445 LUTs, ~46.7 each) and the block RAM is exact: two lookups per
209/// dual-port BRAM18, so a quarter of a BRAM36 per lookup. This is the lever
210/// for a BRAM-bound chip: fewer table rounds per lane cost LUTs but free
211/// block RAM for more lanes.
212#[must_use]
213pub fn table_lane(rounds: u32) -> (f64, f64) {
214    let lookups: f64 = TABLE_ROUND_LOOKUPS.iter().take(rounds as usize).sum();
215    let saving_per_lookup = (HARAKA_LANE_LUT - HARAKA_TABLE_LANE_LUT) / TABLE_ROUND_LOOKUPS.iter().sum::<f64>();
216    (HARAKA_LANE_LUT - lookups * saving_per_lookup, lookups / 4.0)
217}
218
219/// Whether the cost of a lane with `rounds` table rounds comes from a build
220/// (0 = v3 routed, 6 = v5 synthesis) rather than the interpolation. Every
221/// count is buildable (`DSIG_TABLE_MASK`); only the cost is interpolated.
222fn table_rounds_built(rounds: u32) -> bool {
223    rounds == 0 || rounds == 6
224}
225
226/// Choices of the "table RAM" knob: where T-table lookups live.
227const TABLE_RAM: [&str; 2] = ["block RAM (built)", "block RAM, then URAM (model)"];
228
229/// Table lookups one UltraRAM serves: one per port of a dual-port
230/// 4,096 × 72 URAM (a 256 × 32 T-table fills 1/16 of it). Block RAM serves
231/// four per BRAM36 (two per BRAM18), so URAM is the poorer home per tile,
232/// but a slices-4 kernel leaves ~670 of the device's 960 unused while block
233/// RAM is the wall.
234pub const LOOKUPS_PER_URAM: f64 = 2.0;
235
236/// LUTs of the loader one lane needs when any of its T-tables are in URAM:
237/// **estimate**. UltraRAM cannot be preloaded by the bitstream, so after
238/// every reset an 8-bit counter walks the 256 entries and writes each table
239/// (as [`crate::aes_table::SboxLoader`] does for URAM S-boxes); the lane
240/// reports not-ready until it finishes. Not built for T-tables.
241pub const TABLE_LOADER_LUT: f64 = 200.0;
242
243/// Splits `bram36` worth of table block RAM into what fits in `free_bram36`
244/// and the rest moved to URAM (at most `free_uram`): returns (BRAM36 kept,
245/// URAM used). What fits in neither stays in block RAM, so an over-full
246/// share shows up as a block RAM limit.
247fn spill_tables(bram36: f64, free_bram36: f64, free_uram: f64) -> (f64, f64) {
248    let kept = bram36.min(free_bram36.max(0.0));
249    // Lookups that did not fit, at four per BRAM36.
250    let over = (bram36 - kept) * 4.0;
251    let uram = (over / LOOKUPS_PER_URAM).ceil().min(free_uram.max(0.0));
252    (bram36 - uram * LOOKUPS_PER_URAM / 4.0, uram)
253}
254/// BLAKE3 lane with its ~224 32-bit adders in DSPs: **estimate**
255/// (each adder ~32 LUTs → one DSP48E2).
256///
257/// Unit: CLB LUTs per lane. This knob is planned, not built.
258pub const BLAKE3_DSP_LANE_LUT: f64 = 16_422.0 - 224.0 * 32.0;
259/// DSPs of a BLAKE3 lane with DSP adders: **estimate**.
260///
261/// Unit: DSP48E2s per lane, one per adder.
262pub const BLAKE3_LANE_DSP: u32 = 224;
263/// SHA-512 lane (`rhdl_sha512_pipelined`, 80 unrolled rounds): **estimate**
264/// until synthesized (80 × ~420 + 64 × ~150 LUTs).
265///
266/// Unit: CLB LUTs per lane, used only when the chain hash is SHA-512 and
267/// the lanes are DSig's own.
268pub const SHA512_LANE_LUT: f64 = 43_200.0;
269/// A TOML string: the TUI writes flag values verbatim into
270/// `build-config.toml`, so strings carry their own quotes.
271fn quoted(value: &str) -> String {
272    format!("{value:?}")
273}
274
275/// The `buildable` flag, and `not_buildable` listing every reason a plan
276/// cannot be built as planned today; `measure/export_build.py` refuses such
277/// a table. Same keys as the Ed25519 planner.
278fn buildable<'a>(gaps: impl Iterator<Item = &'a str>) -> BuildFlags {
279    let gaps: Vec<&str> = gaps.collect();
280    let mut flags = vec![("buildable".into(), gaps.is_empty().to_string())];
281    if !gaps.is_empty() {
282        flags.push(("not_buildable".into(), quoted(&gaps.join("; "))));
283    }
284    flags
285}
286
287/// A chain lane that sends its blocks to the shared SHA-512 line instead of
288/// hashing them itself: block assembly, a tag FIFO and the truncation
289/// (**estimate**).
290///
291/// Unit: CLB LUTs per lane.
292pub const SHA512_CLIENT_LUT: f64 = 1_500.0;
293/// Latency of one chain step, in cycles, for Haraka-512, BLAKE3 and SHA-512
294/// (indexed by the chain-hash knob; measured in simulation).
295pub const CHAIN_LATENCY: [f64; 3] = [20.0, 28.0, 82.0];
296
297/// Choices of the verifier's "certificate checks" knob.
298const VERIFY_CERTS: [&str; 2] = ["chip's Ed25519 verify module", "own Ed25519 verify core (as built)"];
299
300/// Display names of the message-length choices (index 1 = profile 2).
301pub const MESSAGES: [&str; 2] = ["16 B (profile 1)", "64 B (profile 2, uBFT context)"];
302/// Profile 2's extra LUTs per slice (**estimate**, not synthesized): the
303/// signer's tail block and CV registers (24 words) with their muxes and
304/// the factory's chained-message request path.
305const MESSAGE64_SLICE_LUT: f64 = 1_200.0;
306/// Profile 2's extra LUTs per verify unit (**estimate**): the loader's tail
307/// row and the hasher's second digest step.
308const MESSAGE64_VERIFY_LUT: f64 = 400.0;
309
310/// Display names of the chain-hash choices, indexed by `chain_hash`.
311const HASHES: [&str; 3] = ["Haraka-512", "BLAKE3", "SHA-512"];
312/// Display names of the SHA-lane choices.
313const SHA_ROUTES: [&str; 2] = ["own lanes", "shared line (model)"];
314/// The Winternitz parameters offered; index 0 is the built one.
315const WINTERNITZ: [u32; 2] = [4, 16];
316/// The batch sizes offered; index 0 is the built one.
317const BATCHES: [u32; 3] = [128, 64, 256];
318
319// ------------------------------------------------------------------- HBSS
320
321/// Which hash-based one-time signature scheme DSig uses.
322#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
323pub enum Scheme {
324    /// W-OTS+ (Winternitz chains): what the hardware implements.
325    #[default]
326    WotsPlus,
327    /// HORS (few-time signatures from a set of revealed secrets): offered
328    /// so the TUI and build flags are ready, but **not implemented**, and
329    /// there is no cost model yet, so it plans nothing.
330    Hors,
331}
332
333/// The schemes in the order of the HBSS knob.
334const SCHEMES: [Scheme; 2] = [Scheme::WotsPlus, Scheme::Hors];
335
336/// What the planner says when the HBSS knob is set to HORS.
337const HORS_TODO: &str = "HORS is not implemented yet (no hardware, no cost model): set HBSS back to W-OTS+";
338
339/// The hash-based one-time signature scheme (HBSS) and its batching.
340///
341/// DSig signs with W-OTS+: a message digest is cut into base-`w` digits, one
342/// hash chain per digit plus a few checksum chains. Each chain is hashed
343/// `w − 1` times at key generation. Larger `w` means fewer, longer chains:
344/// shorter signatures, more hashing. The built hardware is `w = 4`, 128-bit
345/// digests (64 digits + 4 checksum digits = 68 chains of 3 steps) and
346/// `K = 128` keys per batch; every other combination is a **model**.
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
348pub struct Hbss {
349    /// The scheme; only W-OTS+ has hardware and a cost model.
350    pub scheme: Scheme,
351    /// Winternitz parameter: digits are base `w`, chains have `w − 1` steps.
352    pub w: u32,
353    /// Keys per batch, certified by one Ed25519 signature (`K`).
354    pub batch: u32,
355}
356
357impl Default for Hbss {
358    fn default() -> Self {
359        Self { scheme: Scheme::WotsPlus, w: 4, batch: 128 }
360    }
361}
362
363impl Hbss {
364    /// Bits of the message digest that the digits encode.
365    pub const DIGEST_BITS: u32 = 128;
366
367    /// The scheme the hardware implements.
368    #[must_use]
369    pub fn built(self) -> bool {
370        self.scheme == Scheme::WotsPlus && self.w == 4 && self.batch == 128
371    }
372
373    /// Message digits: `128 / log2 w`.
374    #[must_use]
375    pub fn message_digits(self) -> u32 {
376        Self::DIGEST_BITS / self.w.trailing_zeros()
377    }
378
379    /// Checksum digits: the fewest base-`w` digits that can hold the largest
380    /// checksum, `message_digits × (w − 1)`.
381    #[must_use]
382    pub fn checksum_digits(self) -> u32 {
383        // Largest possible checksum value.
384        let max = u64::from(self.message_digits()) * u64::from(self.w - 1);
385        // Count digits until w^d exceeds it.
386        let mut digits = 0;
387        let mut reach = 1_u64;
388        while reach <= max {
389            reach *= u64::from(self.w);
390            digits += 1;
391        }
392        digits
393    }
394
395    /// Hash chains per key (68 for the built scheme).
396    #[must_use]
397    pub fn chains(self) -> u32 {
398        self.message_digits() + self.checksum_digits()
399    }
400
401    /// Chain steps to make one key: every chain hashed `w − 1` times (204).
402    #[must_use]
403    pub fn steps_per_key(self) -> f64 {
404        f64::from(self.chains() * (self.w - 1))
405    }
406
407    /// BLAKE3 compressions to make one key: 3 for seeds and nonces, the XOF
408    /// that makes 18 secret bytes per chain, the commitment over 18 endpoint
409    /// bytes per chain (plus a 16-byte prefix), and 2 for the Merkle leaf.
410    #[must_use]
411    pub fn blake3_per_key(self) -> f64 {
412        // Bytes of chain material per key.
413        let bytes = f64::from(self.chains()) * 18.0;
414        3.0 + (bytes / 64.0).ceil() + ((bytes + 16.0) / 64.0).ceil() + 2.0
415    }
416
417    /// Cycles one slice needs per key: its three chain lanes do three steps
418    /// per clock, its BLAKE3 lane one compression per clock, and the chain
419    /// engine takes one chain per clock. The busiest of the three sets the
420    /// period. For the built scheme every term but BLAKE3 is 68.
421    #[must_use]
422    pub fn key_period(self) -> f64 {
423        (self.steps_per_key() / f64::from(LANES_PER_SLICE))
424            .ceil()
425            .max(self.blake3_per_key())
426            .max(f64::from(self.chains()))
427    }
428
429    /// Merkle levels from a key to its batch root: `log2 K`.
430    #[must_use]
431    pub fn merkle_height(self) -> u32 {
432        self.batch.trailing_zeros()
433    }
434
435    /// Signature bytes: one 18-byte node per chain, one 32-byte sibling per
436    /// Merkle level, and 194 bytes of fixed fields (nonces, indices, root,
437    /// certificate). 1,642 for the built scheme.
438    #[must_use]
439    pub fn signature_bytes(self) -> u32 {
440        self.chains() * 18 + self.merkle_height() * 32 + 194
441    }
442
443    /// Expected chain steps to finish one verification. The built scheme
444    /// uses [`VERIFY_CHAIN_STEPS`]; for other `w` each chain needs on average
445    /// half its length.
446    #[must_use]
447    pub fn verify_steps(self) -> f64 {
448        if self.w == 4 {
449            VERIFY_CHAIN_STEPS * f64::from(self.chains()) / 68.0
450        } else {
451            f64::from(self.chains() * (self.w - 1)) / 2.0
452        }
453    }
454
455    /// BLAKE3 compressions per verification: the endpoint commitment (its
456    /// blocks plus one parent per extra 1 KiB chunk), the message digest and
457    /// one per Merkle level. 21 + 1 + 7 = 29 for the built scheme, as the
458    /// hardware counts (native simulation: 29.00 per record).
459    #[must_use]
460    pub fn verify_blake3(self) -> f64 {
461        let bytes = f64::from(self.chains()) * 18.0 + 16.0;
462        (bytes / 64.0).ceil() + ((bytes / 1024.0).ceil() - 1.0) + 1.0 + f64::from(self.merkle_height())
463    }
464
465    /// A short label, e.g. `W-OTS+ w=4, 68 chains, K=128, 1642 B`.
466    #[must_use]
467    pub fn label(self) -> String {
468        format!(
469            "W-OTS+ w={}, {} chains, K={}, {} B sig",
470            self.w,
471            self.chains(),
472            self.batch,
473            self.signature_bytes()
474        )
475    }
476
477    /// Knobs for the scheme, `w` and `K`, all shared between DSig sign and
478    /// verify.
479    fn knobs(self) -> [Knob; 3] {
480        [
481            Knob::choice("HBSS", SCHEMES.iter().position(|&x| x == self.scheme).unwrap_or(0), &["W-OTS+", "HORS (not implemented)"]).shared(),
482            Knob::choice(
483                "Winternitz w",
484                WINTERNITZ.iter().position(|&w| w == self.w).unwrap_or(0),
485                &["4", "16 (model)"],
486            )
487            .shared(),
488            Knob::choice(
489                "batch K",
490                BATCHES.iter().position(|&k| k == self.batch).unwrap_or(0),
491                &["128", "64 (model)", "256 (model)"],
492            )
493            .shared(),
494        ]
495    }
496
497    /// Applies HBSS knob `index` (0 scheme, 1 `w`, 2 `K`) set to choice
498    /// `pick`.
499    fn set(&mut self, index: usize, pick: usize) {
500        match index {
501            0 => self.scheme = SCHEMES[pick.min(SCHEMES.len() - 1)],
502            1 => self.w = WINTERNITZ[pick.min(WINTERNITZ.len() - 1)],
503            _ => self.batch = BATCHES[pick.min(BATCHES.len() - 1)],
504        }
505    }
506}
507
508/// The certificate ceiling: one Ed25519 signature per batch of `K` keys,
509/// one Ed25519 signature every [`ED25519_CYCLES_PER_SIGN`] cycles.
510fn certificate_ceiling(hbss: Hbss, mhz: f64) -> f64 {
511    f64::from(hbss.batch) * mhz * 1e6 / ED25519_CYCLES_PER_SIGN
512}
513
514// ----------------------------------------------------------------- signer
515
516/// The DSig component.
517///
518/// The signer as the chip planner sees it. Its fields are the planner knobs;
519/// [`Component::knobs`] shows them and [`Component::set_knob`] changes them.
520/// Only the default chain hash (Haraka-512) with the default HBSS matches the
521/// preserved `DSIG_FPGA_W4_HARAKA_BLAKE3_V1` profile.
522#[derive(Clone, Debug)]
523pub struct Dsig {
524    /// 0 Haraka-512, 1 BLAKE3, 2 SHA-512.
525    ///
526    /// Index into the hash name list; picks the lane type of every slice
527    /// (the `DSIG_CHAIN` build variable).
528    pub chain_hash: usize,
529    /// SHA-512 chains only: hash on the shared SHA-512 line instead of
530    /// DSig's own pipelines (**model**). The line's blocks then go to DSig
531    /// as well as Ed25519, so the planner balances them.
532    pub sha_shared: bool,
533    /// The hash-based scheme and its batch size.
534    pub hbss: Hbss,
535    /// Profile 2: sign 64-byte messages (`DSIG_MESSAGE_BYTES=64`).
536    pub message64: bool,
537    /// Fill the share's spare RAM with Haraka S-boxes.
538    ///
539    /// `true` uses [`config::Build::fill_within`]; `false` keeps every
540    /// S-box in LUTs. Only matters for Haraka-512.
541    pub sbox_ram: bool,
542    /// BLAKE3 adders in DSPs.
543    ///
544    /// Planned and estimated only: trades LUTs for DSPs in every BLAKE3 lane.
545    pub blake3_dsp: bool,
546    /// Certify batches with DSig's own Ed25519 core (as built) instead of the
547    /// chip's Ed25519 sign module.
548    pub own_ed25519: bool,
549    /// AES rounds of every Haraka lane, from round 3 up, computed with
550    /// block-RAM T-tables (0..=6; see [`table_lane`]), or
551    /// [`AUTO_TABLE_ROUNDS`] (the default) to pick the fastest inside the
552    /// share, so block RAM the share leaves over becomes signing speed. 0 is
553    /// build v3 (LUT S-boxes, routed), 6 is build v5 (rounds 3–8, synthesis).
554    /// Any value above 0 overrides "S-box RAM": the remaining S-boxes stay in
555    /// LUTs.
556    pub table_rounds: u32,
557    /// T-table lookups that do not fit the share's block RAM move to its
558    /// spare UltraRAM ([`LOOKUPS_PER_URAM`], plus a [`TABLE_LOADER_LUT`]
559    /// loader per lane). **Model**: the URAM T-table and its loader are not
560    /// built. On by default (the user's choice: spare URAM should speed DSig
561    /// up); the detail line and `not_buildable` mark a plan that uses it.
562    pub table_uram: bool,
563}
564
565/// Haraka-512 chains, the built HBSS, auto table rounds (as many T-table
566/// rounds as the share's block RAM makes fastest), BLAKE3 adders in LUTs,
567/// certificates from the chip's Ed25519 sign module (the modular chip of the
568/// paper). Set "Ed25519 certificates" to "inside DSig" for the routed,
569/// measured kernel; turn "S-box RAM" to "fill" to explore the (estimated) RAM
570/// placement.
571impl Default for Dsig {
572    fn default() -> Self {
573        Self {
574            chain_hash: 0,
575            sha_shared: false,
576            hbss: Hbss::default(),
577            message64: false,
578            sbox_ram: false,
579            blake3_dsp: false,
580            own_ed25519: false,
581            table_rounds: AUTO_TABLE_ROUNDS,
582            table_uram: true,
583        }
584    }
585}
586
587impl Dsig {
588    /// Largest slice count the planner may choose. Every count from 1 is
589    /// tried; 2, 4 and 8 are built (`DSIG_SLICES=N`, see
590    /// [`Dsig::BUILT_SLICES`]); the rest are a **model** with the same
591    /// per-slice cost (1 needs 7-bit key numbers in a slice, today 6).
592    pub const MAX_SLICES: u32 = 16;
593    /// Slice counts `DSIG_SLICES` accepts.
594    pub const BUILT_SLICES: [u32; 3] = [2, 4, 8];
595
596    /// Whether the chain lanes hash on the shared SHA-512 line.
597    #[must_use]
598    pub fn uses_shared_line(&self) -> bool {
599        self.chain_hash == 2 && self.sha_shared
600    }
601
602    /// The S-box placement for `slices` slices inside `share`.
603    ///
604    /// Only Haraka lanes with "S-box RAM" on place S-boxes. The T-tables
605    /// (and their URAM spill) take RAM first; the S-boxes of the remaining
606    /// rounds then fill what is left, block RAM first (no loader), then
607    /// UltraRAM, in dual-port pairs: four per BRAM36, two per URAM.
608    #[must_use]
609    pub fn placement(&self, slices: u32, share: &Share) -> config::Build {
610        if self.chain_hash != 0 || !self.sbox_ram {
611            return config::Build::lut_only(slices);
612        }
613        let lanes = f64::from(slices * LANES_PER_SLICE);
614        let (_, table_bram, table_uram) = self.tables(slices, share);
615        let own = if self.own_ed25519 { 1.0 } else { 0.0 };
616        let fixed = f64::from(slices) * SLICE_BRAM36 + own * ED25519_BRAM36 + GLUE_BRAM36;
617        let free_bram = f64::from(share.bram36).min(KERNEL_BRAM36) - fixed - table_bram;
618        let free_uram = f64::from(share.uram.min(DEVICE_URAM)) - f64::from(slices * SLICE_URAM) - table_uram;
619        // Boxes a lane still has once its table rounds are tables.
620        let boxes = SBOXES_PER_LANE - table_lookups(self.table_rounds) as u32;
621        let bram = ((4.0 * free_bram / lanes).floor().max(0.0) as u32).min(boxes);
622        let uram = ((2.0 * free_uram / lanes).floor().max(0.0) as u32).min(boxes - bram);
623        config::Build { slices, uram_per_lane: uram, bram_per_lane: bram }
624    }
625
626    /// T-table RAM of `slices` slices inside `share`: (lookups per lane,
627    /// BRAM36, URAM). With "table RAM" on URAM, the lookups the share's
628    /// block RAM cannot hold move to its spare UltraRAM.
629    fn tables(&self, slices: u32, share: &Share) -> (f64, f64, f64) {
630        if self.chain_hash != 0 || self.table_rounds == 0 {
631            return (0.0, 0.0, 0.0);
632        }
633        let lanes = f64::from(slices * LANES_PER_SLICE);
634        let own = if self.own_ed25519 { 1.0 } else { 0.0 };
635        let fixed = f64::from(slices) * SLICE_BRAM36 + own * ED25519_BRAM36 + GLUE_BRAM36;
636        let bram = table_lane(self.table_rounds).1 * lanes;
637        let (bram, uram) = if self.table_uram {
638            spill_tables(
639                bram,
640                f64::from(share.bram36).min(KERNEL_BRAM36) - fixed,
641                f64::from(share.uram.min(DEVICE_URAM)) - f64::from(slices * SLICE_URAM),
642            )
643        } else {
644            (bram, 0.0)
645        };
646        (table_lookups(self.table_rounds), bram, uram)
647    }
648
649    /// Cost of `slices` slices inside `share` with the current knobs, plus a
650    /// description.
651    ///
652    /// The whole kernel: `slices` slices, the Ed25519 core and the glue. The
653    /// returned string is the one-line description shown in the planner.
654    #[must_use]
655    pub fn cost(&self, slices: u32, share: &Share) -> (Cost, String) {
656        // Chain lanes in the whole kernel (three per slice).
657        let lanes = f64::from(slices * LANES_PER_SLICE);
658        // LUTs of one factory BLAKE3 lane under the adder knob.
659        let factory_blake3 = if self.blake3_dsp { BLAKE3_DSP_LANE_LUT } else { BLAKE3_LANE_LUT };
660        // DSPs of one BLAKE3 lane under the adder knob (0 when off).
661        let blake3_dsp_per = if self.blake3_dsp { BLAKE3_LANE_DSP } else { 0 };
662        // A slice without its three chain lanes or its factory BLAKE3 lane.
663        let slice_rest = SLICE_LUT - 3.0 * HARAKA_LANE_LUT - BLAKE3_LANE_LUT;
664        // The S-box placement (all-LUT unless Haraka with S-box RAM).
665        let build = self.placement(slices, share);
666        // Per-lane LUTs and DSPs of the chosen chain hash.
667        let (lane_lut, lane_dsp) = match self.chain_hash {
668            // Haraka: the measured lane minus the LUTs freed by each S-box
669            // moved to RAM.
670            // Haraka with T-tables: the per-lane figure derived from build v5.
671            // Either way, each S-box placed in RAM frees its LUTs, and a
672            // lane with UltraRAM S-boxes adds its loader.
673            0 => {
674                let base = if self.table_rounds > 0 { table_lane(self.table_rounds).0 } else { HARAKA_LANE_LUT };
675                let placed = f64::from(build.uram_per_lane + build.bram_per_lane);
676                let loader = if build.uram_per_lane > 0 { config::LOADER_LUT } else { 0.0 };
677                (base - placed * LUTS_PER_LUT_SBOX + loader, 0)
678            }
679            // BLAKE3 chains: each chain lane is another BLAKE3 lane.
680            1 => (factory_blake3, blake3_dsp_per),
681            // SHA-512 chains on the shared line: only a small client per lane.
682            _ if self.sha_shared => (SHA512_CLIENT_LUT, 0),
683            // SHA-512 chains on DSig's own pipelines.
684            _ => (SHA512_LANE_LUT, 0),
685        };
686        // Slice count as f64 for the LUT sum.
687        let s = f64::from(slices);
688        // Kernel LUTs: per-slice rest and factory lane, every chain lane, the
689        // Ed25519 core and the glue (which contains the cert unit's BLAKE3
690        // lane, so the DSP-adder saving is applied to it once here).
691        // The Ed25519 certificate core, when DSig carries its own.
692        let own = if self.own_ed25519 { 1.0 } else { 0.0 };
693        let message = if self.message64 { MESSAGE64_SLICE_LUT } else { 0.0 };
694        let lut = s * (slice_rest + factory_blake3 + message) + lanes * lane_lut + own * ED25519_LUT + GLUE_LUT
695            + if self.blake3_dsp { -224.0 * 32.0 } else { 0.0 }; // the cert unit's BLAKE3 lane
696        // Kernel DSPs: Ed25519, then one BLAKE3 lane per slice plus the cert
697        // unit's (hence `slices + 1`), then any DSPs in the chain lanes.
698        let dsp = u32::from(self.own_ed25519) * ED25519_DSP + (slices + 1) * blake3_dsp_per + slices * LANES_PER_SLICE * lane_dsp;
699        // RAM taken by placed S-boxes: one URAM each, or half a BRAM36 each.
700        // UltraRAM S-boxes: one URAM per pair, rounded up per lane.
701        let uram_placed = build.uram_per_lane.div_ceil(2) * slices * LANES_PER_SLICE;
702        // Block RAM outside the tables and S-boxes: caches, Ed25519, glue.
703        let fixed_bram = s * SLICE_BRAM36 + own * ED25519_BRAM36 + GLUE_BRAM36;
704        // T-table RAM, spilled to URAM if "table RAM" allows it.
705        let (_, table_bram, table_uram) = self.tables(slices, share);
706        // A loader in every lane, once any table is in URAM.
707        let lut = lut + if table_uram > 0.0 { lanes * TABLE_LOADER_LUT } else { 0.0 };
708        // Block-RAM S-boxes: two per BRAM18, a quarter of a BRAM36 each.
709        let bram_placed = f64::from(build.bram_per_lane) * lanes / 4.0 + table_bram;
710        // The planner's integer cost; LUTs are clamped at zero so an
711        // aggressive estimate can never go negative.
712        let cost = Cost {
713            lut: lut.max(0.0) as u32,
714            dsp,
715            bram36: (fixed_bram + bram_placed).ceil() as u32,
716            uram: slices * SLICE_URAM + uram_placed + table_uram as u32,
717        };
718        // The human-readable line: hash, routing, S-box split, scheme.
719        let route = if self.uses_shared_line() { " on the shared line" } else { "" };
720        let tables = if self.chain_hash == 0 && self.table_rounds > 0 {
721            format!(
722                ", T-tables in {} rounds/lane ({}){}",
723                self.table_rounds,
724                if table_rounds_built(self.table_rounds) { "v5 synth" } else { "cost interpolated" },
725                if table_uram > 0.0 { format!(", {table_uram:.0} URAM of tables (model)") } else { String::new() }
726            )
727        } else {
728            String::new()
729        };
730        let sboxes = if build.uram_per_lane + build.bram_per_lane > 0 {
731            let left = SBOXES_PER_LANE - table_lookups(self.table_rounds) as u32;
732            format!(
733                "{tables}, S-boxes/lane {} URAM + {} BRAM + {} LUT (pairs, est.)",
734                build.uram_per_lane,
735                build.bram_per_lane,
736                left - build.uram_per_lane - build.bram_per_lane
737            )
738        } else {
739            tables
740        };
741        let status = if self.measured(slices) {
742            "built, card-measured 6.02M/s"
743        } else if Self::BUILT_SLICES.contains(&slices) {
744            "built"
745        } else {
746            "model"
747        };
748        let certs = if self.own_ed25519 { "own Ed25519 core" } else { "certs by Ed25519 sign module" };
749        let detail = format!(
750            "{slices} slice{} ({status}), {} chains{route}{sboxes}; {}; {certs}",
751            if slices == 1 { "" } else { "s" },
752            HASHES[self.chain_hash],
753            self.hbss.label()
754        );
755        (cost, detail)
756    }
757
758    /// Signatures per second of `slices` slices at `mhz`.
759    ///
760    /// The slice ceiling `slices × f / key period`; with DSig's own Ed25519
761    /// core also capped by the certificate ceiling `K × f / 895` (otherwise
762    /// the planner sizes the chip's Ed25519 signer for `1/K` of this rate). Unlike
763    /// [`config::Build::signatures_per_second`] it leaves out the frame-write
764    /// stage, which is never the limit at these slice counts in that model.
765    /// With the shared SHA-512 line, the line's supply is checked by the
766    /// planner through this plan's demand, not here.
767    #[must_use]
768    pub fn rate(&self, slices: u32, mhz: f64) -> f64 {
769        // The slower of "keys made" and "batches certified × K".
770        let keys = f64::from(slices) * mhz * 1e6 / self.hbss.key_period();
771        if self.own_ed25519 { keys.min(certificate_ceiling(self.hbss, mhz)) } else { keys }
772    }
773
774    /// Whether this configuration rests on measurement: the routed
775    /// all-LUT Haraka two-slice kernel with the built scheme and its own
776    /// Ed25519 core (build v3, measured on the card).
777    fn measured(&self, slices: u32) -> bool {
778        self.chain_hash == 0
779            && !self.blake3_dsp
780            && slices == 2
781            && !self.sbox_ram
782            // (No table rounds, so "table RAM" has nothing to place.)
783            && self.table_rounds == 0
784            && self.hbss.built()
785            && self.own_ed25519
786    }
787}
788
789/// How the chip planner talks to the DSig signer.
790impl Component for Dsig {
791    /// The row label in the planner.
792    fn name(&self) -> String {
793        "DSig sign".into()
794    }
795
796    /// Tells the planner this component supplies DSig signatures.
797    fn role(&self) -> Role {
798        Role::DsigSign
799    }
800
801    /// The knobs, in the order [`Component::set_knob`] indexes them:
802    /// 0 HBSS, 1 chain hash, 2 SHA lanes, 3 Winternitz w, 4 batch K (these
803    /// five shared with [`DsigVerify`]), 5 S-box RAM, 6 BLAKE3 adders,
804    /// 7 Ed25519 certificates, 8 table rounds, 9 table RAM (the last two
805    /// the signer's own; the verify unit has separate ones).
806    fn knobs(&self) -> Vec<Knob> {
807        let [scheme, w, batch] = self.hbss.knobs();
808        vec![
809            scheme,
810            Knob::choice("chain hash", self.chain_hash, &HASHES).shared(),
811            Knob::choice("SHA lanes", usize::from(self.sha_shared), &SHA_ROUTES).shared(),
812            w,
813            batch,
814            // Choice 0 is "fill", so the flag is inverted for the index.
815            Knob::choice("S-box RAM", usize::from(!self.sbox_ram), &["fill", "LUT only"]),
816            Knob::choice("BLAKE3 adders", usize::from(self.blake3_dsp), &["LUT", "DSP (est.)"]),
817            Knob::choice(
818                "Ed25519 certs",
819                usize::from(self.own_ed25519),
820                &["chip's Ed25519 sign module", "inside DSig (as built: own small signer)"],
821            ),
822            Knob::choice("table rounds", self.table_rounds as usize, &TABLE_ROUNDS),
823            Knob::choice("table RAM", usize::from(self.table_uram), &TABLE_RAM),
824            Knob::choice("certificate checks", usize::from(self.own_ed25519), &VERIFY_CERTS),
825            Knob::choice("message", usize::from(self.message64), &MESSAGES).shared(),
826        ]
827    }
828
829    /// Applies a knob change from the planner. `value` is the chosen index
830    /// as a float; out-of-range indices are clamped to the last choice.
831    fn set_knob(&mut self, index: usize, value: f64) {
832        // The chosen index, never negative.
833        let pick = value.max(0.0) as usize;
834        match index {
835            0 => self.hbss.set(0, pick),
836            1 => self.chain_hash = pick.min(2),
837            2 => self.sha_shared = pick >= 1,
838            3 => self.hbss.set(1, pick),
839            4 => self.hbss.set(2, pick),
840            // Index 0 ("fill") means S-boxes in RAM.
841            5 => self.sbox_ram = pick == 0,
842            6 => self.blake3_dsp = pick >= 1,
843            7 => self.own_ed25519 = pick >= 1,
844            8 => self.table_rounds = pick.min(AUTO_TABLE_ROUNDS as usize) as u32,
845            9 => self.table_uram = pick >= 1,
846            10 => self.own_ed25519 = pick >= 1,
847            _ => self.message64 = pick >= 1,
848        }
849    }
850
851    /// HORS has no plan yet.
852    fn unavailable(&self) -> Option<String> {
853        (self.hbss.scheme == Scheme::Hors).then(|| HORS_TODO.into())
854    }
855
856    /// The largest slice count whose cost fits `share`, or `None` if even
857    /// one slice does not fit (or the HBSS is HORS, which has no model).
858    ///
859    /// Sizes are tried smallest first. The first size that does not fit stops
860    /// the search, and the resource it ran out of becomes the plan's `limit`,
861    /// so the planner can tell the user what to add.
862    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
863        // Auto: plan every fixed table-round count and keep the best.
864        if self.table_rounds == AUTO_TABLE_ROUNDS {
865            return best_over_table_rounds((0..=6).filter_map(|rounds| {
866                Dsig { table_rounds: rounds, ..self.clone() }.plan(share, cx)
867            }));
868        }
869        // The clock the planner is evaluating at.
870        let mhz = cx.mhz;
871        // The best fitting plan so far.
872        let mut best: Option<Plan> = None;
873        if self.hbss.scheme != Scheme::WotsPlus {
874            return None;
875        }
876        for slices in 1..=Self::MAX_SLICES {
877            // Kernel cost of this size under the current knobs and share.
878            let (cost, detail) = self.cost(slices, share);
879            if !cost.fits(share) {
880                // Too big: say what ran out (if a smaller size fit) and stop.
881                if let Some(plan) = best.as_mut() {
882                    plan.limit = cost.limit(share).into();
883                }
884                break;
885            }
886            // Fits: record it and try the next larger size.
887            let rate = self.rate(slices, mhz);
888            // Blocks the chain lanes take from the shared line, if they use it.
889            let demands = if self.uses_shared_line() {
890                vec![Flow {
891                    service: SHA512_BLOCKS,
892                    per_second: rate * self.hbss.steps_per_key(),
893                }]
894            } else {
895                Vec::new()
896            };
897            // What caps the rate: the slices or the certificate core.
898            let capped = self.own_ed25519 && certificate_ceiling(self.hbss, mhz) < f64::from(slices) * mhz * 1e6 / self.hbss.key_period();
899            best = Some(Plan {
900                cost,
901                rate,
902                unit: "signatures",
903                limit: if capped { "Ed25519 certificate core".into() } else { String::new() },
904                detail,
905                demands,
906                supplies: vec![Flow {
907                    service: "dsig signatures",
908                    per_second: rate,
909                }],
910                measured: self.measured(slices),
911                latency_cycles: SIGN_LATENCY_CYCLES,
912                // Little's law: requests in flight = rate × time in system.
913                in_flight: rate * SIGN_LATENCY_CYCLES / (mhz * 1e6),
914                // Keys per Ed25519 certificate: sets Ed25519 sign need (1/K)
915                // and the verify cache-hit fraction.
916                batch: Some(f64::from(self.hbss.batch)),
917                // With its own core DSig signs its certificates itself.
918                covers: if self.own_ed25519 { vec![Role::Ed25519Sign] } else { Vec::new() },
919            });
920        }
921        if let Some(plan) = best.as_mut() {
922            // Every size fit: the limit is the design itself, not the share.
923            if plan.limit.is_empty() {
924                plan.limit = format!("slice count ({} is the model's largest)", Self::MAX_SLICES);
925            }
926        }
927        best
928    }
929
930    /// The build variables that build `plan` (`DSIG_SLICES`, `DSIG_CHAIN`
931    /// and, for Haraka, `DSIG_TABLE_MASK` and `DSIG_SBOX_PLACEMENT`; see
932    /// `build.rs`).
933    ///
934    /// The slice count is recovered from the plan's detail line (it starts
935    /// with the count). Haraka builds also get the matching
936    /// `DSIG_SBOX_PLACEMENT`, which needs the share; the TUI writes these
937    /// for the plan it shows, so the placement is recomputed from the plan's
938    /// own RAM cost.
939    fn build_flags(&self, plan: &Plan) -> BuildFlags {
940        // Slice count: the first word of the detail line.
941        let slices: u32 = plan.detail.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(2);
942        // `DSIG_CHAIN` value of the chosen chain hash.
943        let chain = ["haraka", "blake3", "sha512"][self.chain_hash];
944        // `key = value` pairs the TUI writes out; keys are prefixed `dsig.`.
945        let mut flags = vec![
946            ("dsig.DSIG_SLICES".into(), slices.to_string()),
947            ("dsig.DSIG_CHAIN".into(), quoted(chain)),
948            ("dsig.DSIG_MESSAGE_BYTES".into(), if self.message64 { "64" } else { "16" }.into()),
949        ];
950        if !self.own_ed25519 {
951            flags.push(("dsig.certificates".into(), quoted("chip Ed25519 sign module (root port; not built)")));
952        }
953        // Table rounds the plan used (auto resolves to a fixed count per
954        // plan; the detail line names it), and whether any table is in URAM.
955        let rounds: u32 = plan
956            .detail
957            .split("T-tables in ")
958            .nth(1)
959            .and_then(|t| t.split_whitespace().next())
960            .and_then(|n| n.parse().ok())
961            .unwrap_or(0);
962        let uram_tables = plan.detail.contains("URAM of tables");
963        // S-box placement only exists for Haraka lanes; the plan's own RAM
964        // cost is exactly the RAM its placement was allowed to use.
965        if self.chain_hash == 0 {
966            // T-table rounds 3 .. 3 + rounds (`DSIG_TABLE_MASK`, bits 3..=8).
967            flags.push(("dsig.DSIG_TABLE_MASK".into(), quoted(&format!("{:#x}", ((1_u32 << rounds) - 1) << 3))));
968            let build = Dsig { table_rounds: rounds, ..self.clone() }.placement(slices, &plan.cost);
969            flags.push((
970                "dsig.DSIG_SBOX_PLACEMENT".into(),
971                quoted(&format!("uram={},bram={}", build.uram_per_lane, build.bram_per_lane)),
972            ));
973        }
974        // Every reason this plan cannot be built as planned today.
975        let slice_gap = format!("{slices} slice{} (DSIG_SLICES builds 2, 4 or 8)", if slices == 1 { "" } else { "s" });
976        let gaps = [
977            (!self.hbss.built(), "HBSS parameters other than W-OTS+ w=4, K=128"),
978            (!Self::BUILT_SLICES.contains(&slices), slice_gap.as_str()),
979            (self.uses_shared_line(), "chain lanes on the shared SHA-512 line"),
980            (self.message64, "64-byte messages (profile 2): simulated, not yet exported or card-built"),
981            (self.blake3_dsp, "BLAKE3 adders in DSPs"),
982            (!self.own_ed25519, "certificates from the chip's Ed25519 sign module"),
983            (uram_tables, "T-tables in UltraRAM"),
984        ];
985        flags.extend(buildable(gaps.iter().filter(|(gap, _)| *gap).map(|(_, why)| *why)));
986        flags
987    }
988}
989
990/// Idle signing latency: **native simulation** (cycle-accurate RHDL, not the
991/// card) of the slice signer behind the uBFT service's request stream
992/// (`tests/ubft_signer.rs`, 2 slices, 8 isolated requests after the first
993/// certificate: 171–188 cycles, mean 173.1, every frame the reference
994/// signature). A key is cached and its certificate made, so this is the
995/// message digest, the 68-cycle schedule slot it waits for, the node reads
996/// from the key cache, the 26-beat frame and the relay registers. It replaces
997/// an unmeasured 64-cycle estimate (28 BLAKE3 + 26 beats + ~10 registers)
998/// that left out the schedule wait and the cache reads. The batch kernel's
999/// HBM reader and writer add their own cycles (not measured).
1000///
1001/// Unit: clock cycles. The planner uses it for idle latency and, with
1002/// Little's law, for the number of signatures in flight.
1003pub const SIGN_LATENCY_CYCLES: f64 = 173.0;
1004
1005/// Idle signing latency of the **whole uBFT service kernel**
1006/// ([`crate::ubft::UbftEngine`]): native simulation, `tests/ubft_engine.rs`,
1007/// request beat to the frame's last beat, 6 isolated requests after the
1008/// first certificate. Profile 1: 207–217 cycles, mean 208.7 (1.01–1.06 µs
1009/// at 205 MHz); profile 2 (64-byte messages): 207–257, mean 215.3. It is
1010/// [`SIGN_LATENCY_CYCLES`] (the signer alone) plus the engine's stream
1011/// registers and arbitration, about 36 cycles. The uBFT signing component
1012/// uses this one, since a replica sees the whole kernel.
1013#[cfg(feature = "ubft")]
1014pub const UBFT_SIGN_LATENCY_CYCLES: f64 = 209.0;
1015
1016// ------------------------------------------------------------ verify model
1017//
1018// The verifier is built ([`crate::verify`]): units of three recirculating
1019// chain lanes and one BLAKE3 lane, plus (as built) the Ed25519 project's
1020// known-key verify core for certificates. A unit walks each chain from the
1021// revealed node to its end, rebuilds the endpoint commitment, then climbs
1022// the Merkle path to the batch root:
1023//
1024//   revealed nodes ──(3 − digit) chain steps each──▶ 68 ends
1025//        ──21 BLAKE3──▶ commitment ──7 BLAKE3──▶ root ──(cache miss)──▶ Ed25519
1026//
1027// Where each figure comes from:
1028//
1029// | Figure | Value | Provenance |
1030// |---|---|---|
1031// | core cost, Haraka, 4 table rounds | 89,627 LUT, 282 BRAM36 | verifier kernel build v1 routed (31e0144, mean of 3 cores); alone (OOC, fba2a3d) 80,211 LUT, 273 BRAM36, WNS +0.258 ns |
1032// | cycles per verification | 35.12 | native simulation, 256 records (91bebd0) |
1033// | idle latency of the hash path | 729 cycles | native simulation, one record, instant certificate |
1034// | unit I/O (reader, writer, its share of the cluster) | 6,083 LUT | build v1 routed |
1035// | Ed25519 verify core (fix14) | 79,532 LUT, 698 DSP, 43 BRAM36, 176 URAM | build v3 routed (OOC 007zzr: 84,435 LUT, WNS +0.072 ns) |
1036// | kernel timing at 205 MHz | met, WNS +0.016 ns, 368,713 LUT | build v3 routed |
1037// | kernel rate on the card | ≥ 16,959,195 verifications/s, 3 units | build v3, finite burst of 1,572,864 |
1038// | its rate and latency | 0.279M/s; 52,670 cycles isolated, 63,026 median loaded | Ed25519 project, native simulation |
1039
1040/// Expected chain steps to finish one DSig verification: 68 W-OTS+ chains
1041/// (w = 4) each need `3 − digit` steps; the 64 message digits are uniform
1042/// (mean 1.5 steps) and the 4 checksum digits skew low.
1043///
1044/// Unit: chain steps per verification (expected value). Native simulation
1045/// of 256 real records measured 101.4.
1046pub const VERIFY_CHAIN_STEPS: f64 = 105.0;
1047/// CLB LUTs of one routed verify core (Haraka lanes with
1048/// [`VERIFY_CORE_TABLE_ROUNDS`] table rounds) inside the kernel:
1049/// **measured**, mean of the three cores of verifier build v1 (Vivado
1050/// 2023.2 routed, 89,495–89,716). Routed alone (OOC) it was 80,211.
1051pub const VERIFY_CORE_LUT: f64 = 89_627.0;
1052/// Block-RAM tiles of that core in the kernel (88 RAMB36 + 388 RAMB18):
1053/// **measured**, build v1.
1054pub const VERIFY_CORE_BRAM36: f64 = 282.0;
1055/// Table rounds of the measured core's lanes (`DSIG_TABLE_MASK=0x078`).
1056pub const VERIFY_CORE_TABLE_ROUNDS: u32 = 4;
1057/// LUTs of a unit beyond its core: HBM reader and writer and its share of
1058/// the cluster (registered links, registers, arbiter). **Measured**, build
1059/// v1: (366,916 engine − 79,786 Ed25519) / 3 units − 89,627 per core.
1060pub const VERIFY_UNIT_IO_LUT: f64 = 6_083.0;
1061/// Cycles per verification of one core for the built scheme: **native
1062/// simulation** (two whole batches, steady state).
1063pub const VERIFY_CYCLES_MEASURED: f64 = 35.12;
1064/// Idle latency of the hash path for the built scheme (first beat to
1065/// verdict, certificate already checked): **native simulation**.
1066pub const VERIFY_LATENCY_MEASURED: f64 = 729.0;
1067/// The Ed25519 known-key verify core a verifier embeds (fix14, constant
1068/// banks as `ConstRom`): CLB LUTs inside the verifier kernel, **measured**
1069/// (build v3, routed, `engine/c1`: 79,532). Alone it routes at 84,435 with
1070/// WNS +0.072 ns (Ed25519 project, 007zzr; fix12 in build v1: 79,786 in
1071/// context, 82,184 alone).
1072pub const VERIFY_ED25519_LUT: f64 = 79_532.0;
1073/// The verifier kernel's card rate: **measured** (build v3, three units,
1074/// 1,572,864 valid signatures in 19,012,289 cycles at 205.00 MHz, finite
1075/// burst `vrun-clean-1`): 12.09 cycles per signature for the kernel, 36.3
1076/// per unit.
1077pub const VERIFY_CARD_RATE: f64 = 16_959_195.0;
1078/// Units in the card-measured verifier kernel.
1079pub const VERIFY_CARD_UNITS: u32 = 3;
1080/// Its DSP slices (**measured**, 007zzi).
1081pub const VERIFY_ED25519_DSP: u32 = 698;
1082/// Its block-RAM tiles (41 RAMB36 + 4 RAMB18; **measured**, 007zzi).
1083pub const VERIFY_ED25519_BRAM36: u32 = 43;
1084/// Its UltraRAMs (**measured**, 007zzi).
1085pub const VERIFY_ED25519_URAM: u32 = 176;
1086/// Its certificate checks per second at 205 MHz: **native simulation**
1087/// (Ed25519 project, fix12 dcb2e6e: 600 verifications, 735.25 cycles each).
1088pub const VERIFY_ED25519_RATE: f64 = 278_810.0;
1089/// Its latency per check, saturated median: **native simulation** (Ed25519
1090/// project: min 54,144, median 63,026, p90 81,738 cycles; isolated ~52k).
1091/// The verifier's verdict queue hides it after a run's first batch.
1092pub const VERIFY_ED25519_LATENCY: f64 = 63_026.0;
1093/// Its latency for an isolated check: **native simulation** (Ed25519
1094/// project, one request at a time, 4 samples: 52,642–52,672, median 52,670).
1095pub const VERIFY_ED25519_ISOLATED: f64 = 52_670.0;
1096
1097/// DSig verification (built: [`crate::verify`]).
1098///
1099/// A verify unit has three chain lanes and one BLAKE3 lane, like a slice.
1100/// Its rate is set by its busier side: chain steps over three lanes, or
1101/// BLAKE3 compressions on one lane. The Ed25519 certificate check (`1 − h`
1102/// of verifications) is done by the Ed25519 verify component, not here.
1103/// Its knobs must match the signer's for the two to verify each other.
1104#[derive(Clone, Debug)]
1105pub struct DsigVerify {
1106    /// 0 Haraka-512, 1 BLAKE3, 2 SHA-512 (must match the signer).
1107    pub chain_hash: usize,
1108    /// SHA-512 chains on the shared line instead of own pipelines.
1109    pub sha_shared: bool,
1110    /// The hash-based scheme (must match the signer).
1111    pub hbss: Hbss,
1112    /// Profile 2: 64-byte messages (must match the signer).
1113    pub message64: bool,
1114    /// Table rounds of the unit's three Haraka lanes (0..=6, see
1115    /// [`table_lane`], or [`AUTO_TABLE_ROUNDS`], the default). Independent of the signer's: the verify unit is
1116    /// separate hardware, so a BRAM-bound chip can give tables to one and
1117    /// LUT S-boxes to the other.
1118    pub table_rounds: u32,
1119    /// Table lookups beyond the share's block RAM go to its UltraRAM, as
1120    /// [`Dsig::table_uram`]. **Model.** On by default, like the signer's.
1121    pub table_uram: bool,
1122    /// Certificates are checked by the verifier's own Ed25519 verify core
1123    /// (as built) instead of the chip's Ed25519 verify module.
1124    pub own_ed25519: bool,
1125}
1126
1127/// Haraka-512 chains, the built HBSS and auto table rounds, matching the
1128/// default signer; certificates on the chip's Ed25519 verify module.
1129impl Default for DsigVerify {
1130    fn default() -> Self {
1131        Self {
1132            chain_hash: 0,
1133            sha_shared: false,
1134            hbss: Hbss::default(),
1135            message64: false,
1136            table_rounds: AUTO_TABLE_ROUNDS,
1137            table_uram: true,
1138            own_ed25519: false,
1139        }
1140    }
1141}
1142
1143impl DsigVerify {
1144    /// Whether the chain lanes hash on the shared SHA-512 line.
1145    #[must_use]
1146    pub fn uses_shared_line(&self) -> bool {
1147        self.chain_hash == 2 && self.sha_shared
1148    }
1149
1150    /// LUTs and block RAM of a verify core beyond its three chain lanes:
1151    /// the measured core minus its three lanes (as the lane model prices
1152    /// them). The BLAKE3 lane, scheduler, banks and cache are all in here.
1153    #[must_use]
1154    pub fn core_rest() -> (f64, f64) {
1155        let (lane_lut, lane_bram) = table_lane(VERIFY_CORE_TABLE_ROUNDS);
1156        (VERIFY_CORE_LUT - 3.0 * lane_lut, VERIFY_CORE_BRAM36 - 3.0 * lane_bram)
1157    }
1158
1159    /// Cost of one verify unit: three chain lanes of the chosen hash, the
1160    /// measured rest of the core, and the unit's HBM I/O.
1161    #[must_use]
1162    pub fn unit_cost(&self) -> Cost {
1163        // LUTs of one chain lane of the selected hash and routing.
1164        let lane = match self.chain_hash {
1165            0 => table_lane(self.table_rounds).0,
1166            1 => BLAKE3_LANE_LUT,
1167            _ if self.sha_shared => SHA512_CLIENT_LUT,
1168            _ => SHA512_LANE_LUT,
1169        };
1170        let (rest_lut, rest_bram) = Self::core_rest();
1171        Cost {
1172            lut: (3.0 * lane + rest_lut + VERIFY_UNIT_IO_LUT + if self.message64 { MESSAGE64_VERIFY_LUT } else { 0.0 }) as u32,
1173            dsp: 0,
1174            bram36: (rest_bram + if self.chain_hash == 0 { 3.0 * table_lane(self.table_rounds).1 } else { 0.0 }).ceil()
1175                as u32,
1176            uram: 0,
1177        }
1178    }
1179
1180    /// The embedded Ed25519 verify core, when certificates are checked inside.
1181    #[must_use]
1182    pub fn ed25519_cost(&self) -> Cost {
1183        if self.own_ed25519 {
1184            Cost {
1185                lut: VERIFY_ED25519_LUT as u32,
1186                dsp: VERIFY_ED25519_DSP,
1187                bram36: VERIFY_ED25519_BRAM36,
1188                uram: VERIFY_ED25519_URAM,
1189            }
1190        } else {
1191            Cost::default()
1192        }
1193    }
1194
1195    /// Whether this configuration is the one measured (costs from Vivado,
1196    /// period from simulation).
1197    #[must_use]
1198    pub fn is_measured(&self) -> bool {
1199        self.chain_hash == 0
1200            && self.table_rounds == VERIFY_CORE_TABLE_ROUNDS
1201            && self.hbss == Hbss::default()
1202            && !self.table_uram
1203    }
1204
1205    /// Cost of `units` units inside `share`, and the URAM their tables took.
1206    ///
1207    /// Without "table RAM" on URAM this is `units × unit_cost`. With it, the
1208    /// table block RAM that the share cannot hold moves to UltraRAM, and every
1209    /// lane gets a [`TABLE_LOADER_LUT`] loader.
1210    #[must_use]
1211    pub fn units_cost(&self, units: u32, share: &Share) -> (Cost, f64) {
1212        let total = Cost::default().add(self.unit_cost(), units).add(self.ed25519_cost(), 1);
1213        let table_bram = if self.chain_hash == 0 { 3.0 * table_lane(self.table_rounds).1 * f64::from(units) } else { 0.0 };
1214        if !self.table_uram || table_bram == 0.0 {
1215            return (total, 0.0);
1216        }
1217        let fixed = f64::from(total.bram36) - table_bram;
1218        let (kept, uram) = spill_tables(table_bram, f64::from(share.bram36) - fixed, f64::from(share.uram));
1219        let loaders = if uram > 0.0 { 3.0 * f64::from(units) * TABLE_LOADER_LUT } else { 0.0 };
1220        let cost = Cost {
1221            lut: total.lut + loaders as u32,
1222            bram36: (fixed + kept).ceil() as u32,
1223            uram: total.uram + uram as u32,
1224            ..total
1225        };
1226        (cost, uram)
1227    }
1228
1229    /// Cycles per verification of one unit: the busier of chain steps over
1230    /// three lanes and BLAKE3 compressions on one lane (35 for the built
1231    /// scheme: 105 / 3 versus 29). The built scheme uses the measured period.
1232    #[must_use]
1233    pub fn cycles_per_verify(&self) -> f64 {
1234        if self.hbss == Hbss::default() && self.chain_hash == 0 {
1235            VERIFY_CYCLES_MEASURED
1236        } else {
1237            (self.hbss.verify_steps() / 3.0).max(self.hbss.verify_blake3())
1238        }
1239    }
1240
1241    /// Idle latency of the hash path. The built scheme uses the measured
1242    /// figure; others scale it by their chain and commitment depth.
1243    #[must_use]
1244    pub fn latency_cycles(&self) -> f64 {
1245        let model = |v: &DsigVerify| {
1246            f64::from(v.hbss.w - 1) * (CHAIN_LATENCY[v.chain_hash] + 1.0) + v.hbss.verify_blake3() * 34.0
1247        };
1248        VERIFY_LATENCY_MEASURED * model(self) / model(&DsigVerify::default())
1249    }
1250}
1251
1252/// How the chip planner talks to the verifier model.
1253impl Component for DsigVerify {
1254    /// The row label.
1255    fn name(&self) -> String {
1256        "DSig verify".into()
1257    }
1258
1259    /// Tells the planner this component supplies DSig verifications.
1260    fn role(&self) -> Role {
1261        Role::DsigVerify
1262    }
1263
1264    /// Knobs: 0 HBSS, 1 chain hash, 2 SHA lanes, 3 Winternitz w, 4 batch K,
1265    /// 5 table rounds, 6 table RAM. The first five are shared with the
1266    /// signer (the TUI shows them under DSig sign); the last two are the
1267    /// unit's own.
1268    fn knobs(&self) -> Vec<Knob> {
1269        let [scheme, w, batch] = self.hbss.knobs();
1270        vec![
1271            scheme,
1272            Knob::choice("chain hash", self.chain_hash, &HASHES).shared(),
1273            Knob::choice("SHA lanes", usize::from(self.sha_shared), &SHA_ROUTES).shared(),
1274            w,
1275            batch,
1276            Knob::choice("table rounds", self.table_rounds as usize, &TABLE_ROUNDS),
1277            Knob::choice("table RAM", usize::from(self.table_uram), &TABLE_RAM),
1278            Knob::choice("certificate checks", usize::from(self.own_ed25519), &VERIFY_CERTS),
1279            Knob::choice("message", usize::from(self.message64), &MESSAGES).shared(),
1280        ]
1281    }
1282
1283    /// Applies a knob change, clamping out-of-range indices.
1284    fn set_knob(&mut self, index: usize, value: f64) {
1285        let pick = value.max(0.0) as usize;
1286        match index {
1287            0 => self.hbss.set(0, pick),
1288            1 => self.chain_hash = pick.min(2),
1289            2 => self.sha_shared = pick >= 1,
1290            3 => self.hbss.set(1, pick),
1291            4 => self.hbss.set(2, pick),
1292            5 => self.table_rounds = pick.min(AUTO_TABLE_ROUNDS as usize) as u32,
1293            6 => self.table_uram = pick >= 1,
1294            7 => self.own_ed25519 = pick >= 1,
1295            _ => self.message64 = pick >= 1,
1296        }
1297    }
1298
1299    /// HORS has no plan yet.
1300    fn unavailable(&self) -> Option<String> {
1301        (self.hbss.scheme == Scheme::Hors).then(|| HORS_TODO.into())
1302    }
1303
1304    /// `DSIG_VERIFY_UNITS` and the lanes' table mask; what is not built yet
1305    /// is named.
1306    fn build_flags(&self, plan: &Plan) -> BuildFlags {
1307        let units: u32 = plan.detail.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(1);
1308        let mut gaps = Vec::new();
1309        if !self.own_ed25519 {
1310            gaps.push("certificates on the chip's Ed25519 verify module (built kernel embeds its own)");
1311        }
1312        if self.table_uram && plan.detail.contains("URAM of tables") {
1313            gaps.push("T-tables in URAM (model)");
1314        }
1315        if self.hbss != Hbss::default() {
1316            gaps.push("only W-OTS+ w=4, K=128 is built");
1317        }
1318        if self.message64 {
1319            gaps.push("64-byte messages (profile 2): simulated, not yet exported or card-built");
1320        }
1321        let mut flags = buildable(gaps.into_iter());
1322        flags.push(("dsig.DSIG_VERIFY_UNITS".into(), units.to_string()));
1323        flags.push(("dsig.DSIG_MESSAGE_BYTES".into(), if self.message64 { "64" } else { "16" }.into()));
1324        if self.chain_hash == 0 {
1325            let rounds = if self.table_rounds == AUTO_TABLE_ROUNDS { VERIFY_CORE_TABLE_ROUNDS } else { self.table_rounds };
1326            flags.push(("dsig.DSIG_TABLE_MASK".into(), quoted(&format!("{:#x}", ((1_u32 << rounds) - 1) << 3))));
1327        }
1328        flags.push(("dsig.DSIG_CHAIN".into(), quoted(["haraka", "blake3", "sha512"][self.chain_hash])));
1329        flags
1330    }
1331
1332    /// As many identical verify units as the share's LUTs and BRAM allow.
1333    ///
1334    /// Unlike the signer, units scale one by one, so the plan is a simple
1335    /// division. The Ed25519 check on cache misses is planned separately.
1336    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1337        if self.hbss.scheme != Scheme::WotsPlus {
1338            return None;
1339        }
1340        // Auto: plan every fixed table-round count and keep the best.
1341        if self.table_rounds == AUTO_TABLE_ROUNDS {
1342            return best_over_table_rounds((0..=6).filter_map(|rounds| {
1343                DsigVerify { table_rounds: rounds, ..self.clone() }.plan(share, cx)
1344            }));
1345        }
1346        // One unit's cost.
1347        let unit = self.unit_cost();
1348        let ed = self.ed25519_cost();
1349        if ed.lut > share.lut || ed.dsp > share.dsp || ed.bram36 > share.bram36 || ed.uram > share.uram {
1350            return None;
1351        }
1352        // Whole units that fit next to the Ed25519 core (if any), limited by
1353        // LUTs or by BRAM, whichever is tighter (`max(1)` avoids dividing by
1354        // zero).
1355        let mut units = ((share.lut - ed.lut) / unit.lut.max(1)).min((share.bram36 - ed.bram36) / unit.bram36.max(1));
1356        // With tables allowed in URAM, add units while the spilled cost fits.
1357        if self.table_uram {
1358            while self.units_cost(units + 1, share).0.fits(share) {
1359                units += 1;
1360            }
1361        }
1362        if units == 0 {
1363            return None;
1364        }
1365        // Verifications per second of all units together; an own Ed25519
1366        // core caps them at one certificate check per K verifications.
1367        let mut rate = f64::from(units) * cx.mhz * 1e6 / self.cycles_per_verify();
1368        if self.own_ed25519 {
1369            rate = rate.min(VERIFY_ED25519_RATE * cx.mhz / 205.0 * f64::from(self.hbss.batch));
1370        }
1371        // Idle latency of one verification.
1372        // Idle latency of the hash path, plus (with an own Ed25519 core) the
1373        // certificate check for the share of verifications whose batch is
1374        // not already checked: (1 − h) × the isolated check latency.
1375        let mut latency = self.latency_cycles();
1376        if self.own_ed25519 {
1377            latency += (1.0 - cx.cache_hit(f64::from(self.hbss.batch))) * VERIFY_ED25519_ISOLATED;
1378        }
1379        // Total cost of `units` units, and URAM taken by their tables.
1380        let (cost, table_uram) = self.units_cost(units, share);
1381        // Blocks taken from the shared line, if the lanes use it.
1382        let demands = if self.uses_shared_line() {
1383            vec![Flow {
1384                service: SHA512_BLOCKS,
1385                per_second: rate * self.hbss.verify_steps(),
1386            }]
1387        } else {
1388            Vec::new()
1389        };
1390        Some(Plan {
1391            cost,
1392            rate,
1393            unit: "verifications",
1394            // What would run out first if one more unit were added.
1395            limit: self.units_cost(units + 1, share).0.limit(share).into(),
1396            detail: format!(
1397                "{units} units × 3 {} lanes{} + 1 BLAKE3 lane; {:.2} cycles/verify ({}); {}; hit rate {:.1} %{}",
1398                HASHES[self.chain_hash],
1399                if self.chain_hash != 0 || self.table_rounds == 0 {
1400                    String::new()
1401                } else if table_uram > 0.0 {
1402                    format!(" ({} table rounds, {table_uram:.0} URAM of tables)", self.table_rounds)
1403                } else {
1404                    format!(" ({} table rounds)", self.table_rounds)
1405                },
1406                self.cycles_per_verify(),
1407                if self.hbss == Hbss::default() && self.chain_hash == 0 { "native sim" } else { "model" },
1408                self.hbss.label(),
1409                100.0 * cx.cache_hit(f64::from(self.hbss.batch)),
1410                if self.own_ed25519 { "; own Ed25519 verify core" } else { "" }
1411            ),
1412            demands,
1413            supplies: vec![Flow {
1414                service: "dsig verifies",
1415                per_second: rate,
1416            }],
1417            measured: self.is_measured(),
1418            latency_cycles: latency,
1419            in_flight: rate * latency / (cx.mhz * 1e6),
1420            batch: Some(f64::from(self.hbss.batch)),
1421            covers: if self.own_ed25519 { vec![Role::Ed25519Verify] } else { Vec::new() },
1422        })
1423    }
1424}
1425
1426/// Checks that the planner component agrees with measurement and behaves
1427/// sensibly as the budget grows.
1428// ------------------------------------------------------------ uBFT service
1429//
1430// DSig as uBFT's signature service (`crate::ubft`, feature `ubft`): the
1431// planner's uBFT modes (`rhdl_fpga_planner::ubft`) plan these two modules
1432// beside the uBFT replica. A slot needs one signature and 2f verifications,
1433// so the right build is the smallest one, and its idle latency is the
1434// headline: more slices or units would not shorten a lone request.
1435
1436/// Isolated verify latency of the uBFT service with its certificate already
1437/// checked: **native simulation** (`tests/ubft_blocks.rs`, instant Ed25519
1438/// model, header handshake to verdict: 732–736 cycles; the verify core alone
1439/// is [`VERIFY_LATENCY_MEASURED`]).
1440#[cfg(feature = "ubft")]
1441pub const UBFT_VERIFY_LATENCY_CYCLES: f64 = 734.0;
1442
1443/// Slice counts the uBFT signer can be built with (`DSIG_SLICES`).
1444#[cfg(feature = "ubft")]
1445pub const UBFT_SLICES: [u32; 3] = [2, 4, 8];
1446/// Their names in the Tuner.
1447#[cfg(feature = "ubft")]
1448const UBFT_SLICE_NAMES: [&str; 3] = ["2", "4", "8"];
1449/// The uBFT services' profile-1 gate in the Tuner.
1450#[cfg(feature = "ubft")]
1451const UBFT_PROFILE1: [&str; 2] = ["refused (U33)", "allowed (U33 open)"];
1452
1453/// The reason a uBFT plan in profile 1 is not buildable unless allowed:
1454/// profile 1 signs view, slot, k and m only, so the session, the role and
1455/// the CTB fields of uBFT's 48-byte context are not authenticated (audit
1456/// finding U33) and the CTB role cannot be signed at all.
1457#[cfg(feature = "ubft")]
1458const UBFT_PROFILE1_GAP: &str =
1459    "uBFT with 16-byte messages (profile 1) leaves U33 open and cannot sign CTB; set 'profile 1 for uBFT' to allowed";
1460
1461/// The uBFT services' message settings, shared by signer and verifier.
1462/// Profile 2 is the default: it signs uBFT's whole context.
1463#[cfg(feature = "ubft")]
1464#[derive(Clone, Copy, Debug)]
1465pub struct UbftMessage {
1466    /// Profile 2: 64-byte messages (`DSIG_MESSAGE_BYTES=64`).
1467    pub message64: bool,
1468    /// Accept profile 1 under uBFT anyway (`dsig.allow_profile1_ubft`).
1469    pub allow_profile1: bool,
1470}
1471
1472#[cfg(feature = "ubft")]
1473impl Default for UbftMessage {
1474    /// Profile 2, profile 1 refused.
1475    fn default() -> Self {
1476        Self { message64: true, allow_profile1: false }
1477    }
1478}
1479
1480#[cfg(feature = "ubft")]
1481impl UbftMessage {
1482    /// Its two knobs, both shared by the uBFT signer and verifier.
1483    fn knobs(self) -> [Knob; 2] {
1484        [
1485            Knob::choice("message", usize::from(self.message64), &MESSAGES).shared(),
1486            Knob::choice("profile 1 for uBFT", usize::from(self.allow_profile1), &UBFT_PROFILE1).shared(),
1487        ]
1488    }
1489    /// Sets knob `index` (0: message, 1: the profile-1 gate).
1490    fn set(&mut self, index: usize, value: f64) {
1491        match index {
1492            0 => self.message64 = value >= 1.0,
1493            1 => self.allow_profile1 = value >= 1.0,
1494            _ => {}
1495        }
1496    }
1497    /// Whether the plan can be built, the message length and the gate.
1498    fn flags(self) -> BuildFlags {
1499        let refused = !self.message64 && !self.allow_profile1;
1500        let mut flags = buildable(refused.then_some(UBFT_PROFILE1_GAP).into_iter());
1501        flags.push(("dsig.DSIG_MESSAGE_BYTES".into(), if self.message64 { "64" } else { "16" }.into()));
1502        flags.push(("dsig.allow_profile1_ubft".into(), self.allow_profile1.to_string()));
1503        flags
1504    }
1505}
1506
1507/// DSig signing as a uBFT service: the batch signer's slices and certificate
1508/// unit behind a request stream ([`crate::ubft::UbftSigner`]), with its own
1509/// Ed25519 certificate core and the T-table rounds of the measured builds.
1510#[cfg(feature = "ubft")]
1511#[derive(Clone, Debug)]
1512pub struct DsigUbftSign {
1513    /// Slices (2 is the smallest buildable signer).
1514    pub slices: u32,
1515    /// Message profile and the profile-1 gate.
1516    pub message: UbftMessage,
1517}
1518
1519#[cfg(feature = "ubft")]
1520impl Default for DsigUbftSign {
1521    /// Two slices: the smallest build; a uBFT slot needs one signature.
1522    /// Profile 2.
1523    fn default() -> Self {
1524        Self { slices: 2, message: UbftMessage::default() }
1525    }
1526}
1527
1528#[cfg(feature = "ubft")]
1529impl DsigUbftSign {
1530    /// The signer model it prices with.
1531    fn inner(&self) -> Dsig {
1532        Dsig {
1533            own_ed25519: true,
1534            table_rounds: 4,
1535            message64: self.message.message64,
1536            ..Dsig::default()
1537        }
1538    }
1539}
1540
1541#[cfg(feature = "ubft")]
1542impl Component for DsigUbftSign {
1543    fn name(&self) -> String {
1544        "DSig sign (uBFT service)".into()
1545    }
1546    fn role(&self) -> Role {
1547        Role::DsigSign
1548    }
1549    /// The slice count, a choice of exactly the counts `DSIG_SLICES`
1550    /// builds (2, 4, 8; its value is the index into [`UBFT_SLICES`]); then
1551    /// the message profile and the profile-1 gate ([`UbftMessage`]).
1552    fn knobs(&self) -> Vec<Knob> {
1553        let index = UBFT_SLICES.iter().position(|&n| n == self.slices).unwrap_or(0);
1554        let mut knobs = vec![Knob::choice("slices", index, &UBFT_SLICE_NAMES)];
1555        knobs.extend(self.message.knobs());
1556        knobs
1557    }
1558    fn set_knob(&mut self, index: usize, value: f64) {
1559        if index == 0 {
1560            self.slices = UBFT_SLICES[(value.max(0.0) as usize).min(UBFT_SLICES.len() - 1)];
1561        } else {
1562            self.message.set(index - 1, value);
1563        }
1564    }
1565    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1566        let inner = self.inner();
1567        let (cost, detail) = inner.cost(self.slices, share);
1568        if !cost.fits(share) {
1569            return None;
1570        }
1571        let rate = inner.rate(self.slices, cx.mhz);
1572        Some(Plan {
1573            cost,
1574            rate,
1575            unit: "signatures",
1576            limit: "fixed size (a slot needs one signature)".into(),
1577            detail: format!(
1578                "uBFT service, {detail}; isolated latency {UBFT_SIGN_LATENCY_CYCLES:.0} cycles through the whole kernel (native sim); AXI4-Stream requests"
1579            ),
1580            demands: Vec::new(),
1581            supplies: vec![Flow {
1582                service: "dsig signatures",
1583                per_second: rate,
1584            }],
1585            measured: false,
1586            latency_cycles: UBFT_SIGN_LATENCY_CYCLES,
1587            in_flight: 1.0,
1588            batch: Some(f64::from(inner.hbss.batch)),
1589            covers: vec![Role::Ed25519Sign],
1590        })
1591    }
1592    fn build_flags(&self, _plan: &Plan) -> BuildFlags {
1593        // Every slice count it offers lowers (`DSIG_SLICES` = 2, 4 or 8),
1594        // in either message profile (the uBFT exporter builds both); the
1595        // 2-slice service is the one being routed.
1596        let mut flags = self.message.flags();
1597        flags.extend([
1598            ("dsig.DSIG_SLICES".into(), self.slices.to_string()),
1599            ("dsig.DSIG_TABLE_MASK".into(), quoted("0x78")),
1600            ("dsig.features".into(), quoted("ubft")),
1601        ]);
1602        flags
1603    }
1604    fn ubft(&self) -> bool {
1605        true
1606    }
1607}
1608
1609/// DSig verification as a uBFT service: one verify core behind a request
1610/// stream ([`crate::ubft::UbftVerifier`]) and the Ed25519 known-key verify
1611/// core with one key slot per peer replica.
1612#[cfg(feature = "ubft")]
1613#[derive(Clone, Debug, Default)]
1614pub struct DsigUbftVerify {
1615    /// Message profile and the profile-1 gate (shared with the signer).
1616    pub message: UbftMessage,
1617}
1618
1619#[cfg(feature = "ubft")]
1620impl DsigUbftVerify {
1621    /// The verifier model it prices with.
1622    fn inner(&self) -> DsigVerify {
1623        DsigVerify {
1624            own_ed25519: true,
1625            table_rounds: 4,
1626            table_uram: false,
1627            message64: self.message.message64,
1628            ..DsigVerify::default()
1629        }
1630    }
1631}
1632
1633#[cfg(feature = "ubft")]
1634impl Component for DsigUbftVerify {
1635    fn name(&self) -> String {
1636        "DSig verify (uBFT service)".into()
1637    }
1638    fn role(&self) -> Role {
1639        Role::DsigVerify
1640    }
1641    /// The message profile and the profile-1 gate ([`UbftMessage`]).
1642    fn knobs(&self) -> Vec<Knob> {
1643        self.message.knobs().to_vec()
1644    }
1645    fn set_knob(&mut self, index: usize, value: f64) {
1646        self.message.set(index, value);
1647    }
1648    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1649        let inner = self.inner();
1650        let (cost, _) = inner.units_cost(1, share);
1651        if !cost.fits(share) {
1652            return None;
1653        }
1654        let batch = f64::from(inner.hbss.batch);
1655        let rate = (cx.mhz * 1e6 / inner.cycles_per_verify()).min(VERIFY_ED25519_RATE * cx.mhz / 205.0 * batch);
1656        // A miss waits for one Ed25519 check; hits do not.
1657        let latency = UBFT_VERIFY_LATENCY_CYCLES + (1.0 - cx.cache_hit(batch)) * VERIFY_ED25519_ISOLATED;
1658        Some(Plan {
1659            cost,
1660            rate,
1661            unit: "verifications",
1662            limit: "fixed size (a slot needs 2f verifications)".into(),
1663            detail: format!(
1664                "uBFT service, 1 unit; {:.0} cycles with the certificate cached (native sim), +{VERIFY_ED25519_ISOLATED:.0} on a miss; \
1665                 8 trusted-key slots (capacity 8; max active membership 7); hit rate {:.1} %",
1666                UBFT_VERIFY_LATENCY_CYCLES,
1667                100.0 * cx.cache_hit(batch)
1668            ),
1669            demands: Vec::new(),
1670            supplies: Vec::new(),
1671            measured: false,
1672            latency_cycles: latency,
1673            in_flight: 1.0,
1674            batch: Some(batch),
1675            covers: vec![Role::Ed25519Verify],
1676        })
1677    }
1678    fn build_flags(&self, _plan: &Plan) -> BuildFlags {
1679        let mut flags = self.message.flags();
1680        flags.push(("dsig.features".into(), quoted("ubft")));
1681        flags
1682    }
1683    fn ubft(&self) -> bool {
1684        true
1685    }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690    use super::*;
1691
1692    /// The whole device as one share.
1693    fn device() -> Share {
1694        Share { lut: 1_303_680, dsp: 9_024, bram36: 2_016, uram: 960 }
1695    }
1696
1697    /// The lane split must add back up to the routed kernel (within 1,000
1698    /// LUTs) for the one build that was actually routed.
1699    #[test]
1700    fn the_haraka_two_slice_cost_matches_the_routed_link() {
1701        // Default knobs except all S-boxes in LUTs, as in the routed link.
1702        let dsig = Dsig { sbox_ram: false, own_ed25519: true, table_rounds: 0, ..Dsig::default() };
1703        let (cost, _) = dsig.cost(2, &device());
1704        // Routed slices-2 link: 301,037 CLB LUT for the kernel.
1705        assert!((f64::from(cost.lut) - 301_037.0).abs() < 1_000.0, "{cost:?}");
1706        // The card-measured build stays labelled measured with the default
1707        // "table RAM" (it has no tables to place).
1708        assert!(dsig.measured(2));
1709    }
1710
1711    /// With T-table lanes, the four-slice kernel adds back up to build v5's
1712    /// synthesized LUTs, and its block RAM fits the kernel region (1,776
1713    /// BRAM36), unlike build v4.
1714    #[test]
1715    fn the_table_four_slice_cost_matches_the_v5_synthesis() {
1716        let dsig = Dsig { own_ed25519: true, table_rounds: 6, ..Dsig::default() };
1717        let (cost, line) = dsig.cost(4, &device());
1718        assert!((f64::from(cost.lut) - V5_KERNEL_LUT_SYNTH).abs() < 10.0, "{cost:?}");
1719        assert!(cost.bram36 < 1_776, "{cost:?}");
1720        assert!(line.contains("T-tables"), "{line}");
1721        assert!(!dsig.measured(4) && !dsig.measured(2), "synthesis is not a measurement");
1722        // A table lane is about a third of a LUT S-box lane.
1723        assert!((7_000.0..8_000.0).contains(&HARAKA_TABLE_LANE_LUT));
1724        // The interpolation meets both builds and trades LUTs for BRAM.
1725        assert_eq!(table_lane(0), (HARAKA_LANE_LUT, 0.0));
1726        assert!((table_lane(6).0 - HARAKA_TABLE_LANE_LUT).abs() < 1e-6);
1727        assert_eq!(table_lane(6).1, HARAKA_TABLE_LANE_BRAM36);
1728        for n in 0..6 {
1729            assert!(table_lane(n + 1).0 < table_lane(n).0 && table_lane(n + 1).1 > table_lane(n).1);
1730        }
1731        assert!(table_rounds_built(6) && !table_rounds_built(3));
1732    }
1733
1734    /// Auto is never worse than any fixed table-round count, for the signer
1735    /// and the verify unit, in a LUT-rich and a BRAM-poor share.
1736    #[test]
1737    fn auto_table_rounds_is_the_best_fixed_count() {
1738        let cx = Context::default();
1739        let shares = [
1740            Share { lut: 600_000, dsp: 2_000, bram36: 600, uram: 900 },
1741            Share { lut: 300_000, dsp: 2_000, bram36: 1_700, uram: 900 },
1742        ];
1743        for share in shares {
1744            let rate = |p: Option<Plan>| p.map_or(0.0, |p| p.rate);
1745            let auto = rate(Dsig { table_rounds: AUTO_TABLE_ROUNDS, ..Dsig::default() }.plan(&share, &cx));
1746            let auto_v = rate(DsigVerify { table_rounds: AUTO_TABLE_ROUNDS, ..DsigVerify::default() }.plan(&share, &cx));
1747            for rounds in 0..=6 {
1748                assert!(auto >= rate(Dsig { table_rounds: rounds, ..Dsig::default() }.plan(&share, &cx)));
1749                assert!(auto_v >= rate(DsigVerify { table_rounds: rounds, ..DsigVerify::default() }.plan(&share, &cx)));
1750            }
1751        }
1752    }
1753
1754    /// The built scheme's derived numbers match the hardware constants.
1755    #[test]
1756    fn the_built_scheme_matches_the_hardware() {
1757        let h = Hbss::default();
1758        assert_eq!(h.chains(), crate::CHAINS as u32);
1759        assert_eq!(h.steps_per_key(), 204.0);
1760        assert_eq!(h.key_period(), config::KEY_PERIOD);
1761        assert_eq!(h.signature_bytes(), 1_642);
1762        assert_eq!(h.merkle_height(), crate::MERKLE_HEIGHT as u32);
1763        assert_eq!(Hbss { w: 16, ..Hbss::default() }.chains(), 35);
1764    }
1765
1766    /// Tables in URAM: on a share with little block RAM the signer and the
1767    /// verifier both get faster, never slower, and the plan still fits.
1768    #[test]
1769    fn table_uram_turns_spare_ultraram_into_speed() {
1770        let cx = Context::default();
1771        let rate = |p: Option<Plan>| p.map_or(0.0, |p| p.rate);
1772        let share = Share { lut: 600_000, dsp: 2_000, bram36: 900, uram: 900 };
1773        for rounds in [6, AUTO_TABLE_ROUNDS] {
1774            let bram = Dsig { table_rounds: rounds, table_uram: false, ..Dsig::default() };
1775            let uram = Dsig { table_uram: true, ..bram.clone() };
1776            let plan = uram.plan(&share, &cx).expect("a plan");
1777            assert!(plan.cost.fits(&share), "{plan:?}");
1778            assert!(plan.rate >= rate(bram.plan(&share, &cx)));
1779            let vbram = DsigVerify { table_rounds: rounds, table_uram: false, ..DsigVerify::default() };
1780            let vuram = DsigVerify { table_uram: true, ..vbram.clone() };
1781            let vplan = vuram.plan(&share, &cx).expect("a plan");
1782            assert!(vplan.cost.fits(&share), "{vplan:?}");
1783            assert!(vplan.rate >= rate(vbram.plan(&share, &cx)));
1784        }
1785        // Six table rounds per lane: block RAM caps the signer, URAM lifts it.
1786        let six = Dsig { table_rounds: 6, table_uram: false, ..Dsig::default() };
1787        let lifted = Dsig { table_uram: true, ..six.clone() }.plan(&share, &cx).unwrap();
1788        assert!(lifted.rate > rate(six.plan(&share, &cx)), "{}", lifted.detail);
1789        assert!(lifted.cost.uram > 0 && lifted.detail.contains("URAM of tables"));
1790    }
1791
1792    /// Monotonicity: more LUTs must not lower the planned rate.
1793    #[test]
1794    fn more_budget_never_gives_fewer_signatures() {
1795        let dsig = Dsig::default();
1796        let cx = Context::default();
1797        let mut last = 0.0;
1798        for lut in (100_000..=1_000_000).step_by(50_000) {
1799            let share = Share { lut, ..device() };
1800            let rate = dsig.plan(&share, &cx).map_or(0.0, |p| p.rate);
1801            assert!(rate >= last, "{lut}");
1802            last = rate;
1803        }
1804    }
1805
1806    /// A partial share of the chip's RAM still plans (the bug the TUI hit:
1807    /// S-box placement used to size itself for the whole device).
1808    #[test]
1809    fn a_quarter_of_the_chip_still_plans_a_signer() {
1810        let share = Share { lut: 230_000, dsp: 1_125, bram36: 504, uram: 240 };
1811        let plan = Dsig { sbox_ram: true, table_rounds: 0, ..Dsig::default() }.plan(&share, &Context::default()).expect("a plan");
1812        assert!(plan.cost.fits(&share), "{plan:?}");
1813        assert!(plan.rate > 0.0);
1814    }
1815
1816    /// The shared "message" knob reaches signer and verifier, adds its
1817    /// (estimated) LUTs, and keeps the signing rate.
1818    #[test]
1819    fn the_message_knob_is_shared_priced_and_flagged() {
1820        let mut signer = Dsig::default();
1821        let mut verify = DsigVerify::default();
1822        let share = Share { lut: 600_000, ..device() };
1823        let (base, _) = signer.cost(2, &share);
1824        let base_unit = verify.unit_cost();
1825        let at = |knobs: &[Knob]| knobs.iter().position(|k| k.name == "message").unwrap();
1826        let (si, vi) = (at(&signer.knobs()), at(&verify.knobs()));
1827        assert!(signer.knobs()[si].shared && verify.knobs()[vi].shared);
1828        // The "certificate checks" knob sets its own field (it used to fall
1829        // through to "table RAM").
1830        let ci = signer.knobs().iter().position(|k| k.name == "certificate checks").unwrap();
1831        signer.set_knob(ci, 1.0);
1832        assert!(signer.own_ed25519 && signer.table_uram);
1833        signer.set_knob(ci, 0.0);
1834        signer.set_knob(si, 1.0);
1835        verify.set_knob(vi, 1.0);
1836        assert!(signer.message64 && verify.message64);
1837        let (wide, _) = signer.cost(2, &share);
1838        assert_eq!(wide.lut, base.lut + 2 * MESSAGE64_SLICE_LUT as u32);
1839        assert_eq!(verify.unit_cost().lut, base_unit.lut + MESSAGE64_VERIFY_LUT as u32);
1840        assert_eq!(signer.rate(2, 205.0), Dsig::default().rate(2, 205.0), "same signing rate");
1841    }
1842
1843    /// The rate grows with every slice the share holds (not only at 2, 4,
1844    /// 8), and a cheaper chain hash buys more slices in the same LUTs.
1845    #[test]
1846    fn rate_scales_slice_by_slice_and_with_the_hash() {
1847        let cx = Context::default();
1848        let share = Share { lut: 600_000, ..device() };
1849        // LUT S-boxes on both sides: the comparison is about LUTs per slice.
1850        let haraka = Dsig { table_rounds: 0, ..Dsig::default() }.plan(&share, &cx).unwrap();
1851        let blake3 = Dsig { chain_hash: 1, table_rounds: 0, ..Dsig::default() }.plan(&share, &cx).unwrap();
1852        assert!(blake3.rate > haraka.rate, "{} vs {}", blake3.detail, haraka.detail);
1853        let slices = |p: &Plan| p.detail.split_whitespace().next().unwrap().parse::<u32>().unwrap();
1854        assert!((haraka.rate - f64::from(slices(&haraka)) * 205e6 / 68.0).abs() < 1.0);
1855    }
1856
1857    /// HORS plans nothing and says why; DSig's own Ed25519 core covers
1858    /// Ed25519 signing.
1859    #[test]
1860    fn hors_is_unavailable_and_own_core_covers_ed25519_sign() {
1861        let mut d = Dsig::default();
1862        d.set_knob(0, 1.0);
1863        assert!(d.plan(&device(), &Context::default()).is_none() && d.unavailable().is_some());
1864        let own = Dsig { own_ed25519: true, ..Dsig::default() };
1865        let plan = own.plan(&device(), &Context::default()).unwrap();
1866        assert_eq!(plan.covers, vec![Role::Ed25519Sign]);
1867        assert!(Dsig::default().plan(&device(), &Context::default()).unwrap().covers.is_empty());
1868    }
1869
1870    /// SHA-512 chains on the shared line demand 204 blocks per signature;
1871    /// on own lanes they demand none.
1872    #[test]
1873    fn shared_sha_chains_demand_line_blocks() {
1874        let cx = Context::default();
1875        let own = Dsig { chain_hash: 2, ..Dsig::default() };
1876        let shared = Dsig { sha_shared: true, ..own.clone() };
1877        let p = shared.plan(&device(), &cx).unwrap();
1878        assert_eq!(p.demands[0].per_second, p.rate * 204.0);
1879        assert!(own.plan(&device(), &cx).unwrap().demands.is_empty());
1880    }
1881
1882    /// The uBFT signer offers exactly the slice counts DSIG_SLICES builds.
1883    #[cfg(feature = "ubft")]
1884    #[test]
1885    fn ubft_signer_slices_are_the_buildable_counts() {
1886        let mut signer = DsigUbftSign::default();
1887        let knob = &signer.knobs()[0];
1888        assert_eq!(knob.choices, ["2", "4", "8"]);
1889        for (index, slices) in UBFT_SLICES.iter().enumerate() {
1890            signer.set_knob(0, index as f64);
1891            assert_eq!(signer.slices, *slices);
1892            assert_eq!(signer.knobs()[0].value, index as f64);
1893        }
1894        signer.set_knob(0, 9.0);
1895        assert_eq!(signer.slices, 8, "out of range clamps to the last choice");
1896    }
1897
1898    /// uBFT defaults to profile 2; profile 1 is buildable only when the
1899    /// gate allows it, in both services, and the flags say which.
1900    #[cfg(feature = "ubft")]
1901    #[test]
1902    fn ubft_defaults_to_profile_2_and_gates_profile_1() {
1903        let flag = |flags: &BuildFlags, key: &str| flags.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
1904        let cx = Context::default();
1905        let mut signer = DsigUbftSign::default();
1906        let mut verify = DsigUbftVerify::default();
1907        let components: [&mut dyn Component; 2] = [&mut signer, &mut verify];
1908        for c in components {
1909            let knobs = c.knobs();
1910            let at = knobs.iter().position(|k| k.name == "message").unwrap();
1911            assert!(knobs[at].shared && knobs[at + 1].shared && knobs[at + 1].name == "profile 1 for uBFT");
1912            assert_eq!(knobs[at].value, 1.0, "profile 2 by default");
1913            let plan = c.plan(&device(), &cx).unwrap();
1914            let f = c.build_flags(&plan);
1915            assert_eq!(flag(&f, "buildable").as_deref(), Some("true"));
1916            assert_eq!(flag(&f, "dsig.DSIG_MESSAGE_BYTES").as_deref(), Some("64"));
1917            assert_eq!(flag(&f, "dsig.allow_profile1_ubft").as_deref(), Some("false"));
1918            // Profile 1, refused: priced, but not buildable, with the reason.
1919            c.set_knob(at, 0.0);
1920            let plan = c.plan(&device(), &cx).unwrap();
1921            let f = c.build_flags(&plan);
1922            assert_eq!(flag(&f, "buildable").as_deref(), Some("false"));
1923            assert!(flag(&f, "not_buildable").unwrap().contains("U33"));
1924            assert_eq!(flag(&f, "dsig.DSIG_MESSAGE_BYTES").as_deref(), Some("16"));
1925            // Profile 1, allowed.
1926            c.set_knob(at + 1, 1.0);
1927            let f = c.build_flags(&plan);
1928            assert_eq!(flag(&f, "buildable").as_deref(), Some("true"));
1929            assert_eq!(flag(&f, "dsig.allow_profile1_ubft").as_deref(), Some("true"));
1930        }
1931        // Profile 2 prices its extra digest block.
1932        let p1 = DsigUbftSign { message: UbftMessage { message64: false, allow_profile1: true }, ..DsigUbftSign::default() };
1933        let lut = |c: &dyn Component| c.plan(&device(), &cx).unwrap().cost.lut;
1934        assert!(lut(&DsigUbftSign::default()) > lut(&p1));
1935    }
1936}