Skip to main content

wots_rhdl/
cluster.rs

1//! One-lane, four-context SHA-profile WOTS cluster.
2//!
3//! This module is the first complete lane-local slice of the selected
4//! three-cluster topology. Four structurally identical
5//! [`SingleContextWorkEngine`] instances share one fully pipelined SHA-256
6//! compression lane. Request arbitration and response routing stay local, so a
7//! later top level only needs to combine three registered output streams; it
8//! never needs to mux context RAM outputs or complete retained results.
9//!
10//! Requests are selected by a rotating four-way arbiter. A canonical request
11//! may issue at most once per cycle, and the cursor advances past the accepted
12//! context. The lane has no backpressure. Its response tag is validated before
13//! the payload is routed to exactly one local context. The tag's context field
14//! is always local (`0..=3`); lane or SLR identity is deliberately absent.
15//! Generation remains in the canonical tag, while the opaque external job
16//! identifier is retained in separate resetless cluster storage.
17//!
18//! Completed, non-faulted contexts are framed without exposing their wide RAM
19//! ports outside the cluster. A result read has one mandatory cycle of latency.
20//! [`LaneLocalResultFramer`] pipelines those reads through a two-entry elastic
21//! beat buffer, sustaining one accepted 512-bit beat per cycle once primed when
22//! the consumer is always ready. Arbitrary stalls are a correctness contract:
23//! the registered head beat remains stable and at most one read response is in
24//! flight. A frame owns one context until beat 34 transfers, so frames cannot
25//! interleave and the context's output credit is released only on that final
26//! handshake.
27//!
28//! A context whose sticky `fault` coexists with `result_valid` is released and
29//! discarded without emitting any successful beat. Malformed or out-of-range
30//! lane responses are routed nowhere and latch an observable cluster fault.
31//! Shared reset flushes the lane, all contexts, the framer, cursors, valid bits,
32//! and sticky fault state. Resetless data registers are ignored while their
33//! separately reset ownership bits are clear.
34//!
35//! The implementation has source and behavioral-simulation evidence only.
36//! Structural identity is explicit through [`ReplicatedSynchronous`], but that
37//! is not synthesis, placement, timing, resource, or hardware evidence.
38
39use rhdl::{core::ReplicatedSynchronous, prelude::*};
40use rhdl_fpga::core::dff::DFF;
41use rhdl_primitives::NoResetDff;
42use sha256_rhdl::lane::{
43    CompressionInput, CompressionOutput, ExternalFullCompressionLane, InlineCompressionLane,
44};
45
46use crate::{
47    blocks::HashBytes,
48    digits::MessageBytes,
49    engine::{ContextEngineInput, SingleContextWorkEngine},
50    tag::{decode_tag_kernel, tag_is_well_formed_kernel},
51};
52
53/// Number of scheduler contexts local to one SHA compression lane.
54pub const LANE_LOCAL_CONTEXTS: usize = 4;
55/// Number of 512-bit transfers in one fused signature/public-key frame.
56pub const LANE_LOCAL_OUTPUT_BEATS: usize = 35;
57/// Number of registered beat slots in the local elastic output buffer.
58pub const LANE_LOCAL_BEAT_BUFFER_DEPTH: usize = 2;
59
60const _: () = {
61    assert!(LANE_LOCAL_CONTEXTS == 4);
62    assert!(LANE_LOCAL_OUTPUT_BEATS == 35);
63    assert!(LANE_LOCAL_BEAT_BUFFER_DEPTH == 2);
64};
65
66/// Exact synchronous interface required from a lane-local compression engine.
67///
68/// The default implementation is [`InlineCompressionLane`], a copyable
69/// type-level adapter that delegates exactly to
70/// [`sha256_rhdl::lane::CompressionLane`]. The modular production top instead
71/// injects [`ExternalFullCompressionLane`], whose behavioral model remains the
72/// complete lane while its HDL descriptor is resolved from a separately
73/// generated RHDL source file.
74pub trait CompressionLaneComponent:
75    Synchronous<I = CompressionInput, O = CompressionOutput>
76    + Clone
77    + Copy
78    + PartialEq
79    + core::fmt::Debug
80{
81}
82
83impl<T> CompressionLaneComponent for T where
84    T: Synchronous<I = CompressionInput, O = CompressionOutput>
85        + Clone
86        + Copy
87        + PartialEq
88        + core::fmt::Debug
89{
90}
91
92/// One registered lane-local stream transfer.
93///
94/// `data`, `keep`, and `last` map directly to a later 512-bit streaming shell.
95/// `job_id` is opaque metadata stored outside the SHA state and is constant for
96/// all 35 beats of a frame.
97#[derive(Clone, Copy, Debug, Digital, Eq, PartialEq)]
98pub struct LaneLocalBeat {
99    /// Sixty-four byte lanes in canonical signature/public-key order.
100    pub data: [b8; 64],
101    /// One validity bit per byte lane.
102    pub keep: b64,
103    /// True only for beat 34.
104    pub last: bool,
105    /// Opaque external identity associated with the retained context.
106    pub job_id: b32,
107}
108
109impl Default for LaneLocalBeat {
110    fn default() -> Self {
111        Self {
112            data: [b8(0); 64],
113            keep: b64(0),
114            last: false,
115            job_id: b32(0),
116        }
117    }
118}
119
120/// Inputs needed to assemble one canonical output beat.
121#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
122pub struct AssembleLaneLocalBeatInput {
123    /// Beat index `0..=34`.
124    pub beat: b6,
125    /// Signature segment `2 * beat` for beats `0..=33`.
126    pub signature_first: HashBytes,
127    /// Signature segment `2 * beat + 1` for beats `0..=32`.
128    pub signature_second: HashBytes,
129    /// Public seed placed in the high half of beat 33.
130    pub public_seed: HashBytes,
131    /// Public-key hash placed in the low half of beat 34.
132    pub public_key_hash: HashBytes,
133    /// Opaque job identity copied to every transfer.
134    pub job_id: b32,
135}
136
137/// Assemble one canonical 512-bit lane-local output transfer.
138///
139/// Beats 0 through 32 contain two signature segments, beat 33 contains segment
140/// 66 followed by the public seed, and beat 34 contains only the public-key
141/// hash in its valid low half. Out-of-range indices return an invalid zero beat.
142#[kernel]
143pub fn assemble_lane_local_beat_kernel(input: AssembleLaneLocalBeatInput) -> LaneLocalBeat {
144    let mut output = LaneLocalBeat::default();
145    if input.beat < b6(33) {
146        for lane in 0..32 {
147            output.data[lane] = input.signature_first[lane];
148            output.data[lane + 32] = input.signature_second[lane];
149        }
150        output.keep = b64(0xffff_ffff_ffff_ffff);
151        output.job_id = input.job_id;
152    } else if input.beat == b6(33) {
153        for lane in 0..32 {
154            output.data[lane] = input.signature_first[lane];
155            output.data[lane + 32] = input.public_seed[lane];
156        }
157        output.keep = b64(0xffff_ffff_ffff_ffff);
158        output.job_id = input.job_id;
159    } else if input.beat == b6(34) {
160        for lane in 0..32 {
161            output.data[lane] = input.public_key_hash[lane];
162        }
163        output.keep = b64(0xffff_ffff);
164        output.last = true;
165        output.job_id = input.job_id;
166    }
167    output
168}
169
170/// Compact request-arbitration input for four local contexts.
171#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
172pub struct LaneIssueControlInput {
173    /// Request-valid bits by local context.
174    pub valid: [bool; LANE_LOCAL_CONTEXTS],
175    /// Complete canonical task tags by local context.
176    pub tags: [b32; LANE_LOCAL_CONTEXTS],
177}
178
179/// One-cycle rotating request-arbitration decision.
180#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
181pub struct LaneIssueControlOutput {
182    /// At least one canonical request was selected.
183    pub valid: bool,
184    /// Selected local context, meaningful only when `valid` is true.
185    pub context: b2,
186    /// One-hot request handshakes.
187    pub accepted: [bool; LANE_LOCAL_CONTEXTS],
188    /// Requests rejected because their tag is malformed or names another slot.
189    pub rejected: [bool; LANE_LOCAL_CONTEXTS],
190    /// Cursor to register after this decision.
191    pub next_cursor: b2,
192}
193
194/// Select at most one canonical local request with rotating fairness.
195#[kernel]
196#[allow(clippy::assign_op_pattern)] // Compound assignment is not lowered by pinned RHDL.
197pub fn lane_issue_control_kernel(
198    input: LaneIssueControlInput,
199    cursor: b2,
200) -> LaneIssueControlOutput {
201    let fields = [
202        decode_tag_kernel(input.tags[0]),
203        decode_tag_kernel(input.tags[1]),
204        decode_tag_kernel(input.tags[2]),
205        decode_tag_kernel(input.tags[3]),
206    ];
207    let canonical = [
208        tag_is_well_formed_kernel(input.tags[0]) && fields[0].context == b4(0),
209        tag_is_well_formed_kernel(input.tags[1]) && fields[1].context == b4(1),
210        tag_is_well_formed_kernel(input.tags[2]) && fields[2].context == b4(2),
211        tag_is_well_formed_kernel(input.tags[3]) && fields[3].context == b4(3),
212    ];
213    let rejected = [
214        input.valid[0] && !canonical[0],
215        input.valid[1] && !canonical[1],
216        input.valid[2] && !canonical[2],
217        input.valid[3] && !canonical[3],
218    ];
219    let eligible = [
220        input.valid[0] && canonical[0],
221        input.valid[1] && canonical[1],
222        input.valid[2] && canonical[2],
223        input.valid[3] && canonical[3],
224    ];
225    let mut output = LaneIssueControlOutput {
226        rejected,
227        next_cursor: cursor,
228        ..LaneIssueControlOutput::default()
229    };
230    let mut scan = cursor;
231    for _offset in 0..LANE_LOCAL_CONTEXTS {
232        if !output.valid && eligible[scan] {
233            output.valid = true;
234            output.context = scan;
235            output.accepted[scan] = true;
236            output.next_cursor = scan + b2(1);
237        }
238        scan = scan + b2(1);
239    }
240    output
241}
242
243/// Request payloads presented by all four local contexts.
244#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
245pub struct LaneIssueInput {
246    /// Stable context-owned compression requests.
247    pub requests: [CompressionInput; LANE_LOCAL_CONTEXTS],
248}
249
250/// Selected lane payload and per-context handshakes.
251#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
252pub struct LaneIssueOutput {
253    /// Compression token sent to the local lane.
254    pub lane: CompressionInput,
255    /// Per-context request handshakes.
256    pub accepted: [bool; LANE_LOCAL_CONTEXTS],
257    /// Per-context malformed or wrongly owned requests.
258    pub rejected: [bool; LANE_LOCAL_CONTEXTS],
259    /// Cursor to register after this decision.
260    pub next_cursor: b2,
261}
262
263/// Mux the request selected by [`lane_issue_control_kernel`].
264#[kernel]
265pub fn issue_lane_request_kernel(input: LaneIssueInput, cursor: b2) -> LaneIssueOutput {
266    let control = lane_issue_control_kernel(
267        LaneIssueControlInput {
268            valid: [
269                input.requests[0].valid,
270                input.requests[1].valid,
271                input.requests[2].valid,
272                input.requests[3].valid,
273            ],
274            tags: [
275                input.requests[0].tag,
276                input.requests[1].tag,
277                input.requests[2].tag,
278                input.requests[3].tag,
279            ],
280        },
281        cursor,
282    );
283    let mut lane = CompressionInput::default();
284    if control.valid {
285        lane = match control.context {
286            Bits::<2>(0) => input.requests[0],
287            Bits::<2>(1) => input.requests[1],
288            Bits::<2>(2) => input.requests[2],
289            _ => input.requests[3],
290        };
291    }
292    LaneIssueOutput {
293        lane,
294        accepted: control.accepted,
295        rejected: control.rejected,
296        next_cursor: control.next_cursor,
297    }
298}
299
300/// Fail-closed routing result for one local lane response.
301#[allow(clippy::struct_excessive_bools)] // Each flag is an independent protocol wire.
302#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
303pub struct LaneResponseRouteOutput {
304    /// One response slot per local context.
305    pub contexts: [CompressionOutput; LANE_LOCAL_CONTEXTS],
306    /// True when a valid canonical response was routed.
307    pub accepted: bool,
308    /// Destination local context, meaningful only when `accepted` is true.
309    pub context: b2,
310    /// A valid response had a noncanonical task tag.
311    pub malformed: bool,
312    /// A valid canonical response named context `4..=15`.
313    pub out_of_range: bool,
314    /// Union of all response rejection classes.
315    pub rejected: bool,
316}
317
318/// Validate and route one fixed-latency lane response.
319#[kernel]
320pub fn route_lane_response_kernel(response: CompressionOutput) -> LaneResponseRouteOutput {
321    let fields = decode_tag_kernel(response.tag);
322    let canonical = tag_is_well_formed_kernel(response.tag);
323    let in_range = fields.context < b4(4);
324    let accepted = response.valid && canonical && in_range;
325    let malformed = response.valid && !canonical;
326    let out_of_range = response.valid && canonical && !in_range;
327    let mut contexts = [CompressionOutput::default(); LANE_LOCAL_CONTEXTS];
328    if accepted {
329        let local_context: b2 = fields.context.resize();
330        contexts[local_context] = response;
331    }
332    LaneResponseRouteOutput {
333        contexts,
334        accepted,
335        context: fields.context.resize(),
336        malformed,
337        out_of_range,
338        rejected: malformed || out_of_range,
339    }
340}
341
342/// Selectable retained-result view consumed by the lane-local framer.
343#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
344pub struct LaneFramerContextInput {
345    /// A complete result is retained by this context.
346    pub result_valid: bool,
347    /// Sticky integrity fault associated with the active or retained job.
348    pub fault: bool,
349    /// A prior accepted pair read returns this cycle.
350    pub result_read_valid: bool,
351    /// Pair address associated with the returned data.
352    pub result_read_pair: b6,
353    /// Even signature segment returned by the pair read.
354    pub signature_first: HashBytes,
355    /// Odd signature segment returned by the pair read.
356    pub signature_second: HashBytes,
357    /// Retained public seed.
358    pub public_seed: HashBytes,
359    /// Retained public-key hash.
360    pub public_key_hash: HashBytes,
361    /// Opaque job identity stored by the cluster.
362    pub job_id: b32,
363}
364
365/// Inputs to the registered lane-local result framer.
366#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
367pub struct LaneLocalResultFramerInput {
368    /// Retained-result views for all four local contexts.
369    pub contexts: [LaneFramerContextInput; LANE_LOCAL_CONTEXTS],
370    /// Downstream acceptance for the currently registered head beat.
371    pub output_ready: bool,
372    /// Host acceptance for a held faulted-job completion.
373    pub error_ready: bool,
374}
375
376/// Framer read/release controls and registered stream output.
377#[allow(clippy::struct_excessive_bools)] // Independent protocol wires.
378#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
379pub struct LaneLocalResultFramerOutput {
380    /// Per-context retained-memory read enables.
381    pub result_read_enable: [bool; LANE_LOCAL_CONTEXTS],
382    /// Pair address accompanying each read enable.
383    pub result_read_pair: [b6; LANE_LOCAL_CONTEXTS],
384    /// Per-context final-beat or fault-discard releases.
385    pub result_taken: [bool; LANE_LOCAL_CONTEXTS],
386    /// Faulted retained results discarded in this cycle.
387    pub discarded: [bool; LANE_LOCAL_CONTEXTS],
388    /// A faulted-job completion with retained identity is available.
389    pub error_valid: bool,
390    /// Opaque identity of the held faulted-job completion.
391    pub error_job_id: b32,
392    /// Local context owning the held faulted-job completion.
393    pub error_context: b2,
394    /// Error completion transferred and released its context this cycle.
395    pub error_credit_release: bool,
396    /// A registered output beat is available.
397    pub output_valid: bool,
398    /// Registered head beat, stable while valid and not ready.
399    pub output: LaneLocalBeat,
400    /// One complete frame currently owns a context.
401    pub frame_active: bool,
402    /// Owning local context, meaningful while `frame_active` or `output_valid`.
403    pub frame_context: b2,
404    /// Final beat transferred and released its context this cycle.
405    pub output_credit_release: bool,
406    /// Sticky local read/framing protocol failure.
407    pub fault: bool,
408}
409
410/// Narrow resettable state for [`LaneLocalResultFramer`].
411#[allow(clippy::struct_excessive_bools)] // Each bit owns an independent protocol condition.
412#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
413pub struct LaneLocalFramerControl {
414    /// Rotating result-selection cursor.
415    pub cursor: b2,
416    /// A frame currently owns one context.
417    pub frame_active: bool,
418    /// Context owned by the current frame.
419    pub frame_context: b2,
420    /// Next beat index not yet requested or buffered.
421    pub next_fetch: b6,
422    /// One synchronous retained-memory response is expected.
423    pub read_inflight: bool,
424    /// Exact pair address expected from the in-flight read.
425    pub read_pair: b6,
426    /// Number of valid registered beats (`0..=2`).
427    pub fifo_count: b2,
428    /// Sticky local read/framing protocol failure.
429    pub fault: bool,
430    /// A faulted-job completion is held for the host.
431    pub error_valid: bool,
432    /// Context associated with the held error completion.
433    pub error_context: b2,
434    /// Rotating selection cursor for faulted completed jobs.
435    pub error_cursor: b2,
436    /// A final data or error handshake has asked a context to release.
437    pub release_pending: bool,
438    /// Context whose retained-result clear is awaited.
439    pub release_context: b2,
440}
441
442/// Fair retained-result selection.
443#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
444pub struct LaneResultSelection {
445    /// At least one non-faulted retained result is available.
446    pub valid: bool,
447    /// Selected local context.
448    pub context: b2,
449    /// Cursor following the selected context.
450    pub next_cursor: b2,
451}
452
453/// Select one non-faulted result with rotating fairness.
454#[kernel]
455#[allow(clippy::assign_op_pattern)] // Compound assignment is not lowered by pinned RHDL.
456pub fn select_lane_result_kernel(
457    eligible: [bool; LANE_LOCAL_CONTEXTS],
458    cursor: b2,
459) -> LaneResultSelection {
460    let mut selection = LaneResultSelection {
461        next_cursor: cursor,
462        ..LaneResultSelection::default()
463    };
464    let mut scan = cursor;
465    for _offset in 0..LANE_LOCAL_CONTEXTS {
466        if !selection.valid && eligible[scan] {
467            selection.valid = true;
468            selection.context = scan;
469            selection.next_cursor = scan + b2(1);
470        }
471        scan = scan + b2(1);
472    }
473    selection
474}
475
476/// Registered, non-interleaving lane-local result framer.
477#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
478pub struct LaneLocalResultFramer {
479    control: DFF<LaneLocalFramerControl>,
480    head: NoResetDff<LaneLocalBeat>,
481    tail: NoResetDff<LaneLocalBeat>,
482    error_job_id: NoResetDff<b32>,
483}
484
485impl Default for LaneLocalResultFramer {
486    fn default() -> Self {
487        Self {
488            control: DFF::new(LaneLocalFramerControl::default()),
489            head: NoResetDff::new(),
490            tail: NoResetDff::new(),
491            error_job_id: NoResetDff::new(),
492        }
493    }
494}
495
496impl SynchronousIO for LaneLocalResultFramer {
497    type I = LaneLocalResultFramerInput;
498    type O = LaneLocalResultFramerOutput;
499    type Kernel = lane_local_result_framer_kernel;
500}
501
502/// State transition for [`LaneLocalResultFramer`].
503#[kernel]
504#[allow(clippy::assign_op_pattern)] // Compound assignment is not lowered by pinned RHDL.
505pub fn lane_local_result_framer_kernel(
506    clock_reset: ClockReset,
507    input: LaneLocalResultFramerInput,
508    q: LaneLocalResultFramerQ,
509) -> (LaneLocalResultFramerOutput, LaneLocalResultFramerD) {
510    let resetting = clock_reset.reset.any();
511    let mut control = q.control;
512    let mut head = q.head;
513    let mut tail = q.tail;
514    let mut error_job_id = q.error_job_id;
515    let mut result_read_enable = [false; LANE_LOCAL_CONTEXTS];
516    let mut result_read_pair = [b6(0); LANE_LOCAL_CONTEXTS];
517    let mut result_taken = [false; LANE_LOCAL_CONTEXTS];
518    let mut discarded = [false; LANE_LOCAL_CONTEXTS];
519    let mut output_credit_release = false;
520    let mut error_credit_release = false;
521
522    if !resetting
523        && q.control.release_pending
524        && !input.contexts[q.control.release_context].result_valid
525    {
526        control.release_pending = false;
527    }
528
529    // A faulted result owns its context until the host accepts one registered
530    // error completion carrying the original job identity. This prevents a
531    // silent discard from leaving software waiting forever.
532    if !resetting && q.control.error_valid && input.error_ready {
533        result_taken[q.control.error_context] = true;
534        discarded[q.control.error_context] = true;
535        error_credit_release = true;
536        control.error_valid = false;
537        control.release_pending = true;
538        control.release_context = q.control.error_context;
539    }
540
541    let mut selected_frame_invalid = false;
542    let mut expected_return = false;
543    if !resetting && q.control.frame_active {
544        let selected = input.contexts[q.control.frame_context];
545        selected_frame_invalid = !selected.result_valid || selected.fault;
546        expected_return = q.control.read_inflight
547            && selected.result_read_valid
548            && selected.result_read_pair == q.control.read_pair;
549    }
550    let mut unexpected_return = false;
551    if !resetting {
552        for context in 0..LANE_LOCAL_CONTEXTS {
553            if input.contexts[context].result_read_valid
554                && (!q.control.frame_active
555                    || q.control.frame_context != b2(context as u128)
556                    || !q.control.read_inflight
557                    || input.contexts[context].result_read_pair != q.control.read_pair)
558            {
559                unexpected_return = true;
560            }
561        }
562    }
563    let corrupt_fifo = q.control.fifo_count == b2(3);
564    let orphan_final = q.control.fifo_count != b2(0) && q.head.last && !q.control.frame_active;
565    let suppress_output =
566        selected_frame_invalid || unexpected_return || corrupt_fifo || orphan_final;
567    let output_valid = !resetting && q.control.fifo_count != b2(0) && !suppress_output;
568    let output_handshake = output_valid && input.output_ready;
569    if output_handshake {
570        if q.head.last {
571            if q.control.frame_active {
572                result_taken[q.control.frame_context] = true;
573                output_credit_release = true;
574                control.release_pending = true;
575                control.release_context = q.control.frame_context;
576            } else {
577                control.fault = true;
578            }
579            control.frame_active = false;
580            control.read_inflight = false;
581            control.next_fetch = b6(0);
582            control.fifo_count = b2(0);
583        } else if q.control.fifo_count == b2(2) {
584            head = q.tail;
585            control.fifo_count = b2(1);
586        } else if q.control.fifo_count == b2(1) {
587            control.fifo_count = b2(0);
588        } else {
589            control.fault = true;
590            control.fifo_count = b2(0);
591        }
592    } else if corrupt_fifo || orphan_final {
593        control.fault = true;
594        control.fifo_count = b2(0);
595    }
596
597    if !resetting && control.frame_active && selected_frame_invalid {
598        let selected = input.contexts[control.frame_context];
599        if selected.result_valid && !control.error_valid {
600            control.error_valid = true;
601            control.error_context = control.frame_context;
602            control.error_cursor = control.frame_context + b2(1);
603            error_job_id = selected.job_id;
604        }
605        control.frame_active = false;
606        control.read_inflight = false;
607        control.next_fetch = b6(0);
608        control.fifo_count = b2(0);
609        control.fault = true;
610    }
611
612    // A returned pair must belong to the one selected context and match the
613    // exact outstanding address. Any other pulse is fail-closed.
614    if !resetting && unexpected_return {
615        for context in 0..LANE_LOCAL_CONTEXTS {
616            if input.contexts[context].result_read_valid
617                && (!q.control.frame_active
618                    || q.control.frame_context != b2(context as u128)
619                    || !q.control.read_inflight
620                    || input.contexts[context].result_read_pair != q.control.read_pair)
621            {
622                control.fault = true;
623                control.frame_active = false;
624                control.read_inflight = false;
625                control.next_fetch = b6(0);
626                control.fifo_count = b2(0);
627                if input.contexts[context].result_valid && !control.error_valid {
628                    control.error_valid = true;
629                    control.error_context = b2(context as u128);
630                    control.error_cursor = b2(context as u128) + b2(1);
631                    error_job_id = input.contexts[context].job_id;
632                }
633            }
634        }
635    }
636
637    if expected_return && control.frame_active {
638        let selected = input.contexts[control.frame_context];
639        let incoming = assemble_lane_local_beat_kernel(AssembleLaneLocalBeatInput {
640            beat: selected.result_read_pair,
641            signature_first: selected.signature_first,
642            signature_second: selected.signature_second,
643            public_seed: selected.public_seed,
644            public_key_hash: selected.public_key_hash,
645            job_id: selected.job_id,
646        });
647        control.read_inflight = false;
648        if control.fifo_count == b2(0) {
649            head = incoming;
650            control.fifo_count = b2(1);
651        } else if control.fifo_count == b2(1) {
652            tail = incoming;
653            control.fifo_count = b2(2);
654        } else {
655            control.fault = true;
656            control.frame_active = false;
657            control.fifo_count = b2(0);
658            if !control.error_valid {
659                control.error_valid = true;
660                control.error_context = control.frame_context;
661                control.error_cursor = control.frame_context + b2(1);
662                error_job_id = selected.job_id;
663            }
664        }
665    }
666
667    // Beat 34 comes from the retained public-key hash rather than a RAM read.
668    // It may fill the second elastic slot in the same cycle pair 33 returns.
669    if !resetting
670        && control.frame_active
671        && !control.read_inflight
672        && control.next_fetch == b6(34)
673        && control.fifo_count < b2(2)
674    {
675        let selected = input.contexts[control.frame_context];
676        let incoming = assemble_lane_local_beat_kernel(AssembleLaneLocalBeatInput {
677            beat: b6(34),
678            signature_first: [b8(0); 32],
679            signature_second: [b8(0); 32],
680            public_seed: selected.public_seed,
681            public_key_hash: selected.public_key_hash,
682            job_id: selected.job_id,
683        });
684        if control.fifo_count == b2(0) {
685            head = incoming;
686            control.fifo_count = b2(1);
687        } else {
688            tail = incoming;
689            control.fifo_count = b2(2);
690        }
691        control.next_fetch = b6(35);
692    }
693
694    // Reserve one of the two elastic slots before launching the next
695    // synchronous pair read. This prevents a readiness change from overrunning
696    // the registered head/tail buffer.
697    if !resetting
698        && control.frame_active
699        && !control.read_inflight
700        && control.next_fetch < b6(34)
701        && control.fifo_count < b2(2)
702    {
703        result_read_enable[control.frame_context] = true;
704        result_read_pair[control.frame_context] = control.next_fetch;
705        control.read_inflight = true;
706        control.read_pair = control.next_fetch;
707        control.next_fetch = control.next_fetch + b6(1);
708    }
709
710    // Acquire a new frame only while no context or buffered beat is owned. The
711    // first pair read is launched immediately, but its data cannot be emitted
712    // until the explicit return cycle.
713    if !resetting
714        && !control.frame_active
715        && control.fifo_count == b2(0)
716        && !control.error_valid
717        && !control.release_pending
718        && !control.fault
719    {
720        let faulted = [
721            input.contexts[0].result_valid && input.contexts[0].fault,
722            input.contexts[1].result_valid && input.contexts[1].fault,
723            input.contexts[2].result_valid && input.contexts[2].fault,
724            input.contexts[3].result_valid && input.contexts[3].fault,
725        ];
726        let error_selection = select_lane_result_kernel(faulted, control.error_cursor);
727        if error_selection.valid {
728            control.error_valid = true;
729            control.error_context = error_selection.context;
730            control.error_cursor = error_selection.next_cursor;
731            error_job_id = input.contexts[error_selection.context].job_id;
732        }
733    }
734
735    if !resetting
736        && !control.frame_active
737        && control.fifo_count == b2(0)
738        && !control.error_valid
739        && !control.release_pending
740        && !control.fault
741    {
742        let eligible = [
743            input.contexts[0].result_valid && !input.contexts[0].fault,
744            input.contexts[1].result_valid && !input.contexts[1].fault,
745            input.contexts[2].result_valid && !input.contexts[2].fault,
746            input.contexts[3].result_valid && !input.contexts[3].fault,
747        ];
748        let selection = select_lane_result_kernel(eligible, control.cursor);
749        if selection.valid {
750            control.frame_active = true;
751            control.frame_context = selection.context;
752            control.cursor = selection.next_cursor;
753            control.next_fetch = b6(1);
754            control.read_inflight = true;
755            control.read_pair = b6(0);
756            result_read_enable[selection.context] = true;
757            result_read_pair[selection.context] = b6(0);
758        }
759    }
760
761    if resetting {
762        control = LaneLocalFramerControl::default();
763        result_read_enable = [false; LANE_LOCAL_CONTEXTS];
764        result_read_pair = [b6(0); LANE_LOCAL_CONTEXTS];
765        result_taken = [false; LANE_LOCAL_CONTEXTS];
766        discarded = [false; LANE_LOCAL_CONTEXTS];
767        output_credit_release = false;
768        error_credit_release = false;
769    }
770
771    let output = LaneLocalResultFramerOutput {
772        result_read_enable,
773        result_read_pair,
774        result_taken,
775        discarded,
776        error_valid: !resetting && q.control.error_valid,
777        error_job_id: q.error_job_id,
778        error_context: q.control.error_context,
779        error_credit_release,
780        output_valid,
781        output: q.head,
782        frame_active: !resetting && q.control.frame_active,
783        frame_context: q.control.frame_context,
784        output_credit_release,
785        fault: !resetting && q.control.fault,
786    };
787    let d = LaneLocalResultFramerD {
788        control,
789        head,
790        tail,
791        error_job_id,
792    };
793    (output, d)
794}
795
796/// External input to one four-context lane-local cluster.
797#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
798pub struct LaneLocalClusterInput {
799    /// Offer one private-seed signing operation.
800    pub start_valid: bool,
801    /// Private seed consumed by the fused operation.
802    pub private_seed: HashBytes,
803    /// Already-hashed message selecting WOTS signature positions.
804    pub message: MessageBytes,
805    /// Opaque identity retained outside SHA state and returned on every beat.
806    pub job_id: b32,
807    /// Downstream acceptance for the registered output beat.
808    pub output_ready: bool,
809    /// Host acceptance for a held faulted-job completion.
810    pub error_ready: bool,
811}
812
813/// Observable status and registered stream output from one local cluster.
814#[allow(clippy::struct_excessive_bools)] // Independent protocol and audit wires.
815#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
816pub struct LaneLocalClusterOutput {
817    /// At least one context can accept the offered start.
818    pub start_ready: bool,
819    /// A start transferred in this cycle.
820    pub start_accepted: bool,
821    /// Local context selected for the offered start.
822    pub start_context: b2,
823    /// Generation assigned to the offered start.
824    pub start_generation: b8,
825    /// Contexts currently traversing their task DAG.
826    pub context_active: [bool; LANE_LOCAL_CONTEXTS],
827    /// Contexts owning active or retained output credit.
828    pub context_credit_reserved: [bool; LANE_LOCAL_CONTEXTS],
829    /// Contexts currently retaining complete results.
830    pub context_result_valid: [bool; LANE_LOCAL_CONTEXTS],
831    /// Context sticky fault indicators.
832    pub context_fault: [bool; LANE_LOCAL_CONTEXTS],
833    /// Per-context request acceptance pulses.
834    pub request_accepted: [bool; LANE_LOCAL_CONTEXTS],
835    /// A lane response was rejected before routing.
836    pub lane_response_rejected: bool,
837    /// Faulted retained results discarded in this cycle.
838    pub discarded: [bool; LANE_LOCAL_CONTEXTS],
839    /// A held faulted-job completion is available.
840    pub error_valid: bool,
841    /// Opaque identity of the held faulted-job completion.
842    pub error_job_id: b32,
843    /// Local context owning the held faulted-job completion.
844    pub error_context: b2,
845    /// Error completion transferred and released its context this cycle.
846    pub error_credit_release: bool,
847    /// A registered stream beat is available.
848    pub output_valid: bool,
849    /// Registered stream beat, stable under backpressure.
850    pub output: LaneLocalBeat,
851    /// Final beat transferred and released its context this cycle.
852    pub output_credit_release: bool,
853    /// Sticky cluster integrity or framing fault.
854    pub fault: bool,
855}
856
857/// Narrow resettable cluster control.
858#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
859pub struct LaneLocalClusterControl {
860    /// Rotating start-allocation cursor.
861    pub allocation_cursor: b2,
862    /// Rotating compression-request cursor.
863    pub issue_cursor: b2,
864    /// Sticky transport, context, or framer fault.
865    pub fault: bool,
866}
867
868/// Fair start-allocation decision.
869#[derive(Clone, Copy, Debug, Default, Digital, Eq, PartialEq)]
870pub struct LaneStartSelection {
871    /// At least one context reports `start_ready`.
872    pub valid: bool,
873    /// Selected local context.
874    pub context: b2,
875    /// Cursor following the selected context.
876    pub next_cursor: b2,
877}
878
879/// Select one ready context with rotating fairness.
880#[kernel]
881#[allow(clippy::assign_op_pattern)] // Compound assignment is not lowered by pinned RHDL.
882pub fn select_lane_start_kernel(
883    ready: [bool; LANE_LOCAL_CONTEXTS],
884    cursor: b2,
885) -> LaneStartSelection {
886    let mut selection = LaneStartSelection {
887        next_cursor: cursor,
888        ..LaneStartSelection::default()
889    };
890    let mut scan = cursor;
891    for _offset in 0..LANE_LOCAL_CONTEXTS {
892        if !selection.valid && ready[scan] {
893            selection.valid = true;
894            selection.context = scan;
895            selection.next_cursor = scan + b2(1);
896        }
897        scan = scan + b2(1);
898    }
899    selection
900}
901
902/// One SHA lane local to four structurally identical work contexts.
903///
904/// `ROUNDS` and `ADDRESS_BITS` inherit
905/// [`sha256_rhdl::lane::CompressionLane`]'s constraints. The production alias
906/// [`Sha256LaneLocalCluster`] binds them to 64 and 6. Smaller power-of-two lanes
907/// are useful for bounded structural and control tests; they exercise the same
908/// tags, routing, task counts, result memory, and framing but do not produce
909/// cryptographically meaningful SHA-256 digests.
910#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
911pub struct LaneLocalCluster<
912    const ROUNDS: usize,
913    const ADDRESS_BITS: usize,
914    L = InlineCompressionLane<ROUNDS, ADDRESS_BITS>,
915> where
916    rhdl::bits::W<ROUNDS>: BitWidth,
917    rhdl::bits::W<ADDRESS_BITS>: BitWidth,
918    L: CompressionLaneComponent,
919{
920    contexts: ReplicatedSynchronous<SingleContextWorkEngine, LANE_LOCAL_CONTEXTS>,
921    lane: L,
922    framer: LaneLocalResultFramer,
923    control: DFF<LaneLocalClusterControl>,
924    generations: DFF<[b8; LANE_LOCAL_CONTEXTS]>,
925    job_ids: NoResetDff<[b32; LANE_LOCAL_CONTEXTS]>,
926}
927
928impl<const ROUNDS: usize, const ADDRESS_BITS: usize, L> Default
929    for LaneLocalCluster<ROUNDS, ADDRESS_BITS, L>
930where
931    rhdl::bits::W<ROUNDS>: BitWidth,
932    rhdl::bits::W<ADDRESS_BITS>: BitWidth,
933    L: CompressionLaneComponent + Default,
934{
935    fn default() -> Self {
936        Self {
937            contexts: ReplicatedSynchronous::new(SingleContextWorkEngine::default()),
938            lane: L::default(),
939            framer: LaneLocalResultFramer::default(),
940            control: DFF::new(LaneLocalClusterControl::default()),
941            generations: DFF::new([b8(0); LANE_LOCAL_CONTEXTS]),
942            job_ids: NoResetDff::new(),
943        }
944    }
945}
946
947impl<const ROUNDS: usize, const ADDRESS_BITS: usize, L> SynchronousIO
948    for LaneLocalCluster<ROUNDS, ADDRESS_BITS, L>
949where
950    rhdl::bits::W<ROUNDS>: BitWidth,
951    rhdl::bits::W<ADDRESS_BITS>: BitWidth,
952    L: CompressionLaneComponent,
953{
954    type I = LaneLocalClusterInput;
955    type O = LaneLocalClusterOutput;
956    type Kernel = lane_local_cluster_with_lane_kernel<ROUNDS, ADDRESS_BITS, L>;
957}
958
959/// State transition and local connectivity for an injected compression lane.
960#[kernel]
961#[allow(clippy::needless_range_loop)] // Iterators are not lowered by pinned RHDL kernels.
962pub fn lane_local_cluster_with_lane_kernel<
963    const ROUNDS: usize,
964    const ADDRESS_BITS: usize,
965    L: CompressionLaneComponent,
966>(
967    clock_reset: ClockReset,
968    input: LaneLocalClusterInput,
969    q: LaneLocalClusterQ<ROUNDS, ADDRESS_BITS, L>,
970) -> (
971    LaneLocalClusterOutput,
972    LaneLocalClusterD<ROUNDS, ADDRESS_BITS, L>,
973)
974where
975    rhdl::bits::W<ROUNDS>: BitWidth,
976    rhdl::bits::W<ADDRESS_BITS>: BitWidth,
977{
978    let resetting = clock_reset.reset.any();
979    // Allocation is derived only from registered ownership state. Reading the
980    // child `start_ready` output here would create a combinational hierarchy
981    // cycle because this kernel also drives `context_enabled` and output credit.
982    let ready = [
983        !q.contexts[0].active
984            && !q.contexts[0].result_valid
985            && !q.contexts[0].output_credit_reserved,
986        !q.contexts[1].active
987            && !q.contexts[1].result_valid
988            && !q.contexts[1].output_credit_reserved,
989        !q.contexts[2].active
990            && !q.contexts[2].result_valid
991            && !q.contexts[2].output_credit_reserved,
992        !q.contexts[3].active
993            && !q.contexts[3].result_valid
994            && !q.contexts[3].output_credit_reserved,
995    ];
996    let start_selection = select_lane_start_kernel(ready, q.control.allocation_cursor);
997    let start_ready = !resetting && start_selection.valid;
998    let start_accepted = start_ready && input.start_valid;
999
1000    let issue = issue_lane_request_kernel(
1001        LaneIssueInput {
1002            requests: [
1003                q.contexts[0].request,
1004                q.contexts[1].request,
1005                q.contexts[2].request,
1006                q.contexts[3].request,
1007            ],
1008        },
1009        q.control.issue_cursor,
1010    );
1011    let returns = route_lane_response_kernel(q.lane);
1012    let mut lane_input = issue.lane;
1013    if resetting {
1014        lane_input.valid = false;
1015    }
1016
1017    let mut control = q.control;
1018    let mut generations = q.generations;
1019    let mut job_ids = q.job_ids;
1020    if start_accepted {
1021        control.allocation_cursor = start_selection.next_cursor;
1022        generations[start_selection.context] = q.generations[start_selection.context] + b8(1);
1023        job_ids[start_selection.context] = input.job_id;
1024    }
1025    if issue.lane.valid {
1026        control.issue_cursor = issue.next_cursor;
1027    }
1028
1029    let request_rejected =
1030        issue.rejected[0] || issue.rejected[1] || issue.rejected[2] || issue.rejected[3];
1031    let context_fault =
1032        q.contexts[0].fault || q.contexts[1].fault || q.contexts[2].fault || q.contexts[3].fault;
1033    let response_rejected = q.contexts[0].response_rejected
1034        || q.contexts[1].response_rejected
1035        || q.contexts[2].response_rejected
1036        || q.contexts[3].response_rejected;
1037    if !resetting
1038        && (request_rejected
1039            || returns.rejected
1040            || context_fault
1041            || response_rejected
1042            || q.framer.fault)
1043    {
1044        control.fault = true;
1045    }
1046    if resetting {
1047        control = LaneLocalClusterControl::default();
1048        generations = [b8(0); LANE_LOCAL_CONTEXTS];
1049    }
1050
1051    let mut context_inputs = [ContextEngineInput::default(); LANE_LOCAL_CONTEXTS];
1052    for context in 0..LANE_LOCAL_CONTEXTS {
1053        context_inputs[context].context = b4(context as u128);
1054        context_inputs[context].context_enabled = true;
1055        context_inputs[context].generation = q.generations[context];
1056        context_inputs[context].output_credit_available = true;
1057        context_inputs[context].request_ready = !resetting && issue.accepted[context];
1058        context_inputs[context].response = if resetting {
1059            CompressionOutput::default()
1060        } else {
1061            returns.contexts[context]
1062        };
1063        context_inputs[context].result_taken = !resetting && q.framer.result_taken[context];
1064        context_inputs[context].result_read_enable =
1065            !resetting && q.framer.result_read_enable[context];
1066        context_inputs[context].result_read_pair = q.framer.result_read_pair[context];
1067    }
1068    if start_accepted {
1069        context_inputs[start_selection.context].start_valid = true;
1070        context_inputs[start_selection.context].private_seed = input.private_seed;
1071        context_inputs[start_selection.context].message = input.message;
1072        context_inputs[start_selection.context].generation = q.generations[start_selection.context];
1073    }
1074
1075    let mut framer_contexts = [LaneFramerContextInput::default(); LANE_LOCAL_CONTEXTS];
1076    for context in 0..LANE_LOCAL_CONTEXTS {
1077        framer_contexts[context] = LaneFramerContextInput {
1078            result_valid: q.contexts[context].result_valid,
1079            fault: q.contexts[context].fault,
1080            result_read_valid: q.contexts[context].result_read_valid,
1081            result_read_pair: q.contexts[context].result_read_pair,
1082            signature_first: q.contexts[context].signature_first,
1083            signature_second: q.contexts[context].signature_second,
1084            public_seed: q.contexts[context].public_seed,
1085            public_key_hash: q.contexts[context].public_key_hash,
1086            job_id: q.job_ids[context],
1087        };
1088    }
1089
1090    let output = LaneLocalClusterOutput {
1091        start_ready,
1092        start_accepted,
1093        start_context: start_selection.context,
1094        start_generation: q.generations[start_selection.context],
1095        context_active: [
1096            !resetting && q.contexts[0].active,
1097            !resetting && q.contexts[1].active,
1098            !resetting && q.contexts[2].active,
1099            !resetting && q.contexts[3].active,
1100        ],
1101        context_credit_reserved: [
1102            !resetting && q.contexts[0].output_credit_reserved,
1103            !resetting && q.contexts[1].output_credit_reserved,
1104            !resetting && q.contexts[2].output_credit_reserved,
1105            !resetting && q.contexts[3].output_credit_reserved,
1106        ],
1107        context_result_valid: [
1108            !resetting && q.contexts[0].result_valid,
1109            !resetting && q.contexts[1].result_valid,
1110            !resetting && q.contexts[2].result_valid,
1111            !resetting && q.contexts[3].result_valid,
1112        ],
1113        context_fault: [
1114            !resetting && q.contexts[0].fault,
1115            !resetting && q.contexts[1].fault,
1116            !resetting && q.contexts[2].fault,
1117            !resetting && q.contexts[3].fault,
1118        ],
1119        request_accepted: if resetting {
1120            [false; LANE_LOCAL_CONTEXTS]
1121        } else {
1122            issue.accepted
1123        },
1124        lane_response_rejected: !resetting && returns.rejected,
1125        discarded: q.framer.discarded,
1126        error_valid: !resetting && q.framer.error_valid,
1127        error_job_id: q.framer.error_job_id,
1128        error_context: q.framer.error_context,
1129        error_credit_release: !resetting && q.framer.error_credit_release,
1130        output_valid: !resetting && q.framer.output_valid,
1131        output: q.framer.output,
1132        output_credit_release: !resetting && q.framer.output_credit_release,
1133        fault: !resetting && q.control.fault,
1134    };
1135    let d = LaneLocalClusterD::<ROUNDS, ADDRESS_BITS, L> {
1136        contexts: context_inputs,
1137        lane: lane_input,
1138        framer: LaneLocalResultFramerInput {
1139            contexts: framer_contexts,
1140            output_ready: input.output_ready,
1141            error_ready: input.error_ready,
1142        },
1143        control,
1144        generations,
1145        job_ids,
1146    };
1147    (output, d)
1148}
1149
1150/// Native-lane state transition retained for the original two-const API.
1151///
1152/// Generic parent lowering uses [`lane_local_cluster_with_lane_kernel`]. This
1153/// wrapper keeps callers of `lane_local_cluster_kernel::<ROUNDS,
1154/// ADDRESS_BITS>` source-compatible while selecting the checked inline lane.
1155#[kernel]
1156pub fn lane_local_cluster_kernel<const ROUNDS: usize, const ADDRESS_BITS: usize>(
1157    clock_reset: ClockReset,
1158    input: LaneLocalClusterInput,
1159    q: LaneLocalClusterQ<ROUNDS, ADDRESS_BITS>,
1160) -> (
1161    LaneLocalClusterOutput,
1162    LaneLocalClusterD<ROUNDS, ADDRESS_BITS>,
1163)
1164where
1165    rhdl::bits::W<ROUNDS>: BitWidth,
1166    rhdl::bits::W<ADDRESS_BITS>: BitWidth,
1167{
1168    lane_local_cluster_with_lane_kernel::<
1169        ROUNDS,
1170        ADDRESS_BITS,
1171        InlineCompressionLane<ROUNDS, ADDRESS_BITS>,
1172    >(clock_reset, input, q)
1173}
1174
1175/// Production 64-round, four-context lane-local SHA-256 cluster.
1176pub type Sha256LaneLocalCluster = LaneLocalCluster<64, 6>;
1177
1178/// Production cluster whose complete lane is supplied as a separate RHDL artifact.
1179pub type ModularSha256LaneLocalCluster = LaneLocalCluster<64, 6, ExternalFullCompressionLane>;