Skip to main content

logicaffeine_compile/codegen_sva/
keccak_rtl.rs

1//! keccak_rtl — the flagship silicon FACT.
2//!
3//! The handout's Keccak throughput was a "design point." This module makes it real: an
4//! iterative `Keccak-f[1600]` core (one round/cycle, 24-cycle permutation) emitted as
5//! synthesizable Verilog whose round function is generated FROM THE SAME θ/ρ+π/χ/ι
6//! constants the proven-fast native kernel uses (`logicaffeine_system::keccak::
7//! keccak_f1600`), so the two cannot diverge by transcription. The RTL is checked
8//! **bit-exact against the real 24-round oracle under iverilog**, synthesized to real
9//! ECP5 numbers, and two of its layers are proven by our own certified SAT (θ is
10//! GF(2)-linear; χ is a bijection on a row). Plus the ML-KEM NTT butterfly (Montgomery
11//! `fqmul`) emitted, oracle-checked, and its Montgomery invariant SAT-proven.
12
13/// The ρ+π step as (source lane, rotate-left amount) for each destination lane b0..b24 —
14/// IDENTICAL to the constants in `logicaffeine_system::keccak::keccak_f1600`.
15const RHO_PI: [(usize, u32); 25] = [
16    (0, 0),
17    (6, 44),
18    (12, 43),
19    (18, 21),
20    (24, 14),
21    (3, 28),
22    (9, 20),
23    (10, 3),
24    (16, 45),
25    (22, 61),
26    (1, 1),
27    (7, 6),
28    (13, 25),
29    (19, 8),
30    (20, 18),
31    (4, 27),
32    (5, 36),
33    (11, 10),
34    (17, 15),
35    (23, 56),
36    (2, 62),
37    (8, 55),
38    (14, 39),
39    (15, 41),
40    (21, 2),
41];
42
43/// The 24 Keccak-f round constants (FIPS-202) — same table as the native kernel.
44pub const RC: [u64; 24] = [
45    0x0000000000000001,
46    0x0000000000008082,
47    0x800000000000808a,
48    0x8000000080008000,
49    0x000000000000808b,
50    0x0000000080000001,
51    0x8000000080008081,
52    0x8000000000008009,
53    0x000000000000008a,
54    0x0000000000000088,
55    0x0000000080008009,
56    0x000000008000000a,
57    0x000000008000808b,
58    0x800000000000008b,
59    0x8000000000008089,
60    0x8000000000008003,
61    0x8000000000008002,
62    0x8000000000000080,
63    0x000000000000800a,
64    0x800000008000000a,
65    0x8000000080008081,
66    0x8000000000008080,
67    0x0000000080000001,
68    0x8000000080008008,
69];
70
71/// `rotl(expr, n)` on a 64-bit lane, Verilog form (n == 0 is identity).
72fn rotl(expr: &str, n: u32) -> String {
73    if n == 0 {
74        expr.to_string()
75    } else {
76        format!("(({expr} << {n}) | ({expr} >> {}))", 64 - n)
77    }
78}
79
80/// Emit the combinational one-round `Keccak-f[1600]` module: state `s` + round constant
81/// `rc` → `o`. Lane i occupies bits `[64*i +: 64]` (little-endian lane packing).
82pub fn emit_keccak_round_verilog() -> String {
83    let mut w = String::new();
84    w.push_str(
85        "// keccak_round — one Keccak-f[1600] round (theta, rho+pi, chi, iota).\n\
86         // GENERATED from the same constants as logicaffeine_system::keccak::keccak_f1600.\n\
87         module keccak_round(input [1599:0] s, input [63:0] rc, output [1599:0] o);\n",
88    );
89    w.push_str(&keccak_theta_wires("s"));
90    w.push_str(&keccak_rest_wires());
91    w.push_str(&keccak_pack("o"));
92    w.push_str("endmodule\n");
93    w
94}
95
96/// Theta on the 1600-bit `state`: its lanes `a0..a24`, the column parities `c*` and their mix `d*`, and the lanes
97/// after theta, `t0..t24`.
98fn keccak_theta_wires(state: &str) -> String {
99    let mut w = String::new();
100    for i in 0..25 {
101        w.push_str(&format!("  wire [63:0] a{i} = {state}[{}*64 +: 64];\n", i));
102    }
103    for col in 0..5 {
104        w.push_str(&format!(
105            "  wire [63:0] c{col} = a{}^a{}^a{}^a{}^a{};\n",
106            col,
107            col + 5,
108            col + 10,
109            col + 15,
110            col + 20
111        ));
112    }
113    for col in 0..5 {
114        let left = (col + 4) % 5;
115        let right = (col + 1) % 5;
116        w.push_str(&format!(
117            "  wire [63:0] d{col} = c{left} ^ {};\n",
118            rotl(&format!("c{right}"), 1)
119        ));
120    }
121    for i in 0..25 {
122        let col = i % 5;
123        w.push_str(&format!("  wire [63:0] t{i} = a{i} ^ d{col};\n"));
124    }
125    w
126}
127
128/// Rho and pi (`b0..b24` from `t0..t24`), chi row by row, and iota on lane 0 (`rc`): the round after theta, `o0..o24`.
129fn keccak_rest_wires() -> String {
130    let mut w = String::new();
131    for (dst, &(src, rot)) in RHO_PI.iter().enumerate() {
132        w.push_str(&format!(
133            "  wire [63:0] b{dst} = {};\n",
134            rotl(&format!("t{src}"), rot)
135        ));
136    }
137    for r in 0..5 {
138        for c in 0..5 {
139            let i = 5 * r + c;
140            let n1 = 5 * r + (c + 1) % 5;
141            let n2 = 5 * r + (c + 2) % 5;
142            let chi = format!("b{i} ^ (~b{n1} & b{n2})");
143            if i == 0 {
144                w.push_str(&format!("  wire [63:0] o{i} = ({chi}) ^ rc;\n"));
145            } else {
146                w.push_str(&format!("  wire [63:0] o{i} = {chi};\n"));
147            }
148        }
149    }
150    w
151}
152
153/// `assign <out> = {o24, ..., o0};` — lane 0 at the least significant bits.
154fn keccak_pack(out: &str) -> String {
155    let mut w = format!("  assign {out} = {{");
156    for i in (0..25).rev() {
157        w.push_str(&format!("o{i}"));
158        if i != 0 {
159            w.push_str(", ");
160        }
161    }
162    w.push_str("};\n");
163    w
164}
165
166/// Emit the clocked iterative core: `start` latches `s_in`, then one round/cycle for 24
167/// cycles applying `RC[round]`; `done` pulses with `s_out` = the permutation result.
168pub fn emit_keccak_core_verilog() -> String {
169    let mut w = String::new();
170    w.push_str(&emit_keccak_round_verilog());
171    w.push('\n');
172    w.push_str(
173        "// keccak_core — 24-cycle iterative Keccak-f[1600] (one keccak_round per clock).\n\
174         module keccak_core(input clk, input rst, input start, input [1599:0] s_in,\n\
175         \x20                 output reg done, output reg [1599:0] s_out);\n\
176         \x20 reg [4:0] round;\n\
177         \x20 reg running;\n\
178         \x20 reg [1599:0] st;\n\
179         \x20 reg [63:0] rc;\n\
180         \x20 wire [1599:0] nxt;\n\
181         \x20 keccak_round rnd(.s(st), .rc(rc), .o(nxt));\n\
182         \x20 always @(*) begin\n\
183         \x20   case (round)\n",
184    );
185    for (i, rc) in RC.iter().enumerate() {
186        w.push_str(&format!("      5'd{i}: rc = 64'h{rc:016x};\n"));
187    }
188    w.push_str(
189        "      default: rc = 64'h0;\n\
190         \x20   endcase\n\
191         \x20 end\n\
192         \x20 always @(posedge clk) begin\n\
193         \x20   if (rst) begin running <= 1'b0; done <= 1'b0; round <= 5'd0; end\n\
194         \x20   else begin\n\
195         \x20     done <= 1'b0;\n\
196         \x20     if (start) begin st <= s_in; round <= 5'd0; running <= 1'b1; end\n\
197         \x20     else if (running) begin\n\
198         \x20       st <= nxt;\n\
199         \x20       if (round == 5'd23) begin running <= 1'b0; done <= 1'b1; s_out <= nxt; end\n\
200         \x20       else round <= round + 5'd1;\n\
201         \x20     end\n\
202         \x20   end\n\
203         \x20 end\n\
204         endmodule\n",
205    );
206    w
207}
208
209/// Emit `keccak_core_half`: the iterative core with a register between theta and the rest of each round, two clocks a
210/// round (48 for the permutation), each half carrying only its own wiring — theta's column parities, then rho and
211/// pi's lane moves into chi. The same ports and handshake as `keccak_core`.
212pub fn emit_keccak_core_half_verilog() -> String {
213    let mut w = String::new();
214    w.push_str("// keccak_theta — theta of one Keccak-f[1600] round.\nmodule keccak_theta(input [1599:0] s, output [1599:0] t);\n");
215    w.push_str(&keccak_theta_wires("s"));
216    w.push_str("  assign t = {");
217    for i in (0..25).rev() {
218        w.push_str(&format!("t{i}"));
219        if i != 0 {
220            w.push_str(", ");
221        }
222    }
223    w.push_str("};\nendmodule\n\n");
224    w.push_str("// keccak_rest — rho, pi, chi and iota of one Keccak-f[1600] round.\nmodule keccak_rest(input [1599:0] tt, input [63:0] rc, output [1599:0] o);\n");
225    for i in 0..25 {
226        w.push_str(&format!("  wire [63:0] t{i} = tt[{}*64 +: 64];\n", i));
227    }
228    w.push_str(&keccak_rest_wires());
229    w.push_str(&keccak_pack("o"));
230    w.push_str("endmodule\n\n");
231    w.push_str(
232        "// keccak_core_half — 48-cycle iterative Keccak-f[1600]: theta, then the rest of the round, a clock each.\n\
233         module keccak_core_half(input clk, input rst, input start, input [1599:0] s_in,\n\
234         \x20                      output reg done, output reg [1599:0] s_out);\n\
235         \x20 reg [4:0] round;\n\
236         \x20 reg running, half;\n\
237         \x20 reg [1599:0] st, tt;\n\
238         \x20 reg [63:0] rc;\n\
239         \x20 wire [1599:0] th, nxt;\n\
240         \x20 keccak_theta theta(.s(st), .t(th));\n\
241         \x20 keccak_rest rest(.tt(tt), .rc(rc), .o(nxt));\n\
242         \x20 always @(*) begin\n\
243         \x20   case (round)\n",
244    );
245    for (i, rc) in RC.iter().enumerate() {
246        w.push_str(&format!("      5'd{i}: rc = 64'h{rc:016x};\n"));
247    }
248    w.push_str(
249        "      default: rc = 64'h0;\n\
250         \x20   endcase\n\
251         \x20 end\n\
252         \x20 always @(posedge clk) begin\n\
253         \x20   if (rst) begin running <= 1'b0; done <= 1'b0; round <= 5'd0; half <= 1'b0; end\n\
254         \x20   else begin\n\
255         \x20     done <= 1'b0;\n\
256         \x20     if (start) begin st <= s_in; round <= 5'd0; half <= 1'b0; running <= 1'b1; end\n\
257         \x20     else if (running) begin\n\
258         \x20       if (!half) begin tt <= th; half <= 1'b1; end\n\
259         \x20       else begin\n\
260         \x20         st <= nxt; half <= 1'b0;\n\
261         \x20         if (round == 5'd23) begin running <= 1'b0; done <= 1'b1; s_out <= nxt; end\n\
262         \x20         else round <= round + 5'd1;\n\
263         \x20       end\n\
264         \x20     end\n\
265         \x20   end\n\
266         \x20 end\n\
267         endmodule\n",
268    );
269    w
270}
271
272/// Emit a NARROW-BUS wrapper around `keccak_core` so it place-and-routes on a real FPGA:
273/// the bare core exposes a 1600-bit parallel state port (fine for simulation, but 1600
274/// pins exceed any FPGA's IO), so `keccak_top` streams the 25 lanes in and out over a
275/// 64-bit bus addressed by a 5-bit lane index. `we` writes `din` to lane `idx`; `go`
276/// launches the permutation; `done` marks completion; `dout` reads lane `idx` of the
277/// result. ~140 IOs — routes on the ECP5.
278pub fn emit_keccak_top_verilog() -> String {
279    let mut w = String::new();
280    w.push_str(&emit_keccak_core_verilog());
281    w.push('\n');
282    w.push_str(
283        "// keccak_top — narrow-bus (64-bit lane) wrapper so Keccak-f[1600] place-and-routes.\n\
284         module keccak_top(input clk, input rst, input we, input [4:0] idx,\n\
285         \x20                input [63:0] din, input go, output done, output [63:0] dout);\n\
286         \x20 reg [1599:0] state;\n\
287         \x20 reg launch;\n\
288         \x20 wire core_done;\n\
289         \x20 wire [1599:0] result;\n\
290         \x20 keccak_core core(.clk(clk), .rst(rst), .start(launch), .s_in(state),\n\
291         \x20                  .done(core_done), .s_out(result));\n\
292         \x20 assign done = core_done;\n\
293         \x20 assign dout = state[64*idx +: 64];\n\
294         \x20 always @(posedge clk) begin\n\
295         \x20   if (rst) begin launch <= 1'b0; end\n\
296         \x20   else begin\n\
297         \x20     launch <= go;\n\
298         \x20     if (we) state[64*idx +: 64] <= din;\n\
299         \x20     else if (core_done) state <= result;\n\
300         \x20   end\n\
301         \x20 end\n\
302         endmodule\n",
303    );
304    w
305}
306
307/// Self-checking testbench: load the input state + the oracle's 24-round output from
308/// `keccak_vec.hex` (50 × 64-bit words: 25 input lanes then 25 expected lanes), run the
309/// core, compare bit-exact. Prints PASS/FAIL.
310pub fn emit_keccak_testbench() -> String {
311    r#"// tb_keccak_core — the RTL Keccak-f[1600] vs the native oracle (keccak_vec.hex).
312`timescale 1ns/1ps
313module tb_keccak_core;
314  reg clk = 0; always #5 clk = ~clk;
315  reg rst = 1, start = 0;
316  reg [63:0] mem [0:49];
317  reg [1599:0] s_in, expect_out;
318  wire done; wire [1599:0] s_out;
319  integer i;
320  reg seen_done = 0;
321  keccak_core dut(.clk(clk), .rst(rst), .start(start), .s_in(s_in), .done(done), .s_out(s_out));
322  always @(posedge clk) if (done) seen_done <= 1'b1;
323  initial begin
324    $readmemh("keccak_vec.hex", mem);
325    for (i = 0; i < 25; i = i + 1) s_in[64*i +: 64] = mem[i];
326    for (i = 0; i < 25; i = i + 1) expect_out[64*i +: 64] = mem[25 + i];
327    rst = 1;
328    repeat (3) @(posedge clk);
329    #1 rst = 0;                    // deassert OFF the edge — no reset/sample race
330    @(posedge clk); #1 start = 1;
331    @(posedge clk); #1 start = 0;
332    repeat (40) @(posedge clk);   // bounded — 24-round permutation + margin, never hangs
333    if (!seen_done)
334      $display("FAIL: Keccak core never asserted done");
335    else if (s_out === expect_out)
336      $display("PASS: Keccak-f[1600] RTL core matches the native oracle bit-exact");
337    else
338      $display("FAIL: Keccak mismatch");
339    $finish;
340  end
341endmodule
342"#
343    .to_string()
344}
345
346// ── ML-KEM NTT butterfly (Montgomery fqmul) ──────────────────────────────────────────
347
348/// q and Montgomery constant — identical to `logicaffeine_system::ntt`.
349pub const KYBER_Q: i32 = 3329;
350pub const KYBER_QINV_I16: i32 = -3327; // q^-1 mod 2^16, as a signed i16
351
352/// The reference Montgomery reduction (mirrors `ntt::montgomery_reduce`) — the oracle
353/// the emitted RTL is checked against.
354pub fn montgomery_reduce(a: i32) -> i16 {
355    let t = (a as i16).wrapping_mul(KYBER_QINV_I16 as i16);
356    ((a - (t as i32) * KYBER_Q) >> 16) as i16
357}
358
359pub fn fqmul(a: i16, b: i16) -> i16 {
360    montgomery_reduce(a as i32 * b as i32)
361}
362
363/// Emit the combinational Montgomery `fqmul` + one Cooley–Tukey butterfly: inputs
364/// `a`, `b` (the two coefficients) and `zeta`; outputs `hi = a + t`, `lo = a - t` where
365/// `t = fqmul(zeta, b)` — all wrapping 16-bit, matching `ntt_scalar`'s inner step.
366pub fn emit_ntt_butterfly_verilog() -> String {
367    format!(
368        r#"// ntt_butterfly — Montgomery fqmul + one CT butterfly (ML-KEM, q={q}).
369// t = montgomery_reduce(zeta*b); hi = a + t; lo = a - t  (16-bit wrapping).
370module ntt_butterfly(input signed [15:0] a, input signed [15:0] b,
371                     input signed [15:0] zeta, output signed [15:0] hi,
372                     output signed [15:0] lo);
373  wire signed [31:0] prod = zeta * b;                  // full 32-bit product
374  wire signed [15:0] m    = prod[15:0] * (-16'sd{qmag}); // (prod as i16) * qinv (=-{qmag})
375  wire signed [31:0] sub  = prod - m * 32'sd{q};       // prod - m*q
376  wire signed [15:0] t    = sub[31:16];                // arithmetic >> 16
377  assign hi = a + t;
378  assign lo = a - t;
379endmodule
380"#,
381        q = KYBER_Q,
382        qmag = -KYBER_QINV_I16, // qinv = -3327; emit the magnitude, negate in Verilog
383    )
384}
385
386/// The steps of [`emit_ntt_butterfly_pipelined_verilog`]'s datapath a register may follow: the four levels of the
387/// product's adder tree; `m`'s high-byte correction; `m`; the two partial sums of `13·m + (m >>> 8)`; that sum; `t`.
388pub const NTT_STEPS: usize = 9;
389
390/// Where the pipelined butterfly's registers go: `self.0[i]` puts one after step `i` ([`NTT_STEPS`]). The operands
391/// and the results are always registered.
392#[derive(Clone, Copy, Debug, PartialEq, Eq)]
393pub struct NttCuts(pub [bool; NTT_STEPS]);
394
395impl NttCuts {
396    /// One multiply to a stage: registers after the product, after `m` and after `t`.
397    pub const PER_MULTIPLY: NttCuts = NttCuts([false, false, false, true, false, true, false, false, true]);
398    /// A register after every step: no stage holds more than one adder.
399    pub const EVERY_STEP: NttCuts = NttCuts([true; NTT_STEPS]);
400
401    /// Edges from a butterfly's operands to its result: the operand registers, one per cut, the result registers.
402    pub fn latency(self) -> u32 {
403        2 + self.0.iter().filter(|&&c| c).count() as u32
404    }
405}
406
407/// A partial sum of the product's adder tree: its value, `v · 2^offset` of the product, held in `bits` signed bits,
408/// and the least and greatest `v` it can take.
409struct NttTerm {
410    name: String,
411    offset: u32,
412    bits: u32,
413    lo: i64,
414    hi: i64,
415}
416
417/// The fewest two's-complement bits that hold every integer from `lo` to `hi`.
418fn signed_bits(lo: i64, hi: i64) -> u32 {
419    (1..64).find(|&n| -(1i64 << (n - 1)) <= lo && hi < 1i64 << (n - 1)).expect("a range that fits 64 bits")
420}
421
422/// A datapath value: its width, the stage whose wire computes it, and the last stage that reads it.
423struct NttVal {
424    bits: u32,
425    stage: u32,
426    need: u32,
427}
428
429/// The butterfly's values by name as the emitter places them; `at` names a value as a stage sees it, recording the
430/// registers that carry it there.
431struct NttDatapath {
432    vals: std::collections::BTreeMap<String, NttVal>,
433    order: Vec<String>,
434    wires: String,
435}
436
437impl NttDatapath {
438    fn define(&mut self, name: &str, bits: u32, stage: u32, expr: String) {
439        self.wires.push_str(&format!("  wire signed [{}:0] {name}_s{stage} = {expr};\n", bits - 1));
440        self.vals.insert(name.to_string(), NttVal { bits, stage, need: stage });
441        self.order.push(name.to_string());
442    }
443
444    fn at(&mut self, name: &str, stage: u32) -> String {
445        let v = self.vals.get_mut(name).unwrap_or_else(|| panic!("{name} is read before it is defined"));
446        assert!(stage >= v.stage, "{name} is read at stage {stage}, before stage {} computes it", v.stage);
447        v.need = v.need.max(stage);
448        format!("{name}_s{stage}")
449    }
450}
451
452/// Emit module `name`: [`emit_ntt_butterfly_verilog`]'s butterfly as a pipeline taking one butterfly a cycle, its
453/// result [`NttCuts::latency`] edges after its operands, registered after the steps `cuts` names. Each value a later
454/// stage reads reaches it through one register a stage, and only those values are carried.
455///
456/// The product `zeta·b` is an aligned tree of adds over its sixteen partial products (b's bit 15 weighs −2¹⁵), each
457/// add as wide as the range of its sum needs. `m = p[15:0]·q⁻¹ mod 2¹⁶` keeps the product's low byte and corrects the
458/// high one: q⁻¹ = 1 − 2⁸ − 2¹⁰ − 2¹¹ mod 2¹⁶. The reduction subtracts only high halves: the low halves of `p` and
459/// `m·q` agree (Montgomery's invariant), so `t = p[31:16] − (m·q)[31:16]`, and with q = 13·2⁸ + 1 that high half is
460/// `(13·m + (m >>> 8)) >>> 8` — the low byte of `m` cannot carry into it.
461pub fn emit_ntt_butterfly_pipelined_verilog(name: &str, cuts: NttCuts) -> String {
462    let stage_of = |step: usize| 1 + cuts.0[..step].iter().filter(|&&c| c).count() as u32;
463    let last = stage_of(NTT_STEPS);
464    let mut dp = NttDatapath { vals: Default::default(), order: Vec::new(), wires: String::new() };
465    for operand in ["a", "x", "y"] {
466        dp.vals.insert(operand.to_string(), NttVal { bits: 16, stage: 1, need: 1 });
467        dp.order.push(operand.to_string());
468    }
469
470    let s0 = stage_of(0);
471    let (x, y) = (dp.at("x", s0), dp.at("y", s0));
472    let mut terms: Vec<NttTerm> = (0..16u32)
473        .map(|j| {
474            let name = format!("r{j}");
475            let pick = format!("({y}[{j}] ? {{{x}[15], {x}}} : 17'sd0)");
476            dp.define(&name, 17, s0, if j == 15 { format!("-{pick}") } else { pick });
477            let (lo, hi) = if j == 15 { (-32767, 32768) } else { (-32768, 32767) };
478            NttTerm { name, offset: j, bits: 17, lo, hi }
479        })
480        .collect();
481    for level in 0..4 {
482        let st = stage_of(level);
483        terms = terms
484            .chunks(2)
485            .enumerate()
486            .map(|(i, pair)| {
487                let (a, b) = (&pair[0], &pair[1]);
488                let d = b.offset - a.offset;
489                let top = signed_bits((a.lo >> d) + b.lo, (a.hi >> d) + b.hi).min(32 - b.offset);
490                let name = format!("s{level}_{i}");
491                let (av, bv) = (dp.at(&a.name, st), dp.at(&b.name, st));
492                dp.define(&format!("{name}h"), top, st, format!("$signed({av}[{}:{d}]) + {bv}", a.bits - 1));
493                let hv = dp.at(&format!("{name}h"), st);
494                dp.define(&name, d + top, st, format!("{{{hv}, {av}[{}:0]}}", d - 1));
495                NttTerm { name, offset: a.offset, bits: d + top, lo: a.lo + (b.lo << d), hi: a.hi + (b.hi << d) }
496            })
497            .collect();
498    }
499    let p = terms[0].name.clone();
500
501    let st = stage_of(4);
502    let pv = dp.at(&p, st);
503    dp.define("pl", 16, st, format!("{pv}[15:0]"));
504    dp.define("ph", 16, st, format!("{pv}[31:16]"));
505    let pl = dp.at("pl", st);
506    dp.define("u", 8, st, format!("{pl}[7:0] + {{{pl}[5:0], 2'b00}} + {{{pl}[4:0], 3'b000}}"));
507
508    let st = stage_of(5);
509    let (pl, u) = (dp.at("pl", st), dp.at("u", st));
510    dp.define("mh", 8, st, format!("{pl}[15:8] - {u}"));
511    let mh = dp.at("mh", st);
512    dp.define("m", 16, st, format!("{{{mh}, {pl}[7:0]}}"));
513
514    let st = stage_of(6);
515    let m = dp.at("m", st);
516    dp.define("u1", 20, st, format!("{{{{2{{{m}[15]}}}}, {m}, 2'b00}} + {{{{4{{{m}[15]}}}}, {m}}}"));
517    dp.define("u2", 20, st, format!("{{{m}[15], {m}, 3'b000}} + {{{{12{{{m}[15]}}}}, {m}[15:8]}}"));
518
519    let st = stage_of(7);
520    let (u1, u2) = (dp.at("u1", st), dp.at("u2", st));
521    dp.define("mq", 20, st, format!("{u1} + {u2}"));
522
523    let st = stage_of(8);
524    let (ph, mq) = (dp.at("ph", st), dp.at("mq", st));
525    dp.define("t", 16, st, format!("{ph} - {{{{4{{{mq}[19]}}}}, {mq}[19:8]}}"));
526
527    let (a, t) = (dp.at("a", last), dp.at("t", last));
528    dp.define("hi", 16, last, format!("{a} + {t}"));
529    dp.define("lo", 16, last, format!("{a} - {t}"));
530
531    let mut regs = String::new();
532    let mut moves = String::from("    a_s1 <= a; x_s1 <= zeta; y_s1 <= b;\n");
533    for v in &dp.order {
534        let val = &dp.vals[v];
535        let first = if matches!(v.as_str(), "a" | "x" | "y") { 1 } else { val.stage + 1 };
536        for st in first.min(val.need + 1)..=val.need {
537            regs.push_str(&format!("  reg signed [{}:0] {v}_s{st};\n", val.bits - 1));
538            if st > val.stage || matches!(v.as_str(), "a" | "x" | "y") && st > 1 {
539                moves.push_str(&format!("    {v}_s{st} <= {v}_s{};\n", st - 1));
540            }
541        }
542    }
543    let (hi, lo) = (dp.at("hi", last), dp.at("lo", last));
544    format!(
545        "// {name} — Montgomery fqmul + one CT butterfly (ML-KEM, q={q}), one a cycle, latency {lat}.\n\
546         module {name}(input clk, input signed [15:0] a, input signed [15:0] b,\n\
547         {pad}input signed [15:0] zeta, output reg signed [15:0] hi,\n\
548         {pad}output reg signed [15:0] lo);\n\
549         {regs}{wires}  always @(posedge clk) begin\n{moves}    hi <= {hi}; lo <= {lo};\n  end\nendmodule\n",
550        q = KYBER_Q,
551        lat = cuts.latency(),
552        pad = " ".repeat(name.len() + 8),
553        wires = dp.wires,
554    )
555}
556
557#[cfg(test)]
558#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/keccak_rtl/tests.rs"]
559mod tests;