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}