Skip to main content

rhdl_dsig_scalable_core/
slice_schedule.rs

1//! The fixed timetable that lets one BLAKE3 lane serve a whole slice.
2//!
3//! This module is pure compile-time arithmetic: no hardware. It answers one
4//! question for [`crate::slice_factory`]: "in which cycle does each BLAKE3
5//! compression of a key run?" The factory then turns the answer into small
6//! constant tables ([`SLOTS`](crate::slice_factory::SLOTS),
7//! [`WINDOW_LOADS`](crate::slice_factory::WINDOW_LOADS)) that a 0..=67
8//! counter indexes.
9//!
10//! # The problem
11//!
12//! A [slice](crate::sustained) starts one key every [`KEY_PERIOD`] = 68 cycles,
13//! because its chain engine accepts one of the key's 68 chains per cycle. Each
14//! key needs 46 BLAKE3 compressions (seed, two nonces, 20 XOF blocks, a 21-block
15//! endpoint commitment, a Merkle share and a message hash), and they all share
16//! **one** pipelined BLAKE3 lane that accepts one compression per cycle.
17//! Several keys are in flight at once, and some compressions depend on others,
18//! up to 16 in a strict chain.
19//!
20//! (The "21-block endpoint commitment" counts its 20 data blocks plus the
21//! parent that joins its two chunks: 3 + 20 + 21 + 2 = 46 slots, out of 68.)
22//!
23//! # The trick: a modulo schedule
24//!
25//! Give every compression of a key a fixed *offset* from the cycle `T` when that
26//! key's chain 0 enters the chain engine. Key `k` starts at `T₀ + 68k`, so its
27//! compression with offset `x` issues at `T₀ + 68k + x`. Two keys collide on the
28//! lane only if two offsets are equal **mod 68**. So if every offset in the table
29//! has a different residue mod 68, no two compressions ever collide, however
30//! many keys overlap.
31//!
32//! ```text
33//!   cycle mod 68:  0  1  2  3  4  5  …                              67
34//!   op issued:     X7 C0 ·  P  X8 C1 …     one op (or idle) per residue
35//!                   │  └ chunk-0 block of the key that started 1 period ago
36//!                   └ XOF block 7 of the key that starts next period
37//! ```
38//!
39//! The hardware is then a 68-entry table ([`Schedule::residue`]) and a counter,
40//! with no arbiter. To stall, a slice simply does not start a key in some
41//! period; every compression of that key becomes an idle slot.
42//!
43//! Words used below:
44//!
45//! | Term | Meaning |
46//! |---|---|
47//! | `T` | the cycle at which the key's chain 0 enters the chain engine |
48//! | offset | an op's issue cycle minus `T`; negative = before chain 0 |
49//! | residue | a cycle number mod 68; the hardware's counter value |
50//! | period | 68 consecutive cycles, residue 0 to 67 |
51//! | lag | `offset div 68`: in which period, relative to the key's own, the op runs |
52//! | `D` | [`Schedule::use_delay`]: cycles from issue until a result can be used |
53//!
54//! # One key's life on the timeline
55//!
56//! ```text
57//!   offset:  ≪0 ───────────────── 0 ────────── 67 ─────────────────────────▶
58//!            Seed ─D─▶ PkNonce, Nonce
59//!                 └D─▶ Xof(0..19), each D before the first chain that needs it
60//!                                 chains 0..67 enter, one per cycle
61//!                                           chain ends emerge (end_delay later)
62//!                                              Chunk0(0) ─≥D─▶ Chunk0(1) … Chunk0(15)
63//!                                                   Chunk1(0) ─≥D─▶ … Chunk1(3)
64//!                                                                  ─D─▶ Parent → pk_hash
65//! ```
66//!
67//! Preparation ops are placed **as late as possible** (so their results wait
68//! in storage for the shortest time), and commitment ops **as early as
69//! possible** (so the key finishes soon). Each op then lands on the nearest
70//! residue no other op has taken.
71//!
72//! # Constraints the solver enforces
73//!
74//! A result is usable [`Schedule::use_delay`] cycles after its compression
75//! issues: the BLAKE3 latency, one output register, then a write into and a
76//! registered read from the slice's per-key context RAM (latency + 4).
77//!
78//! * **Seed first.** Nonces and XOF blocks hash the seed: each issues ≥ `D` after it.
79//! * **XOF in time.** Chain `i` needs bytes `18i..18i+18` of the XOF stream when
80//!   it issues at `T + i`, so each XOF block is ready before its first chain.
81//! * **Salt in time.** `pk_nonce` salts every chain, so it is ready before `T`.
82//! * **Commitment follows the chain ends.** The commitment hashes
83//!   `pk_nonce ‖ end₀ ‖ … ‖ end₆₇` (1,240 bytes) as 20 blocks: chunk 0 is blocks
84//!   0–15, chunk 1 is blocks 16–19. Block `j` needs every chain end that
85//!   touches it, and each chunk's blocks chain through their chaining values,
86//!   so consecutive blocks are ≥ `D` apart.
87//! * **Parent last.** The `pk_hash` parent combines both chunks.
88//!
89//! The Merkle-share and message slots are reserved residues whose data comes
90//! from elsewhere; their readiness is checked by the controllers that use them.
91//! `tests/slice_schedule.rs` re-verifies every constraint independently.
92//!
93//! # How the solver works
94//!
95//! [`solve`] is a greedy, compile-time search. A table of 68 "used" flags
96//! records which residues are taken.
97//!
98//! 1. Chunk 0 first: 16 blocks, each needing the previous one's chaining
99//!    value, is the hardest fit. `solve_chunk` tries every start `base` and
100//!    every spacing `gap ≥ D` (within one period of the earliest choice) and
101//!    keeps the placement that finishes earliest without collisions.
102//! 2. Chunk 1 the same way, around what chunk 0 took.
103//! 3. The parent at the first free residue `D` after both chunks.
104//! 4. XOF blocks, last to first, each at the latest free residue that is
105//!    still `D` before its first chain.
106//! 5. The two nonces at the latest free residue `D` before `T`.
107//! 6. The seed at the latest free residue `D` before its earliest user.
108//! 7. The Merkle and message slots at the first free residues from offset 0.
109//!
110//! If any step finds no room, `solve` fails at compile time, so a bad
111//! timetable can never reach hardware.
112
113use crate::{CHAINS, ELEMENT_BYTES};
114
115/// Cycles between consecutive key starts in a slice (one chain per cycle).
116///
117/// 68: the chain engine takes one chain per cycle and a key has 68 chains.
118pub const KEY_PERIOD: usize = CHAINS;
119/// XOF output blocks holding the 68 eighteen-byte chain secrets.
120///
121/// 68 × 18 = 1,224 bytes, rounded up to whole 64-byte blocks: 20.
122pub const XOF_BLOCKS: usize = (CHAINS * ELEMENT_BYTES).div_ceil(64);
123/// Endpoint-commitment blocks in the first 1,024-byte chunk.
124///
125/// BLAKE3 hashes input in chunks of 1,024 bytes = 16 blocks of 64 bytes.
126pub const CHUNK0_BLOCKS: usize = 16;
127/// Endpoint-commitment blocks in the 216-byte second chunk.
128///
129/// 216 = 3 × 64 + 24, so 4 blocks, the last one 24 bytes long.
130pub const CHUNK1_BLOCKS: usize = 4;
131/// Bytes hashed by the endpoint commitment: `pk_nonce` and 68 chain ends.
132///
133/// 16 + 68 × 18 = 1,240 bytes.
134pub const COMMITMENT_BYTES: usize = 16 + CHAINS * ELEMENT_BYTES;
135
136/// One kind of compression in the per-key timetable.
137///
138/// Software-side names. The factory maps each to a 4-bit hardware code
139/// ([`crate::slice_factory::code`]).
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum Op {
142    /// Derive the one-time seed (derive-key material block).
143    Seed,
144    /// `pk_nonce`: public salt for every chain of this key.
145    PkNonce,
146    /// Signer-private message nonce.
147    Nonce,
148    /// XOF block `n` of the chain-secret stream.
149    Xof(u8),
150    /// Endpoint-commitment chunk-0 block `n`.
151    Chunk0(u8),
152    /// Endpoint-commitment chunk-1 block `n`.
153    Chunk1(u8),
154    /// Combine both chunk chaining values into `pk_hash`.
155    Parent,
156    /// Reserved slot for one 16-leaf Merkle-subtree parent.
157    Merkle,
158    /// Reserved slot for one message-digit hash.
159    Message,
160    /// Profile 2 only: a second message slot for the digest's tail block.
161    MessageTail,
162}
163
164/// Number of scheduled compressions or reserved slots per key.
165///
166/// 3 (seed, two nonces) + 20 XOF + 16 + 4 commitment + 1 parent + 2 reserved
167/// = 46, plus profile 2's message-tail slot (47). The rest of the 68
168/// residues are idle.
169pub const OPS: usize = BASE_OPS + TAIL_OPS;
170/// The ops of every profile.
171const BASE_OPS: usize = 3 + XOF_BLOCKS + CHUNK0_BLOCKS + CHUNK1_BLOCKS + 1 + 2;
172/// Profile 2 (`DSIG_MESSAGE_BYTES=64`) hashes the message in two blocks.
173const TAIL_OPS: usize = if crate::MESSAGE_BYTES == 64 { 1 } else { 0 };
174/// Cycles between a message result leaving the lane and its tail request
175/// being ready (result register, signer latch, request register, margin).
176pub const TAIL_SLACK: i32 = 6;
177
178/// The op stored at position `index` of [`Schedule::offsets`].
179///
180/// The layout is: seed, pk_nonce, nonce, XOF 0..20, chunk-0 0..16,
181/// chunk-1 0..4, parent, Merkle, message.
182#[must_use]
183pub const fn op_at(index: usize) -> Op {
184    match index {
185        0 => Op::Seed,
186        1 => Op::PkNonce,
187        2 => Op::Nonce,
188        i if i < 3 + XOF_BLOCKS => Op::Xof((i - 3) as u8),
189        i if i < 3 + XOF_BLOCKS + CHUNK0_BLOCKS => Op::Chunk0((i - 3 - XOF_BLOCKS) as u8),
190        i if i < 3 + XOF_BLOCKS + CHUNK0_BLOCKS + CHUNK1_BLOCKS => {
191            Op::Chunk1((i - 3 - XOF_BLOCKS - CHUNK0_BLOCKS) as u8)
192        }
193        i if i == BASE_OPS - 3 => Op::Parent,
194        i if i == BASE_OPS - 2 => Op::Merkle,
195        i if i == BASE_OPS - 1 => Op::Message,
196        _ => Op::MessageTail,
197    }
198}
199
200/// Index of an op in [`Schedule::offsets`]; the inverse of [`op_at`].
201#[must_use]
202pub const fn op_index(op: Op) -> usize {
203    match op {
204        Op::Seed => 0,
205        Op::PkNonce => 1,
206        Op::Nonce => 2,
207        Op::Xof(n) => 3 + n as usize,
208        Op::Chunk0(n) => 3 + XOF_BLOCKS + n as usize,
209        Op::Chunk1(n) => 3 + XOF_BLOCKS + CHUNK0_BLOCKS + n as usize,
210        Op::Parent => BASE_OPS - 3,
211        Op::Merkle => BASE_OPS - 2,
212        Op::Message => BASE_OPS - 1,
213        Op::MessageTail => BASE_OPS,
214    }
215}
216
217/// A complete, collision-free timetable for one slice.
218///
219/// `offsets` and `residue` are two views of the same placement: per op ("when
220/// does op `i` run?") and per residue ("which op runs at residue `r`?").
221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
222pub struct Schedule {
223    /// Issue cycle of each op relative to the key's chain-0 cycle `T`.
224    ///
225    /// Indexed by [`op_index`]. In cycles; may be negative.
226    pub offsets: [i32; OPS],
227    /// For each residue mod 68, the op index issued there (`None` = idle).
228    ///
229    /// The hardware's lookup table. Distinct residues per op are what make
230    /// the schedule collision-free.
231    pub residue: [Option<u8>; KEY_PERIOD],
232    /// Cycles from issuing a compression until its result is usable (latency + 4).
233    ///
234    /// The `+ 4` covers the lane's output register and the round trip through
235    /// the factory's context RAM (a write, then a registered read) before the
236    /// consuming op issues. With the 28-stage BLAKE3 lane this is 32.
237    pub use_delay: i32,
238    /// Cycles from a chain's issue until its end is stored for the commitment.
239    ///
240    /// Supplied by the caller ([`END_DELAY`](crate::slice_factory::END_DELAY));
241    /// depends on the compiled-in chain hash.
242    pub end_delay: i32,
243}
244
245/// First cycle (relative to `T`) at which commitment block `block` is complete.
246///
247/// Chain end `i` occupies commitment bytes `16 + 18i .. 34 + 18i` and is
248/// available `end_delay` cycles after its chain issues at `T + i`. The packer
249/// that assembles 64-byte blocks adds one register.
250#[must_use]
251pub const fn block_ready(block: usize, end_delay: i32) -> i32 {
252    // One past the block's last byte (the final block is short).
253    let block_end = if 64 * (block + 1) < COMMITMENT_BYTES {
254        64 * (block + 1)
255    } else {
256        COMMITMENT_BYTES
257    };
258    // Last chain end with a byte inside this block.
259    let last = (block_end - 16 - 1) / ELEMENT_BYTES;
260    // Chain `last` issues at T + last, its end is stored end_delay later,
261    // and the packer's register adds one cycle.
262    last as i32 + end_delay + 1
263}
264
265/// First chain that reads XOF block `block`.
266///
267/// Chain `c` reads XOF bytes `18c..18c+18`. The answer is the smallest `c`
268/// whose last byte falls in or after block `block`, that is
269/// `18c + 18 > 64 · block`. Chain `c` issues at `T + c`, so this is also the
270/// offset by which the block must be in the window.
271#[must_use]
272pub const fn first_chain_of_xof(block: usize) -> i32 {
273    // Walk forward while chain `chain` still ends before the block starts.
274    let mut chain = 0;
275    while ELEMENT_BYTES * chain + ELEMENT_BYTES <= 64 * block {
276        chain += 1;
277    }
278    chain as i32
279}
280
281/// Residue (0..68) of an offset; correct for negative offsets too, because
282/// `rem_euclid` never returns a negative value.
283const fn residue_of(offset: i32) -> usize {
284    offset.rem_euclid(KEY_PERIOD as i32) as usize
285}
286
287/// Claims the residue of `offset` if it is free.
288///
289/// Returns `true` (and marks the residue used) on success, `false` if
290/// another op already sits on that residue.
291const fn take(used: &mut [bool; KEY_PERIOD], offset: i32) -> bool {
292    let r = residue_of(offset);
293    if used[r] {
294        // Taken: this offset would collide with another op.
295        false
296    } else {
297        used[r] = true;
298        true
299    }
300}
301
302/// Latest offset `<= limit` with a free residue (claims it).
303///
304/// Used for preparation ops: `limit` is the last cycle at which the op can
305/// still issue and deliver on time; we step earlier until a residue is free.
306/// Terminates while fewer than 68 residues are used.
307const fn latest_free(used: &mut [bool; KEY_PERIOD], limit: i32) -> i32 {
308    // Try the deadline itself first, then one cycle earlier each time.
309    let mut offset = limit;
310    while !take(used, offset) {
311        offset -= 1;
312    }
313    offset
314}
315
316/// Earliest offset `>= limit` with a free residue (claims it).
317///
318/// Used for ops that should run as soon as their inputs are ready: `limit`
319/// is the first cycle their inputs exist; we step later until a residue is
320/// free.
321const fn earliest_free(used: &mut [bool; KEY_PERIOD], limit: i32) -> i32 {
322    // Try the earliest allowed cycle first, then one cycle later each time.
323    let mut offset = limit;
324    while !take(used, offset) {
325        offset += 1;
326    }
327    offset
328}
329
330/// Try to place a chunk's dependent chain starting at `base` with `gap`.
331///
332/// Block `n` of the chunk (overall commitment block `first_block + n`) goes
333/// at offset `base + gap · n`. The trial succeeds if every block is at or
334/// after its [`block_ready`] cycle and on a free residue. It works on a copy
335/// of `used`, so a failed trial changes nothing; the caller claims the
336/// residues afterwards.
337const fn place_chunk(
338    used: &[bool; KEY_PERIOD],
339    first_block: usize,
340    blocks: usize,
341    base: i32,
342    gap: i32,
343    end_delay: i32,
344) -> bool {
345    // Scratch copy: claims made here are thrown away.
346    let mut trial = *used;
347    let mut n = 0;
348    while n < blocks {
349        // Offset of block n of this chunk.
350        let offset = base + gap * n as i32;
351        // Too early (its chain ends are not packed yet) or a collision.
352        if offset < block_ready(first_block + n, end_delay) || !take(&mut trial, offset) {
353            return false;
354        }
355        n += 1;
356    }
357    true
358}
359
360/// Earliest feasible `(base, gap)` for a chunk, minimising its finish time.
361///
362/// Searches `gap` from `delay` (blocks of one chunk depend on each other, so
363/// they must be at least one use-delay apart) up to `delay + 67`, and `base`
364/// from the first block's ready cycle up to 67 cycles later: one period of
365/// each, since shifting by a whole period lands on the same residues.
366/// Panics at compile time if nothing fits.
367const fn solve_chunk(
368    used: &[bool; KEY_PERIOD],
369    first_block: usize,
370    blocks: usize,
371    delay: i32,
372    end_delay: i32,
373) -> (i32, i32) {
374    // The first block cannot start before its chain ends are packed.
375    let earliest = block_ready(first_block, end_delay);
376    // Best placement so far, and the offset of its last block.
377    let mut best = (0, 0);
378    let mut best_finish = i32::MAX;
379    let mut gap = delay;
380    while gap < delay + KEY_PERIOD as i32 {
381        let mut base = earliest;
382        while base < earliest + KEY_PERIOD as i32 {
383            // Offset of the chunk's last block under this (base, gap).
384            let finish = base + gap * (blocks as i32 - 1);
385            // Keep it only if it finishes earlier and actually fits.
386            if finish < best_finish && place_chunk(used, first_block, blocks, base, gap, end_delay)
387            {
388                best = (base, gap);
389                best_finish = finish;
390            }
391            base += 1;
392        }
393        gap += 1;
394    }
395    assert!(best_finish != i32::MAX, "no collision-free chunk placement");
396    best
397}
398
399/// Build the timetable for a BLAKE3 latency and a chain-end delay.
400///
401/// `blake3_latency` is the compression pipeline's registered latency
402/// ([`PIPELINE_STAGES`](crate::blake3_core::PIPELINE_STAGES) for the LUT
403/// lane). `end_delay` is the cycles from a chain's issue until its end reaches
404/// the commitment packer (the chain engine's latency plus wiring registers).
405///
406/// # Panics
407///
408/// At compile time, if no collision-free placement exists (more than 68 ops).
409///
410/// See the module doc ("How the solver works") for the order of placement
411/// and the reasoning behind it.
412#[must_use]
413pub const fn solve(blake3_latency: i32, end_delay: i32) -> Schedule {
414    // D: issue-to-usable delay of every BLAKE3 result.
415    let delay = blake3_latency + 4;
416    // Which residues are already claimed.
417    let mut used = [false; KEY_PERIOD];
418    // Result: offset of every op, filled in below.
419    let mut offsets = [0; OPS];
420    // The 16-block chunk-0 chain is the hardest constraint: place it first.
421    let (base0, gap0) = solve_chunk(&used, 0, CHUNK0_BLOCKS, delay, end_delay);
422    let mut n = 0;
423    while n < CHUNK0_BLOCKS {
424        offsets[op_index(Op::Chunk0(n as u8))] = base0 + gap0 * n as i32;
425        take(&mut used, offsets[op_index(Op::Chunk0(n as u8))]);
426        n += 1;
427    }
428    // Chunk 1 (commitment blocks 16..20) fits around chunk 0's residues.
429    let (base1, gap1) = solve_chunk(&used, CHUNK0_BLOCKS, CHUNK1_BLOCKS, delay, end_delay);
430    n = 0;
431    while n < CHUNK1_BLOCKS {
432        offsets[op_index(Op::Chunk1(n as u8))] = base1 + gap1 * n as i32;
433        take(&mut used, offsets[op_index(Op::Chunk1(n as u8))]);
434        n += 1;
435    }
436    // The parent needs both chunks' final chaining values.
437    let last0 = offsets[op_index(Op::Chunk0(CHUNK0_BLOCKS as u8 - 1))];
438    let last1 = offsets[op_index(Op::Chunk1(CHUNK1_BLOCKS as u8 - 1))];
439    let after = if last0 > last1 { last0 } else { last1 };
440    offsets[op_index(Op::Parent)] = earliest_free(&mut used, after + delay);
441    // XOF blocks: as late as possible so the secret buffer stays small.
442    // Tracks the earliest op that reads the seed, so the seed can be placed
443    // D before it. Starts at the nonces' deadline.
444    let mut earliest_seed_use = -1 - delay;
445    n = XOF_BLOCKS;
446    while n > 0 {
447        n -= 1;
448        // Deadline: D before the cycle the window load needs block n
449        // (one cycle before its first chain).
450        let offset = latest_free(&mut used, first_chain_of_xof(n) - 1 - delay);
451        offsets[op_index(Op::Xof(n as u8))] = offset;
452        if offset < earliest_seed_use {
453            earliest_seed_use = offset;
454        }
455    }
456    // pk_nonce salts chain 0 at T; the signer's nonce is needed much later.
457    offsets[op_index(Op::PkNonce)] = latest_free(&mut used, -1 - delay);
458    offsets[op_index(Op::Nonce)] = latest_free(&mut used, -1 - delay);
459    // The seed must be usable before its earliest reader (XOF or nonce).
460    let mut seed_limit = earliest_seed_use;
461    if offsets[op_index(Op::PkNonce)] < seed_limit {
462        seed_limit = offsets[op_index(Op::PkNonce)];
463    }
464    if offsets[op_index(Op::Nonce)] < seed_limit {
465        seed_limit = offsets[op_index(Op::Nonce)];
466    }
467    offsets[op_index(Op::Seed)] = latest_free(&mut used, seed_limit - delay);
468    // The reserved slots carry outside work; any free residue will do.
469    offsets[op_index(Op::Merkle)] = earliest_free(&mut used, 0);
470    offsets[op_index(Op::Message)] = earliest_free(&mut used, 0);
471    if TAIL_OPS == 1 {
472        // The tail needs the first block's chaining value: one lane latency
473        // plus the result register, the signer's latch and its request
474        // register after the message slot. Placed within the same period,
475        // a signature's two blocks still take one key period.
476        let after = offsets[op_index(Op::Message)] + delay + TAIL_SLACK;
477        offsets[op_index(Op::MessageTail)] = earliest_free(&mut used, after);
478    }
479    // Invert the placement: for each residue, which op (if any) runs there.
480    let mut residue = [None; KEY_PERIOD];
481    let mut index = 0;
482    while index < OPS {
483        residue[residue_of(offsets[index])] = Some(index as u8);
484        index += 1;
485    }
486    Schedule {
487        offsets,
488        residue,
489        use_delay: delay,
490        end_delay,
491    }
492}
493
494/// Convenience queries used by the factory, the tests and the documentation.
495impl Schedule {
496    /// Issue offset of `op` relative to its key's chain-0 cycle.
497    #[must_use]
498    pub const fn offset(&self, op: Op) -> i32 {
499        self.offsets[op_index(op)]
500    }
501
502    /// Cycles from the seed compression to a usable `pk_hash` for one key.
503    ///
504    /// How long one key is "in flight". Throughput does not depend on it
505    /// (keys overlap); it decides how many contexts the factory needs.
506    #[must_use]
507    pub const fn key_latency(&self) -> i32 {
508        self.offset(Op::Parent) + self.use_delay - self.offset(Op::Seed)
509    }
510
511    /// Idle residues per period: spare BLAKE3 capacity in the slice.
512    ///
513    /// 68 − [`OPS`] = 22 for the current op list.
514    #[must_use]
515    pub const fn idle_slots(&self) -> usize {
516        // Count residues with no op.
517        let mut idle = 0;
518        let mut r = 0;
519        while r < KEY_PERIOD {
520            if self.residue[r].is_none() {
521                idle += 1;
522            }
523            r += 1;
524        }
525        idle
526    }
527}