Skip to main content

vyre_primitives/graph/
csr_frontier_queue.rs

1//! Device-side active-frontier queues for sparse CSR expansion.
2//!
3//! Low-density dataflow frontiers should not launch one useful lane and
4//! thousands of empty source-node lanes. This module splits sparse expansion
5//! into two GPU-resident primitives:
6//!
7//! 1. `frontier_to_queue` compacts active source-node ids from a packed bitset
8//!    into an active queue with an atomic device-side length. The legacy variant
9//!    uses one cooperative workgroup and a strided scan so the queue length can
10//!    be initialized inside the same dispatch without an unsupported grid barrier.
11//!    The packed-word variants let resident traversal pipelines initialize
12//!    `queue_len` once, then reserve one queue slice per nonzero frontier word.
13//! 2. `csr_queue_forward_traverse` consumes only queued sources and expands
14//!    their CSR rows into `frontier_out`.
15//!
16//! The queue length can exceed queue capacity to expose overflow pressure; the
17//! traversal consumes only the first `queue_capacity` entries.
18
19use std::fmt;
20use std::sync::Arc;
21
22use vyre_foundation::ir::model::expr::Ident;
23use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
24use vyre_foundation::MemoryOrdering;
25
26use crate::bitset::bitset_words;
27use crate::graph::csr_frontier_step::{
28    csr_queue_step_program, CsrQueueEmit, CsrQueueInputs, CsrQueueLanes, CsrQueueRowPlan,
29    CsrQueueStepSpec,
30};
31
32/// Canonical op id for bitset-to-queue compaction.
33pub const FRONTIER_TO_QUEUE_OP_ID: &str = "vyre-primitives::graph::frontier_to_queue";
34/// Canonical op id for multi-workgroup bitset-to-queue compaction.
35pub const FRONTIER_TO_QUEUE_PARALLEL_OP_ID: &str =
36    "vyre-primitives::graph::frontier_to_queue_parallel";
37/// Canonical op id for word-level multi-workgroup bitset-to-queue compaction.
38pub const FRONTIER_WORDS_TO_QUEUE_PARALLEL_OP_ID: &str =
39    "vyre-primitives::graph::frontier_words_to_queue_parallel";
40/// Canonical op id for word-level compaction that also clears an output bitset.
41pub const FRONTIER_WORDS_TO_QUEUE_CLEAR_OUT_PARALLEL_OP_ID: &str =
42    "vyre-primitives::graph::frontier_words_to_queue_clear_out_parallel";
43/// Canonical op id for packed-frontier word popcount prefix-scan pass A.
44pub const FRONTIER_WORD_COUNTS_SCAN_PASS_A_OP_ID: &str =
45    "vyre-primitives::graph::frontier_word_counts_scan_pass_a";
46/// Canonical op id for deterministic packed-frontier block-prefix scatter.
47pub const FRONTIER_WORD_BLOCK_PREFIX_TO_QUEUE_PARALLEL_OP_ID: &str =
48    "vyre-primitives::graph::frontier_word_block_prefix_to_queue_parallel";
49/// Canonical op id for in-place packed-frontier block-offset scan.
50pub const FRONTIER_WORD_BLOCK_OFFSETS_IN_PLACE_OP_ID: &str =
51    "vyre-primitives::graph::frontier_word_block_offsets_in_place";
52/// Canonical op id for packed-frontier scatter with precomputed block offsets.
53pub const FRONTIER_WORD_BLOCK_OFFSETS_TO_QUEUE_PARALLEL_OP_ID: &str =
54    "vyre-primitives::graph::frontier_word_block_offsets_to_queue_parallel";
55/// Workgroup lanes used by the single-workgroup [`frontier_to_queue`] scan.
56///
57/// This is ONE constant on purpose. It drives the declared workgroup size, the
58/// stride of the cooperative scan, and the lane gate that confines the scan to
59/// the first workgroup. Splitting it into three literals is what lets a fixed
60/// workgroup declaration drift away from a lane gate, which is the shape that
61/// produces silent duplicate coverage.
62pub const FRONTIER_TO_QUEUE_WORKGROUP_LANES: u32 = 256;
63/// Workgroup lanes used by the deterministic packed-frontier scan path.
64pub const FRONTIER_WORD_SCAN_BLOCK_LANES: u32 = 1024;
65/// Canonical op id for device-side queue length initialization.
66pub const FRONTIER_QUEUE_LEN_INIT_OP_ID: &str = "vyre-primitives::graph::frontier_queue_len_init";
67/// Canonical op id for queue-driven CSR expansion.
68pub const CSR_QUEUE_FORWARD_OP_ID: &str = "vyre-primitives::graph::csr_queue_forward_traverse";
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71struct FrontierQueueSizingError {
72    message: String,
73}
74
75impl FrontierQueueSizingError {
76    fn new(message: String) -> Self {
77        Self { message }
78    }
79}
80
81impl fmt::Display for FrontierQueueSizingError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(&self.message)
84    }
85}
86
87fn checked_frontier_u32_product(
88    lhs: u32,
89    rhs: u32,
90    context: &str,
91) -> Result<u32, FrontierQueueSizingError> {
92    lhs.checked_mul(rhs).ok_or_else(|| {
93        FrontierQueueSizingError::new(format!(
94            "Fix: {context} overflows u32 word count for lhs={lhs} rhs={rhs}. Shard the frontier queue before GPU dispatch."
95        ))
96    })
97}
98
99fn try_u32_byte_range(words: u32, context: &str) -> Result<usize, FrontierQueueSizingError> {
100    try_u32_byte_range_with_word_size(words, std::mem::size_of::<u32>(), context)
101}
102
103fn try_u32_byte_range_with_word_size(
104    words: u32,
105    word_size: usize,
106    context: &str,
107) -> Result<usize, FrontierQueueSizingError> {
108    let count = usize::try_from(words).map_err(|_| {
109        FrontierQueueSizingError::new(format!(
110            "Fix: {context} words={words} cannot fit usize on this target. Shard the frontier queue before GPU dispatch."
111        ))
112    })?;
113    count.checked_mul(word_size).ok_or_else(|| {
114        FrontierQueueSizingError::new(format!(
115            "Fix: {context} words={words} word_size={word_size} overflows output byte range. Shard the frontier queue before GPU dispatch."
116        ))
117    })
118}
119
120fn invalid_frontier_queue_sizing_program(
121    op_id: &'static str,
122    output: &str,
123    error: FrontierQueueSizingError,
124) -> Program {
125    crate::invalid_output_program(op_id, output, DataType::U32, error.to_string())
126}
127
128/// Build a GPU program that initializes the active queue length scalar.
129///
130/// This replaces a per-wave host-to-device zero upload in resident sparse
131/// traversal pipelines. Keeping initialization as a separate single-lane
132/// device step avoids the global-synchronization race that would occur if the
133/// multi-workgroup compaction kernel tried to clear and atomically increment
134/// the same scalar.
135#[must_use]
136pub fn frontier_queue_len_init(queue_len: &str) -> Program {
137    Program::wrapped(
138        vec![
139            BufferDecl::storage(queue_len, 0, BufferAccess::ReadWrite, DataType::U32).with_count(1),
140        ],
141        [1, 1, 1],
142        vec![Node::Region {
143            generator: Ident::from(FRONTIER_QUEUE_LEN_INIT_OP_ID),
144            source_region: None,
145            body: Arc::new(vec![Node::store(queue_len, Expr::u32(0), Expr::u32(0))]),
146        }],
147    )
148}
149
150/// Build a GPU program that appends every active frontier node to a queue.
151///
152/// This is a single-workgroup cooperative scan: lane 0 clears `queue_len`, a
153/// workgroup barrier orders that clear, then the lanes of that one workgroup
154/// walk `node_count` in [`FRONTIER_TO_QUEUE_WORKGROUP_LANES`]-wide strides.
155/// Sparse queue traversal is selected only for low-density frontiers, so
156/// avoiding a separate queue-length init launch is more valuable than spreading
157/// this scan across every SM. Use [`frontier_to_queue_parallel`] when the
158/// frontier is large enough to want every SM.
159///
160/// Single-workgroup is enforced STRUCTURALLY, not assumed. Every lane whose
161/// global id is at or above the workgroup width retires without touching
162/// memory, so the program computes the same queue for any dispatch span the
163/// driver picks. That gate is load-bearing twice over, and both failures are
164/// silent wrong answers rather than crashes:
165///
166/// 1. Duplicate coverage. `q_src` is `q_iter * WIDTH + q_lane` over a GLOBAL
167///    `q_lane`, so at `G` workgroups the lanes of group `g` re-derive `q_src`
168///    values group 0 already covered at a higher `q_iter`. Every active node at
169///    or above the workgroup width would be appended once per covering group and
170///    `queue_len` inflated by the same factor.
171/// 2. Lost clear. Lane 0's clear of `queue_len` is a PLAIN store ordered only by
172///    a WORKGROUP-scope barrier. Nothing orders it against another group's
173///    `atomic_add`, so a second group's increment could land before the clear
174///    and be erased.
175///
176/// The span is not the caller's to choose: this program contains an atomic, and
177/// once a program contains any atomic the driver widens the dispatch to the
178/// largest non-shared binding, which here is `active_queue` at `queue_capacity`.
179/// A capacity that merely matches the node count therefore already produces a
180/// multi-workgroup grid.
181#[must_use]
182pub fn frontier_to_queue(
183    frontier_in: &str,
184    active_queue: &str,
185    queue_len: &str,
186    node_count: u32,
187    queue_capacity: u32,
188) -> Program {
189    if node_count == 0 || queue_capacity == 0 {
190        return crate::invalid_output_program(FRONTIER_TO_QUEUE_OP_ID,
191        queue_len,
192        DataType::U32,
193        format!(
194            "Fix: frontier_to_queue requires node_count > 0 and queue_capacity > 0, got node_count={node_count} queue_capacity={queue_capacity}."
195        ),);
196    }
197    let lane = Expr::InvocationId { axis: 0 };
198    let words = bitset_words(node_count);
199    let lanes = FRONTIER_TO_QUEUE_WORKGROUP_LANES;
200    let scan_iters = node_count.div_ceil(lanes).max(1);
201    let body = vec![
202        Node::let_bind("q_lane", lane.clone()),
203        Node::if_then(
204            Expr::eq(Expr::var("q_lane"), Expr::u32(0)),
205            vec![Node::store(queue_len, Expr::u32(0), Expr::u32(0))],
206        ),
207        Node::barrier_with_ordering(MemoryOrdering::SeqCst),
208        // Only the lanes of the FIRST workgroup scan. Beyond that width the
209        // strided walk below would re-cover source nodes group 0 already
210        // covered, double-appending them and inflating `queue_len`.
211        Node::if_then(
212            Expr::lt(Expr::var("q_lane"), Expr::u32(lanes)),
213            vec![Node::loop_for(
214                "q_iter",
215                Expr::u32(0),
216                Expr::u32(scan_iters),
217                vec![
218                    Node::let_bind(
219                        "q_src",
220                        Expr::add(
221                            Expr::mul(Expr::var("q_iter"), Expr::u32(lanes)),
222                            Expr::var("q_lane"),
223                        ),
224                    ),
225                    Node::if_then(
226                        Expr::lt(Expr::var("q_src"), Expr::u32(node_count)),
227                        vec![
228                            Node::let_bind(
229                                "q_word_idx",
230                                Expr::shr(Expr::var("q_src"), Expr::u32(5)),
231                            ),
232                            Node::let_bind(
233                                "q_bit_mask",
234                                Expr::shl(
235                                    Expr::u32(1),
236                                    Expr::bitand(Expr::var("q_src"), Expr::u32(31)),
237                                ),
238                            ),
239                            Node::let_bind(
240                                "q_src_word",
241                                Expr::load(frontier_in, Expr::var("q_word_idx")),
242                            ),
243                            Node::if_then(
244                                Expr::ne(
245                                    Expr::bitand(Expr::var("q_src_word"), Expr::var("q_bit_mask")),
246                                    Expr::u32(0),
247                                ),
248                                vec![
249                                    Node::let_bind(
250                                        "q_slot",
251                                        Expr::atomic_add(queue_len, Expr::u32(0), Expr::u32(1)),
252                                    ),
253                                    Node::if_then(
254                                        Expr::lt(Expr::var("q_slot"), Expr::u32(queue_capacity)),
255                                        vec![Node::store(
256                                            active_queue,
257                                            Expr::var("q_slot"),
258                                            Expr::var("q_src"),
259                                        )],
260                                    ),
261                                ],
262                            ),
263                        ],
264                    ),
265                ],
266            )],
267        ),
268    ];
269    Program::wrapped(
270        vec![
271            BufferDecl::storage(frontier_in, 0, BufferAccess::ReadOnly, DataType::U32)
272                .with_count(words),
273            BufferDecl::storage(active_queue, 1, BufferAccess::ReadWrite, DataType::U32)
274                .with_count(queue_capacity),
275            BufferDecl::storage(queue_len, 2, BufferAccess::ReadWrite, DataType::U32).with_count(1),
276        ],
277        [lanes, 1, 1],
278        vec![Node::Region {
279            generator: Ident::from(FRONTIER_TO_QUEUE_OP_ID),
280            source_region: None,
281            body: Arc::new(body),
282        }],
283    )
284}
285
286/// Build a multi-workgroup GPU program that appends active frontier nodes to a queue.
287///
288/// The caller must clear `queue_len` before dispatch, for example with
289/// `frontier_queue_len_init` or a fused resident reset step. Unlike
290/// `frontier_to_queue`, this variant maps one lane to one source node and is
291/// the right materializer for large packed frontiers.
292#[must_use]
293pub fn frontier_to_queue_parallel(
294    frontier_in: &str,
295    active_queue: &str,
296    queue_len: &str,
297    node_count: u32,
298    queue_capacity: u32,
299) -> Program {
300    if node_count == 0 || queue_capacity == 0 {
301        return crate::invalid_output_program(FRONTIER_TO_QUEUE_PARALLEL_OP_ID,
302        queue_len,
303        DataType::U32,
304        format!(
305            "Fix: frontier_to_queue_parallel requires node_count > 0 and queue_capacity > 0, got node_count={node_count} queue_capacity={queue_capacity}."
306        ),);
307    }
308    let lane = Expr::InvocationId { axis: 0 };
309    let words = bitset_words(node_count);
310    let body = vec![
311        Node::let_bind("qp_src", lane),
312        Node::if_then(
313            Expr::lt(Expr::var("qp_src"), Expr::u32(node_count)),
314            vec![
315                Node::let_bind("qp_word_idx", Expr::shr(Expr::var("qp_src"), Expr::u32(5))),
316                Node::let_bind(
317                    "qp_bit_mask",
318                    Expr::shl(
319                        Expr::u32(1),
320                        Expr::bitand(Expr::var("qp_src"), Expr::u32(31)),
321                    ),
322                ),
323                Node::let_bind(
324                    "qp_src_word",
325                    Expr::load(frontier_in, Expr::var("qp_word_idx")),
326                ),
327                Node::if_then(
328                    Expr::ne(
329                        Expr::bitand(Expr::var("qp_src_word"), Expr::var("qp_bit_mask")),
330                        Expr::u32(0),
331                    ),
332                    vec![
333                        Node::let_bind(
334                            "qp_slot",
335                            Expr::atomic_add(queue_len, Expr::u32(0), Expr::u32(1)),
336                        ),
337                        Node::if_then(
338                            Expr::lt(Expr::var("qp_slot"), Expr::u32(queue_capacity)),
339                            vec![Node::store(
340                                active_queue,
341                                Expr::var("qp_slot"),
342                                Expr::var("qp_src"),
343                            )],
344                        ),
345                    ],
346                ),
347            ],
348        ),
349    ];
350    Program::wrapped(
351        vec![
352            BufferDecl::storage(frontier_in, 0, BufferAccess::ReadOnly, DataType::U32)
353                .with_count(words),
354            BufferDecl::storage(active_queue, 1, BufferAccess::ReadWrite, DataType::U32)
355                .with_count(queue_capacity),
356            BufferDecl::storage(queue_len, 2, BufferAccess::ReadWrite, DataType::U32).with_count(1),
357        ],
358        [256, 1, 1],
359        vec![Node::Region {
360            generator: Ident::from(FRONTIER_TO_QUEUE_PARALLEL_OP_ID),
361            source_region: None,
362            body: Arc::new(body),
363        }],
364    )
365}
366
367/// Build a multi-workgroup GPU program that appends active frontier nodes to a
368/// queue by scanning packed frontier words.
369///
370/// The caller must clear `queue_len` before dispatch. This variant maps one
371/// lane to one packed u32 frontier word and performs one atomic queue
372/// reservation per nonzero word, so sparse packed frontiers launch 32x fewer
373/// lanes than `frontier_to_queue_parallel` and avoid per-active-bit atomics.
374#[must_use]
375pub fn frontier_words_to_queue_parallel(
376    frontier_in: &str,
377    active_queue: &str,
378    queue_len: &str,
379    node_count: u32,
380    queue_capacity: u32,
381) -> Program {
382    frontier_words_to_queue_parallel_program(
383        FRONTIER_WORDS_TO_QUEUE_PARALLEL_OP_ID,
384        frontier_in,
385        active_queue,
386        queue_len,
387        None,
388        node_count,
389        queue_capacity,
390    )
391}
392
393/// Build a packed-frontier queue materializer that also clears `frontier_out`.
394///
395/// The caller must still clear `queue_len` before dispatch. Folding the output
396/// clear into this packed-word scan removes a separate full-frontier reset pass
397/// from resident sparse traversal sequences without changing the queue ABI.
398#[must_use]
399pub fn frontier_words_to_queue_clear_out_parallel(
400    frontier_in: &str,
401    active_queue: &str,
402    queue_len: &str,
403    frontier_out: &str,
404    node_count: u32,
405    queue_capacity: u32,
406) -> Program {
407    frontier_words_to_queue_parallel_program(
408        FRONTIER_WORDS_TO_QUEUE_CLEAR_OUT_PARALLEL_OP_ID,
409        frontier_in,
410        active_queue,
411        queue_len,
412        Some(frontier_out),
413        node_count,
414        queue_capacity,
415    )
416}
417
418fn frontier_words_to_queue_parallel_program(
419    op_id: &'static str,
420    frontier_in: &str,
421    active_queue: &str,
422    queue_len: &str,
423    frontier_out_to_clear: Option<&str>,
424    node_count: u32,
425    queue_capacity: u32,
426) -> Program {
427    if node_count == 0 || queue_capacity == 0 {
428        return crate::invalid_output_program(op_id,
429        queue_len,
430        DataType::U32,
431        format!(
432            "Fix: {op_id} requires node_count > 0 and queue_capacity > 0, got node_count={node_count} queue_capacity={queue_capacity}."
433        ),);
434    }
435    let lane = Expr::InvocationId { axis: 0 };
436    let words = bitset_words(node_count);
437    let tail_bits = node_count & 31;
438    let tail_mask = if tail_bits == 0 {
439        u32::MAX
440    } else {
441        (1_u32 << tail_bits) - 1
442    };
443    let mut word_body = vec![
444        Node::let_bind(
445            "qw_src_base",
446            Expr::mul(Expr::var("qw_word_idx"), Expr::u32(32)),
447        ),
448        Node::let_bind(
449            "qw_remaining",
450            Expr::load(frontier_in, Expr::var("qw_word_idx")),
451        ),
452    ];
453    if tail_bits != 0 {
454        word_body.push(Node::if_then(
455            Expr::eq(Expr::var("qw_word_idx"), Expr::u32(words - 1)),
456            vec![Node::assign(
457                "qw_remaining",
458                Expr::bitand(Expr::var("qw_remaining"), Expr::u32(tail_mask)),
459            )],
460        ));
461    }
462    word_body.push(Node::if_then(
463        Expr::ne(Expr::var("qw_remaining"), Expr::u32(0)),
464        vec![
465            Node::let_bind("qw_active_bits", Expr::popcount(Expr::var("qw_remaining"))),
466            Node::let_bind(
467                "qw_base_slot",
468                Expr::atomic_add(queue_len, Expr::u32(0), Expr::var("qw_active_bits")),
469            ),
470            Node::loop_for(
471                "qw_rank",
472                Expr::u32(0),
473                Expr::var("qw_active_bits"),
474                vec![
475                    Node::let_bind("qw_bit", Expr::ctz(Expr::var("qw_remaining"))),
476                    Node::let_bind(
477                        "qw_src",
478                        Expr::add(Expr::var("qw_src_base"), Expr::var("qw_bit")),
479                    ),
480                    Node::let_bind(
481                        "qw_slot",
482                        Expr::add(Expr::var("qw_base_slot"), Expr::var("qw_rank")),
483                    ),
484                    Node::if_then(
485                        Expr::lt(Expr::var("qw_slot"), Expr::u32(queue_capacity)),
486                        vec![Node::store(
487                            active_queue,
488                            Expr::var("qw_slot"),
489                            Expr::var("qw_src"),
490                        )],
491                    ),
492                    Node::assign(
493                        "qw_remaining",
494                        Expr::bitand(
495                            Expr::var("qw_remaining"),
496                            Expr::sub(Expr::var("qw_remaining"), Expr::u32(1)),
497                        ),
498                    ),
499                ],
500            ),
501        ],
502    ));
503    if let Some(frontier_out) = frontier_out_to_clear {
504        word_body.insert(
505            0,
506            Node::store(frontier_out, Expr::var("qw_word_idx"), Expr::u32(0)),
507        );
508    }
509
510    let body = vec![
511        Node::let_bind("qw_word_idx", lane),
512        Node::if_then(
513            Expr::lt(Expr::var("qw_word_idx"), Expr::u32(words)),
514            word_body,
515        ),
516    ];
517    let mut buffers = vec![
518        BufferDecl::storage(frontier_in, 0, BufferAccess::ReadOnly, DataType::U32)
519            .with_count(words),
520        BufferDecl::storage(active_queue, 1, BufferAccess::ReadWrite, DataType::U32)
521            .with_count(queue_capacity),
522        BufferDecl::storage(queue_len, 2, BufferAccess::ReadWrite, DataType::U32).with_count(1),
523    ];
524    if let Some(frontier_out) = frontier_out_to_clear {
525        buffers.push(
526            BufferDecl::storage(frontier_out, 3, BufferAccess::ReadWrite, DataType::U32)
527                .with_count(words),
528        );
529    }
530    Program::wrapped(
531        buffers,
532        [256, 1, 1],
533        vec![Node::Region {
534            generator: Ident::from(op_id),
535            source_region: None,
536            body: Arc::new(body),
537        }],
538    )
539}
540
541/// Build Pass A for deterministic packed-frontier queue materialization.
542///
543/// Each workgroup scans one block of packed frontier words. Lane `L` in block
544/// `B` computes the in-range popcount for word `B*1024 + L`, then participates
545/// in a local inclusive Hillis-Steele scan. The program writes one per-word
546/// inclusive count into `word_partials` and one per-block total into
547/// `block_totals`.
548#[must_use]
549pub fn frontier_word_counts_scan_pass_a(
550    frontier_in: &str,
551    word_partials: &str,
552    block_totals: &str,
553    node_count: u32,
554) -> Program {
555    if node_count == 0 {
556        return crate::invalid_output_program(
557            FRONTIER_WORD_COUNTS_SCAN_PASS_A_OP_ID,
558            word_partials,
559            DataType::U32,
560            "Fix: frontier_word_counts_scan_pass_a requires node_count > 0.".to_string(),
561        );
562    }
563    let words = bitset_words(node_count);
564    let num_blocks = words.div_ceil(FRONTIER_WORD_SCAN_BLOCK_LANES).max(1);
565    let total_partials = match checked_frontier_u32_product(
566        num_blocks,
567        FRONTIER_WORD_SCAN_BLOCK_LANES,
568        "frontier_word_counts_scan_pass_a partial word count",
569    ) {
570        Ok(total_partials) => total_partials,
571        Err(error) => {
572            return invalid_frontier_queue_sizing_program(
573                FRONTIER_WORD_COUNTS_SCAN_PASS_A_OP_ID,
574                word_partials,
575                error,
576            );
577        }
578    };
579    let partial_bytes =
580        match try_u32_byte_range(total_partials, "frontier_word_counts_scan_pass_a partials") {
581            Ok(partial_bytes) => partial_bytes,
582            Err(error) => {
583                return invalid_frontier_queue_sizing_program(
584                    FRONTIER_WORD_COUNTS_SCAN_PASS_A_OP_ID,
585                    word_partials,
586                    error,
587                );
588            }
589        };
590    let block_total_bytes =
591        match try_u32_byte_range(num_blocks, "frontier_word_counts_scan_pass_a block totals") {
592            Ok(block_total_bytes) => block_total_bytes,
593            Err(error) => {
594                return invalid_frontier_queue_sizing_program(
595                    FRONTIER_WORD_COUNTS_SCAN_PASS_A_OP_ID,
596                    block_totals,
597                    error,
598                );
599            }
600        };
601    let tail_bits = node_count & 31;
602    let tail_mask = if tail_bits == 0 {
603        u32::MAX
604    } else {
605        (1_u32 << tail_bits) - 1
606    };
607
608    let lane = Expr::var("fwcs_lane");
609    let block = Expr::var("fwcs_block");
610    let global = Expr::var("fwcs_global");
611    let scratch_a = format!("__{word_partials}_fwcs_scratch_a");
612    let scratch_b = format!("__{word_partials}_fwcs_scratch_b");
613
614    let mut body = Vec::new();
615    body.push(Node::let_bind("fwcs_lane", Expr::LocalId { axis: 0 }));
616    body.push(Node::let_bind("fwcs_block", Expr::WorkgroupId { axis: 0 }));
617    body.push(Node::let_bind(
618        "fwcs_global",
619        Expr::add(
620            Expr::mul(block.clone(), Expr::u32(FRONTIER_WORD_SCAN_BLOCK_LANES)),
621            lane.clone(),
622        ),
623    ));
624    body.push(Node::store(&scratch_a, lane.clone(), Expr::u32(0)));
625    let mut load_word = vec![Node::let_bind(
626        "fwcs_word",
627        Expr::load(frontier_in, global.clone()),
628    )];
629    if tail_bits != 0 {
630        load_word.push(Node::if_then(
631            Expr::eq(global.clone(), Expr::u32(words - 1)),
632            vec![Node::assign(
633                "fwcs_word",
634                Expr::bitand(Expr::var("fwcs_word"), Expr::u32(tail_mask)),
635            )],
636        ));
637    }
638    load_word.push(Node::store(
639        &scratch_a,
640        lane.clone(),
641        Expr::popcount(Expr::var("fwcs_word")),
642    ));
643    body.push(Node::if_then(
644        Expr::lt(global.clone(), Expr::u32(words)),
645        load_word,
646    ));
647    body.push(Node::Barrier {
648        ordering: MemoryOrdering::SeqCst,
649    });
650
651    let mut stride = 1_u32;
652    while stride < FRONTIER_WORD_SCAN_BLOCK_LANES {
653        body.push(Node::store(
654            &scratch_b,
655            lane.clone(),
656            Expr::load(&scratch_a, lane.clone()),
657        ));
658        let previous_lane = Expr::add(lane.clone(), Expr::u32(0u32.wrapping_sub(stride)));
659        body.push(Node::if_then(
660            Expr::lt(Expr::u32(stride - 1), lane.clone()),
661            vec![Node::store(
662                &scratch_b,
663                lane.clone(),
664                Expr::add(
665                    Expr::load(&scratch_a, lane.clone()),
666                    Expr::load(&scratch_a, previous_lane),
667                ),
668            )],
669        ));
670        body.push(Node::Barrier {
671            ordering: MemoryOrdering::SeqCst,
672        });
673        body.push(Node::store(
674            &scratch_a,
675            lane.clone(),
676            Expr::load(&scratch_b, lane.clone()),
677        ));
678        body.push(Node::Barrier {
679            ordering: MemoryOrdering::SeqCst,
680        });
681        stride *= 2;
682    }
683
684    body.push(Node::if_then(
685        Expr::lt(global.clone(), Expr::u32(words)),
686        vec![Node::store(
687            word_partials,
688            global.clone(),
689            Expr::load(&scratch_a, lane.clone()),
690        )],
691    ));
692    body.push(Node::if_then(
693        Expr::eq(lane.clone(), Expr::u32(FRONTIER_WORD_SCAN_BLOCK_LANES - 1)),
694        vec![Node::store(
695            block_totals,
696            block.clone(),
697            Expr::load(&scratch_a, lane.clone()),
698        )],
699    ));
700
701    Program::wrapped(
702        vec![
703            BufferDecl::storage(frontier_in, 0, BufferAccess::ReadOnly, DataType::U32)
704                .with_count(words),
705            BufferDecl::output(word_partials, 1, DataType::U32)
706                .with_count(total_partials)
707                .with_output_byte_range(0..partial_bytes),
708            BufferDecl::storage(block_totals, 2, BufferAccess::ReadWrite, DataType::U32)
709                .with_count(num_blocks)
710                .with_pipeline_live_out(true)
711                .with_output_byte_range(0..block_total_bytes),
712            BufferDecl::workgroup(&scratch_a, FRONTIER_WORD_SCAN_BLOCK_LANES, DataType::U32),
713            BufferDecl::workgroup(&scratch_b, FRONTIER_WORD_SCAN_BLOCK_LANES, DataType::U32),
714        ],
715        [FRONTIER_WORD_SCAN_BLOCK_LANES, 1, 1],
716        vec![Node::Region {
717            generator: Ident::from(FRONTIER_WORD_COUNTS_SCAN_PASS_A_OP_ID),
718            source_region: None,
719            body: Arc::new(body),
720        }],
721    )
722}
723
724/// Convert per-block active counts into exclusive per-block queue offsets.
725///
726/// The conversion is in-place: after this program runs, `block_totals[B]`
727/// contains the number of active nodes in all prior blocks. For up to 1024
728/// blocks this uses one guarded workgroup scan; beyond that it falls back to a
729/// single-lane linear scan over block metadata, which is still O(blocks)
730/// instead of the old O(words * blocks) scatter-side prefix work.
731#[must_use]
732pub fn frontier_word_block_offsets_in_place(block_totals: &str, node_count: u32) -> Program {
733    if node_count == 0 {
734        return crate::invalid_output_program(
735            FRONTIER_WORD_BLOCK_OFFSETS_IN_PLACE_OP_ID,
736            block_totals,
737            DataType::U32,
738            "Fix: frontier_word_block_offsets_in_place requires node_count > 0.".to_string(),
739        );
740    }
741    let words = bitset_words(node_count);
742    let num_blocks = words.div_ceil(FRONTIER_WORD_SCAN_BLOCK_LANES).max(1);
743    let block_total_bytes = match try_u32_byte_range(
744        num_blocks,
745        "frontier_word_block_offsets_in_place block totals",
746    ) {
747        Ok(block_total_bytes) => block_total_bytes,
748        Err(error) => {
749            return invalid_frontier_queue_sizing_program(
750                FRONTIER_WORD_BLOCK_OFFSETS_IN_PLACE_OP_ID,
751                block_totals,
752                error,
753            );
754        }
755    };
756    if num_blocks <= FRONTIER_WORD_SCAN_BLOCK_LANES {
757        return frontier_word_block_offsets_single_workgroup(
758            block_totals,
759            num_blocks,
760            block_total_bytes,
761        );
762    }
763    frontier_word_block_offsets_single_lane(block_totals, num_blocks, block_total_bytes)
764}
765
766fn frontier_word_block_offsets_single_workgroup(
767    block_totals: &str,
768    num_blocks: u32,
769    block_total_bytes: usize,
770) -> Program {
771    let lane = Expr::var("fwbo_lane");
772    let scratch_a = format!("__{block_totals}_fwbo_scratch_a");
773    let scratch_b = format!("__{block_totals}_fwbo_scratch_b");
774    let mut body = Vec::new();
775    body.push(Node::let_bind("fwbo_lane", Expr::LocalId { axis: 0 }));
776    body.push(Node::store(&scratch_a, lane.clone(), Expr::u32(0)));
777    body.push(Node::if_then(
778        Expr::lt(lane.clone(), Expr::u32(num_blocks)),
779        vec![Node::store(
780            &scratch_a,
781            lane.clone(),
782            Expr::load(block_totals, lane.clone()),
783        )],
784    ));
785    body.push(Node::Barrier {
786        ordering: MemoryOrdering::SeqCst,
787    });
788
789    let mut stride = 1_u32;
790    while stride < FRONTIER_WORD_SCAN_BLOCK_LANES {
791        body.push(Node::store(
792            &scratch_b,
793            lane.clone(),
794            Expr::load(&scratch_a, lane.clone()),
795        ));
796        let previous_lane = Expr::add(lane.clone(), Expr::u32(0u32.wrapping_sub(stride)));
797        body.push(Node::if_then(
798            Expr::lt(Expr::u32(stride - 1), lane.clone()),
799            vec![Node::store(
800                &scratch_b,
801                lane.clone(),
802                Expr::add(
803                    Expr::load(&scratch_a, lane.clone()),
804                    Expr::load(&scratch_a, previous_lane),
805                ),
806            )],
807        ));
808        body.push(Node::Barrier {
809            ordering: MemoryOrdering::SeqCst,
810        });
811        body.push(Node::store(
812            &scratch_a,
813            lane.clone(),
814            Expr::load(&scratch_b, lane.clone()),
815        ));
816        body.push(Node::Barrier {
817            ordering: MemoryOrdering::SeqCst,
818        });
819        stride *= 2;
820    }
821
822    body.push(Node::if_then(
823        Expr::lt(lane.clone(), Expr::u32(num_blocks)),
824        vec![
825            Node::if_then(
826                Expr::eq(lane.clone(), Expr::u32(0)),
827                vec![Node::store(block_totals, lane.clone(), Expr::u32(0))],
828            ),
829            Node::if_then(
830                Expr::ne(lane.clone(), Expr::u32(0)),
831                vec![Node::store(
832                    block_totals,
833                    lane.clone(),
834                    Expr::load(&scratch_a, Expr::sub(lane.clone(), Expr::u32(1))),
835                )],
836            ),
837        ],
838    ));
839
840    Program::wrapped(
841        vec![
842            BufferDecl::storage(block_totals, 0, BufferAccess::ReadWrite, DataType::U32)
843                .with_count(num_blocks)
844                .with_pipeline_live_out(true)
845                .with_output_byte_range(0..block_total_bytes),
846            BufferDecl::workgroup(&scratch_a, FRONTIER_WORD_SCAN_BLOCK_LANES, DataType::U32),
847            BufferDecl::workgroup(&scratch_b, FRONTIER_WORD_SCAN_BLOCK_LANES, DataType::U32),
848        ],
849        [FRONTIER_WORD_SCAN_BLOCK_LANES, 1, 1],
850        vec![Node::Region {
851            generator: Ident::from(FRONTIER_WORD_BLOCK_OFFSETS_IN_PLACE_OP_ID),
852            source_region: None,
853            body: Arc::new(body),
854        }],
855    )
856}
857
858fn frontier_word_block_offsets_single_lane(
859    block_totals: &str,
860    num_blocks: u32,
861    block_total_bytes: usize,
862) -> Program {
863    let body = vec![
864        Node::let_bind("fwbo_running", Expr::u32(0)),
865        Node::loop_for(
866            "fwbo_block",
867            Expr::u32(0),
868            Expr::u32(num_blocks),
869            vec![
870                Node::let_bind(
871                    "fwbo_total",
872                    Expr::load(block_totals, Expr::var("fwbo_block")),
873                ),
874                Node::store(
875                    block_totals,
876                    Expr::var("fwbo_block"),
877                    Expr::var("fwbo_running"),
878                ),
879                Node::assign(
880                    "fwbo_running",
881                    Expr::add(Expr::var("fwbo_running"), Expr::var("fwbo_total")),
882                ),
883            ],
884        ),
885    ];
886    Program::wrapped(
887        vec![
888            BufferDecl::storage(block_totals, 0, BufferAccess::ReadWrite, DataType::U32)
889                .with_count(num_blocks)
890                .with_pipeline_live_out(true)
891                .with_output_byte_range(0..block_total_bytes),
892        ],
893        [1, 1, 1],
894        vec![Node::Region {
895            generator: Ident::from(FRONTIER_WORD_BLOCK_OFFSETS_IN_PLACE_OP_ID),
896            source_region: None,
897            body: Arc::new(body),
898        }],
899    )
900}
901
902/// Build the deterministic scatter pass for packed-frontier queue materialization.
903///
904/// `word_partials` must come from [`frontier_word_counts_scan_pass_a`], and
905/// `block_totals` must be the block-total output from that same pass. The
906/// scatter computes the tiny block prefix locally, preserving source-node order
907/// without an additional block-scan dispatch. It writes `queue_len` as the full
908/// in-range active-node count even when the bounded queue truncates the
909/// materialized entries.
910#[must_use]
911pub fn frontier_word_block_prefix_to_queue_parallel(
912    frontier_in: &str,
913    word_partials: &str,
914    block_totals: &str,
915    active_queue: &str,
916    queue_len: &str,
917    node_count: u32,
918    queue_capacity: u32,
919) -> Program {
920    frontier_word_queue_scatter_program(
921        FRONTIER_WORD_BLOCK_PREFIX_TO_QUEUE_PARALLEL_OP_ID,
922        FrontierWordBlockOffsetSource::SumPreviousTotals { block_totals },
923        frontier_in,
924        word_partials,
925        active_queue,
926        queue_len,
927        node_count,
928        queue_capacity,
929    )
930}
931
932/// Build the deterministic scatter pass using precomputed per-block offsets.
933///
934/// `block_offsets` must be the in-place output of
935/// [`frontier_word_block_offsets_in_place`]. This keeps scatter work O(words)
936/// for multi-block frontiers by replacing the per-word previous-block loop with
937/// one block-offset load.
938#[must_use]
939pub fn frontier_word_block_offsets_to_queue_parallel(
940    frontier_in: &str,
941    word_partials: &str,
942    block_offsets: &str,
943    active_queue: &str,
944    queue_len: &str,
945    node_count: u32,
946    queue_capacity: u32,
947) -> Program {
948    frontier_word_queue_scatter_program(
949        FRONTIER_WORD_BLOCK_OFFSETS_TO_QUEUE_PARALLEL_OP_ID,
950        FrontierWordBlockOffsetSource::PrecomputedOffsets { block_offsets },
951        frontier_in,
952        word_partials,
953        active_queue,
954        queue_len,
955        node_count,
956        queue_capacity,
957    )
958}
959
960#[derive(Clone, Copy)]
961enum FrontierWordBlockOffsetSource<'a> {
962    SumPreviousTotals { block_totals: &'a str },
963    PrecomputedOffsets { block_offsets: &'a str },
964}
965
966impl FrontierWordBlockOffsetSource<'_> {
967    fn buffer_name(&self) -> &str {
968        match self {
969            FrontierWordBlockOffsetSource::SumPreviousTotals { block_totals } => block_totals,
970            FrontierWordBlockOffsetSource::PrecomputedOffsets { block_offsets } => block_offsets,
971        }
972    }
973}
974
975#[allow(clippy::too_many_arguments)]
976fn frontier_word_queue_scatter_program(
977    op_id: &'static str,
978    block_offset_source: FrontierWordBlockOffsetSource<'_>,
979    frontier_in: &str,
980    word_partials: &str,
981    active_queue: &str,
982    queue_len: &str,
983    node_count: u32,
984    queue_capacity: u32,
985) -> Program {
986    if node_count == 0 || queue_capacity == 0 {
987        return crate::invalid_output_program(op_id,
988        queue_len,
989        DataType::U32,
990        format!(
991            "Fix: {op_id} requires node_count > 0 and queue_capacity > 0, got node_count={node_count} queue_capacity={queue_capacity}."
992        ),);
993    }
994    let words = bitset_words(node_count);
995    let num_blocks = words.div_ceil(FRONTIER_WORD_SCAN_BLOCK_LANES).max(1);
996    let total_partials =
997        match checked_frontier_u32_product(num_blocks, FRONTIER_WORD_SCAN_BLOCK_LANES, op_id) {
998            Ok(total_partials) => total_partials,
999            Err(error) => return invalid_frontier_queue_sizing_program(op_id, queue_len, error),
1000        };
1001    let tail_bits = node_count & 31;
1002    let tail_mask = if tail_bits == 0 {
1003        u32::MAX
1004    } else {
1005        (1_u32 << tail_bits) - 1
1006    };
1007    let lane = Expr::InvocationId { axis: 0 };
1008    let mut block_offset_body = Vec::new();
1009    match block_offset_source {
1010        FrontierWordBlockOffsetSource::SumPreviousTotals { block_totals } => {
1011            block_offset_body.push(Node::let_bind("fwq_block_offset", Expr::u32(0)));
1012            block_offset_body.push(Node::loop_for(
1013                "fwq_prev_block",
1014                Expr::u32(0),
1015                Expr::var("fwq_block"),
1016                vec![Node::assign(
1017                    "fwq_block_offset",
1018                    Expr::add(
1019                        Expr::var("fwq_block_offset"),
1020                        Expr::load(block_totals, Expr::var("fwq_prev_block")),
1021                    ),
1022                )],
1023            ));
1024        }
1025        FrontierWordBlockOffsetSource::PrecomputedOffsets { block_offsets } => {
1026            block_offset_body.push(Node::let_bind(
1027                "fwq_block_offset",
1028                Expr::load(block_offsets, Expr::var("fwq_block")),
1029            ));
1030        }
1031    }
1032    let mut word_body = vec![
1033        Node::let_bind(
1034            "fwq_src_base",
1035            Expr::mul(Expr::var("fwq_word_idx"), Expr::u32(32)),
1036        ),
1037        Node::let_bind(
1038            "fwq_block",
1039            Expr::div(
1040                Expr::var("fwq_word_idx"),
1041                Expr::u32(FRONTIER_WORD_SCAN_BLOCK_LANES),
1042            ),
1043        ),
1044        Node::let_bind(
1045            "fwq_word",
1046            Expr::load(frontier_in, Expr::var("fwq_word_idx")),
1047        ),
1048    ];
1049    if tail_bits != 0 {
1050        word_body.push(Node::if_then(
1051            Expr::eq(Expr::var("fwq_word_idx"), Expr::u32(words - 1)),
1052            vec![Node::assign(
1053                "fwq_word",
1054                Expr::bitand(Expr::var("fwq_word"), Expr::u32(tail_mask)),
1055            )],
1056        ));
1057    }
1058    word_body.extend(block_offset_body);
1059    word_body.extend([
1060        Node::let_bind("fwq_active_bits", Expr::popcount(Expr::var("fwq_word"))),
1061        Node::let_bind(
1062            "fwq_end",
1063            Expr::add(
1064                Expr::load(word_partials, Expr::var("fwq_word_idx")),
1065                Expr::var("fwq_block_offset"),
1066            ),
1067        ),
1068        Node::let_bind(
1069            "fwq_start",
1070            Expr::sub(Expr::var("fwq_end"), Expr::var("fwq_active_bits")),
1071        ),
1072        Node::let_bind("fwq_remaining", Expr::var("fwq_word")),
1073        Node::loop_for(
1074            "fwq_rank",
1075            Expr::u32(0),
1076            Expr::var("fwq_active_bits"),
1077            vec![
1078                Node::let_bind("fwq_bit", Expr::ctz(Expr::var("fwq_remaining"))),
1079                Node::let_bind(
1080                    "fwq_src",
1081                    Expr::add(Expr::var("fwq_src_base"), Expr::var("fwq_bit")),
1082                ),
1083                Node::let_bind(
1084                    "fwq_slot",
1085                    Expr::add(Expr::var("fwq_start"), Expr::var("fwq_rank")),
1086                ),
1087                Node::if_then(
1088                    Expr::and(
1089                        Expr::lt(Expr::var("fwq_slot"), Expr::u32(queue_capacity)),
1090                        Expr::lt(Expr::var("fwq_src"), Expr::u32(node_count)),
1091                    ),
1092                    vec![Node::store(
1093                        active_queue,
1094                        Expr::var("fwq_slot"),
1095                        Expr::var("fwq_src"),
1096                    )],
1097                ),
1098                Node::assign(
1099                    "fwq_remaining",
1100                    Expr::bitand(
1101                        Expr::var("fwq_remaining"),
1102                        Expr::sub(Expr::var("fwq_remaining"), Expr::u32(1)),
1103                    ),
1104                ),
1105            ],
1106        ),
1107        Node::if_then(
1108            Expr::eq(Expr::var("fwq_word_idx"), Expr::u32(words - 1)),
1109            vec![Node::store(queue_len, Expr::u32(0), Expr::var("fwq_end"))],
1110        ),
1111    ]);
1112
1113    let body = vec![
1114        Node::let_bind("fwq_word_idx", lane),
1115        Node::if_then(
1116            Expr::lt(Expr::var("fwq_word_idx"), Expr::u32(words)),
1117            word_body,
1118        ),
1119    ];
1120
1121    Program::wrapped(
1122        vec![
1123            BufferDecl::storage(frontier_in, 0, BufferAccess::ReadOnly, DataType::U32)
1124                .with_count(words),
1125            BufferDecl::storage(word_partials, 1, BufferAccess::ReadOnly, DataType::U32)
1126                .with_count(total_partials),
1127            BufferDecl::storage(
1128                block_offset_source.buffer_name(),
1129                2,
1130                BufferAccess::ReadOnly,
1131                DataType::U32,
1132            )
1133            .with_count(num_blocks),
1134            BufferDecl::storage(active_queue, 3, BufferAccess::ReadWrite, DataType::U32)
1135                .with_count(queue_capacity),
1136            BufferDecl::storage(queue_len, 4, BufferAccess::ReadWrite, DataType::U32).with_count(1),
1137        ],
1138        [256, 1, 1],
1139        vec![Node::Region {
1140            generator: Ident::from(op_id),
1141            source_region: None,
1142            body: Arc::new(body),
1143        }],
1144    )
1145}
1146
1147/// Positional inputs for [`csr_queue_forward_traverse`].
1148#[derive(Clone, Copy, Debug)]
1149pub struct CsrQueueForwardTraverseParams<'a> {
1150    /// Compacted queue of active source nodes.
1151    pub active_queue: &'a str,
1152    /// Single-element resident length of `active_queue`.
1153    pub queue_len: &'a str,
1154    /// CSR row pointers, `node_count + 1` entries.
1155    pub edge_offsets: &'a str,
1156    /// CSR edge destinations.
1157    pub edge_targets: &'a str,
1158    /// Per-edge kind bits tested against `allow_mask`.
1159    pub edge_kind_mask: &'a str,
1160    /// Packed bitset the reached destinations are ORed into.
1161    pub frontier_out: &'a str,
1162    /// Node count the CSR row pointers and destination bounds are sized by.
1163    pub node_count: u32,
1164    /// Logical edge count the edge-slot bound check uses.
1165    pub edge_count: u32,
1166    /// Static capacity of `active_queue`.
1167    pub queue_capacity: u32,
1168    /// Edge kinds this traversal is allowed to follow.
1169    pub allow_mask: u32,
1170}
1171
1172/// Build a GPU program that expands only queued CSR source rows.
1173#[must_use]
1174#[allow(clippy::too_many_arguments)]
1175pub fn csr_queue_forward_traverse(
1176    active_queue: &str,
1177    queue_len: &str,
1178    edge_offsets: &str,
1179    edge_targets: &str,
1180    edge_kind_mask: &str,
1181    frontier_out: &str,
1182    node_count: u32,
1183    edge_count: u32,
1184    queue_capacity: u32,
1185    allow_mask: u32,
1186) -> Program {
1187    csr_queue_forward_traverse_with(CsrQueueForwardTraverseParams {
1188        active_queue,
1189        queue_len,
1190        edge_offsets,
1191        edge_targets,
1192        edge_kind_mask,
1193        frontier_out,
1194        node_count,
1195        edge_count,
1196        queue_capacity,
1197        allow_mask,
1198    })
1199}
1200
1201/// Build a GPU program that expands only queued CSR source rows.
1202#[must_use]
1203pub fn csr_queue_forward_traverse_with(params: CsrQueueForwardTraverseParams<'_>) -> Program {
1204    let CsrQueueForwardTraverseParams {
1205        active_queue,
1206        queue_len,
1207        edge_offsets,
1208        edge_targets,
1209        edge_kind_mask,
1210        frontier_out,
1211        node_count,
1212        edge_count,
1213        queue_capacity,
1214        allow_mask,
1215    } = params;
1216    if node_count == 0 || queue_capacity == 0 {
1217        return crate::invalid_output_program(CSR_QUEUE_FORWARD_OP_ID,
1218        frontier_out,
1219        DataType::U32,
1220        format!(
1221            "Fix: csr_queue_forward_traverse requires node_count > 0 and queue_capacity > 0, got node_count={node_count} queue_capacity={queue_capacity}."
1222        ),);
1223    }
1224    csr_queue_step_program(&CsrQueueStepSpec {
1225        op_id: CSR_QUEUE_FORWARD_OP_ID,
1226        builder_name: "csr_queue_forward_traverse",
1227        prefix: "qt",
1228        workgroup_size: [256, 1, 1],
1229        inputs: CsrQueueInputs {
1230            active_queue,
1231            queue_len,
1232            edge_offsets,
1233            edge_targets,
1234            edge_kind_mask,
1235        },
1236        lanes: CsrQueueLanes::Scalar,
1237        row_plan: CsrQueueRowPlan::ExpandAll,
1238        emit: CsrQueueEmit::Frontier { frontier_out },
1239        node_count,
1240        edge_count,
1241        queue_capacity,
1242        allow_mask,
1243    })
1244}
1245
1246/// CPU reference for queue materialization.
1247#[must_use]
1248#[cfg(any(test, feature = "cpu-parity"))]
1249pub fn frontier_to_queue_cpu(
1250    frontier_in: &[u32],
1251    node_count: u32,
1252    queue_capacity: usize,
1253) -> (Vec<u32>, u32) {
1254    try_frontier_to_queue_cpu(frontier_in, node_count, queue_capacity).unwrap_or_else(|err| {
1255        panic!("frontier_to_queue CPU oracle received malformed input. {err}")
1256    })
1257}
1258
1259/// Fallible CPU reference for queue materialization.
1260#[cfg(any(test, feature = "cpu-parity"))]
1261pub fn try_frontier_to_queue_cpu(
1262    frontier_in: &[u32],
1263    node_count: u32,
1264    queue_capacity: usize,
1265) -> Result<(Vec<u32>, u32), String> {
1266    let mut queue: Vec<u32> = Vec::new();
1267    let seen = try_frontier_to_queue_cpu_into(frontier_in, node_count, queue_capacity, &mut queue)?;
1268    Ok((queue, seen))
1269}
1270
1271/// Fallible CPU reference for queue materialization into caller-owned storage.
1272///
1273/// On error, `queue` is left unchanged. This keeps parity harnesses and
1274/// resident dispatch diagnostics from losing the last queue snapshot when a
1275/// malformed frontier arrives.
1276#[cfg(any(test, feature = "cpu-parity"))]
1277pub fn try_frontier_to_queue_cpu_into(
1278    frontier_in: &[u32],
1279    node_count: u32,
1280    queue_capacity: usize,
1281    queue: &mut Vec<u32>,
1282) -> Result<u32, String> {
1283    crate::bitset::frontier::materialize_frontier_queue_prefix_into(
1284        node_count,
1285        frontier_in,
1286        queue_capacity,
1287        queue,
1288    )
1289    .map_err(|error| match error {
1290        crate::bitset::frontier::FrontierError::BadShape {
1291            expected_words,
1292            actual_words,
1293            ..
1294        } => format!(
1295            "Fix: frontier_to_queue requires frontier_in.len() == bitset_words(node_count), got len={actual_words} but expected {expected_words} for node_count={node_count}."
1296        ),
1297        other => format!(
1298            "Fix: frontier_to_queue CPU oracle could not materialize the active frontier queue: {other}"
1299        ),
1300    })
1301}
1302
1303/// CPU reference for queue-driven CSR expansion.
1304#[must_use]
1305#[cfg(any(test, feature = "cpu-parity"))]
1306pub fn csr_queue_forward_traverse_cpu(
1307    active_queue: &[u32],
1308    queue_len: u32,
1309    edge_offsets: &[u32],
1310    edge_targets: &[u32],
1311    edge_kind_mask: &[u32],
1312    node_count: u32,
1313    allow_mask: u32,
1314) -> Vec<u32> {
1315    try_csr_queue_forward_traverse_cpu(
1316        active_queue,
1317        queue_len,
1318        edge_offsets,
1319        edge_targets,
1320        edge_kind_mask,
1321        node_count,
1322        allow_mask,
1323    )
1324    .unwrap_or_else(|err| {
1325        panic!("csr_queue_forward_traverse CPU oracle received malformed input. {err}")
1326    })
1327}
1328
1329/// Fallible CPU reference for queue-driven CSR expansion.
1330#[cfg(any(test, feature = "cpu-parity"))]
1331pub fn try_csr_queue_forward_traverse_cpu(
1332    active_queue: &[u32],
1333    queue_len: u32,
1334    edge_offsets: &[u32],
1335    edge_targets: &[u32],
1336    edge_kind_mask: &[u32],
1337    node_count: u32,
1338    allow_mask: u32,
1339) -> Result<Vec<u32>, String> {
1340    let mut out: Vec<u32> = Vec::new();
1341    try_csr_queue_forward_traverse_cpu_into(
1342        active_queue,
1343        queue_len,
1344        edge_offsets,
1345        edge_targets,
1346        edge_kind_mask,
1347        node_count,
1348        allow_mask,
1349        &mut out,
1350    )?;
1351    Ok(out)
1352}
1353
1354/// Fallible CPU reference for queue-driven CSR expansion into caller-owned storage.
1355#[cfg(any(test, feature = "cpu-parity"))]
1356#[allow(clippy::too_many_arguments)]
1357pub fn try_csr_queue_forward_traverse_cpu_into(
1358    active_queue: &[u32],
1359    queue_len: u32,
1360    edge_offsets: &[u32],
1361    edge_targets: &[u32],
1362    edge_kind_mask: &[u32],
1363    node_count: u32,
1364    allow_mask: u32,
1365    out: &mut Vec<u32>,
1366) -> Result<(), String> {
1367    let layout = validate_csr_queue_graph(node_count, edge_offsets, edge_targets, edge_kind_mask)?;
1368    crate::graph::scratch::reserve_graph_items(
1369        out,
1370        layout.words,
1371        "CSR frontier queue CPU oracle",
1372        "frontier output bitset",
1373    )?;
1374    out.clear();
1375    out.resize(layout.words, 0);
1376    let take = (queue_len as usize).min(active_queue.len());
1377    for &src in &active_queue[..take] {
1378        if src >= node_count {
1379            continue;
1380        }
1381        let start = edge_offsets[src as usize] as usize;
1382        let end = edge_offsets[src as usize + 1] as usize;
1383        for edge in start..end.min(edge_targets.len()).min(edge_kind_mask.len()) {
1384            if edge_kind_mask[edge] & allow_mask == 0 {
1385                continue;
1386            }
1387            let dst = edge_targets[edge];
1388            if dst < node_count {
1389                out[dst as usize / 32] |= 1u32 << (dst % 32);
1390            }
1391        }
1392    }
1393    Ok(())
1394}
1395
1396#[cfg(test)]
1397mod generated_cpu_oracle_tests {
1398    use super::*;
1399
1400    #[test]
1401    fn frontier_to_queue_rejects_missing_words_without_clobbering_queue() {
1402        let mut queue = vec![7, 3, 1];
1403
1404        let err = try_frontier_to_queue_cpu_into(&[0b101], 64, 4, &mut queue)
1405            .expect_err("short frontier bitset must fail exact-width validation");
1406
1407        assert!(
1408            err.contains("frontier_in.len() == bitset_words(node_count)"),
1409            "Fix: frontier width error must identify the exact bitset contract, got: {err}"
1410        );
1411        assert_eq!(
1412            queue,
1413            vec![7, 3, 1],
1414            "failed frontier materialization must preserve previous queue diagnostics"
1415        );
1416    }
1417
1418    #[test]
1419    fn frontier_to_queue_clamps_queue_prefix_and_masks_tail_bits() {
1420        let frontier = [0b1010_u32, u32::MAX];
1421        let mut queue = Vec::new();
1422
1423        let seen = try_frontier_to_queue_cpu_into(&frontier, 33, 2, &mut queue)
1424            .expect("Fix: canonical frontier should materialize through the CPU oracle");
1425
1426        assert_eq!(seen, 3);
1427        assert_eq!(queue, vec![1, 3]);
1428        assert!(
1429            queue.iter().all(|node| *node < 33),
1430            "out-of-domain tail bits must not enter the compact queue prefix"
1431        );
1432    }
1433
1434    #[test]
1435    fn queue_forward_traverse_into_rejects_bad_graph_without_clobbering_output() {
1436        let mut out = vec![0xDEAD_BEEF];
1437
1438        let err = try_csr_queue_forward_traverse_cpu_into(
1439            &[0],
1440            1,
1441            &[0, 1, 1],
1442            &[2],
1443            &[1],
1444            2,
1445            1,
1446            &mut out,
1447        )
1448        .expect_err("out-of-range target must fail CSR queue graph validation");
1449
1450        assert!(
1451            err.contains("outside node_count"),
1452            "Fix: queue traversal graph errors must identify invalid targets, got: {err}"
1453        );
1454        assert_eq!(
1455            out,
1456            vec![0xDEAD_BEEF],
1457            "failed queue traversal preflight must preserve previous output diagnostics"
1458        );
1459    }
1460
1461    #[test]
1462    fn generated_frontier_queue_and_traverse_cpu_oracles_match_shape_contracts() {
1463        for node_count in 1u32..=128 {
1464            let edge_offsets: Vec<u32> = (0..=node_count).collect();
1465            let edge_targets: Vec<u32> = (0..node_count)
1466                .map(|node| (node + 1) % node_count)
1467                .collect();
1468            let edge_kind_mask = vec![1u32; node_count as usize];
1469            for queue_capacity in 0usize..32 {
1470                let mut frontier = vec![0u32; bitset_words(node_count) as usize];
1471                let period = (queue_capacity as u32 % 7) + 1;
1472                let mut expected_seen = 0u32;
1473                for node in 0..node_count {
1474                    if node % period == 0 {
1475                        frontier[node as usize / 32] |= 1u32 << (node % 32);
1476                        expected_seen = expected_seen.saturating_add(1);
1477                    }
1478                }
1479                let (queue, seen) =
1480                    try_frontier_to_queue_cpu(&frontier, node_count, queue_capacity).unwrap();
1481                assert_eq!(seen, expected_seen);
1482                assert_eq!(queue.len(), queue_capacity.min(expected_seen as usize));
1483                let out = try_csr_queue_forward_traverse_cpu(
1484                    &queue,
1485                    seen,
1486                    &edge_offsets,
1487                    &edge_targets,
1488                    &edge_kind_mask,
1489                    node_count,
1490                    1,
1491                )
1492                .unwrap();
1493                assert_eq!(out.len(), bitset_words(node_count) as usize);
1494                for &src in &queue {
1495                    let dst = (src + 1) % node_count;
1496                    assert_ne!(out[dst as usize / 32] & (1u32 << (dst % 32)), 0);
1497                }
1498            }
1499        }
1500    }
1501}
1502
1503/// Validated resident graph layout for queue-driven sparse traversal.
1504///
1505/// The primitive owns these derived counts so resident dispatch wrappers do not
1506/// fork CSR edge-count, edge-padding, or frontier bitset sizing rules.
1507#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1508pub struct CsrQueueGraphLayout {
1509    /// Number of graph nodes accepted by the primitive.
1510    pub node_count: u32,
1511    /// Exact physical edge count declared by `edge_offsets[node_count]`.
1512    pub edge_count: u32,
1513    /// Largest CSR row degree in the graph.
1514    pub max_row_degree: u32,
1515    /// Number of u32 words in each packed frontier bitset.
1516    pub words: usize,
1517    /// Number of u32 words to allocate/upload for edge target and kind arrays.
1518    pub edge_storage_words: usize,
1519}
1520
1521/// Validate the CSR graph consumed by queue-driven sparse traversal.
1522///
1523/// Returns the resident graph layout so dispatch wrappers can construct padded
1524/// buffers without owning CSR validation locally.
1525///
1526/// # Errors
1527///
1528/// Returns an actionable diagnostic for zero-node graphs, malformed offsets,
1529/// mismatched edge arrays, or out-of-range destinations.
1530pub fn validate_csr_queue_graph(
1531    node_count: u32,
1532    edge_offsets: &[u32],
1533    edge_targets: &[u32],
1534    edge_kind_mask: &[u32],
1535) -> Result<CsrQueueGraphLayout, String> {
1536    if node_count == 0 {
1537        return Err("Fix: csr_queue_forward_traverse requires node_count > 0.".to_string());
1538    }
1539    let expected_offsets = (node_count as usize).checked_add(1).ok_or_else(|| {
1540        format!(
1541            "Fix: csr_queue_forward_traverse node_count + 1 overflows usize for node_count={node_count}."
1542        )
1543    })?;
1544    if edge_offsets.len() != expected_offsets {
1545        return Err(format!(
1546            "Fix: csr_queue_forward_traverse requires edge_offsets.len() == node_count + 1, got len={}, node_count={node_count}.",
1547            edge_offsets.len()
1548        ));
1549    }
1550    if edge_targets.len() != edge_kind_mask.len() {
1551        return Err(format!(
1552            "Fix: csr_queue_forward_traverse requires edge_targets.len() == edge_kind_mask.len(), got {} vs {}.",
1553            edge_targets.len(),
1554            edge_kind_mask.len()
1555        ));
1556    }
1557    if edge_offsets[0] != 0 {
1558        return Err(format!(
1559            "Fix: csr_queue_forward_traverse requires edge_offsets[0] == 0, got {}.",
1560            edge_offsets[0]
1561        ));
1562    }
1563    let mut max_row_degree = 0u32;
1564    for (row, pair) in edge_offsets.windows(2).enumerate() {
1565        if pair[0] > pair[1] {
1566            return Err(format!(
1567                "Fix: csr_queue_forward_traverse offsets must be monotonic at row {row}: {} > {}.",
1568                pair[0], pair[1]
1569            ));
1570        }
1571        max_row_degree = max_row_degree.max(pair[1] - pair[0]);
1572    }
1573    let edge_count = edge_offsets[expected_offsets - 1] as usize;
1574    if edge_targets.len() != edge_count {
1575        return Err(format!(
1576            "Fix: csr_queue_forward_traverse final offset declares edge_count={edge_count}, but targets_len={} and kind_mask_len={}.",
1577            edge_targets.len(),
1578            edge_kind_mask.len()
1579        ));
1580    }
1581    for (index, &target) in edge_targets.iter().enumerate() {
1582        if target >= node_count {
1583            return Err(format!(
1584                "Fix: csr_queue_forward_traverse edge_targets[{index}]={target} is outside node_count {node_count}."
1585            ));
1586        }
1587    }
1588    let edge_count = u32::try_from(edge_count).map_err(|_| {
1589        format!("Fix: csr_queue_forward_traverse edge count {edge_count} exceeds u32 index space.")
1590    })?;
1591    Ok(CsrQueueGraphLayout {
1592        node_count,
1593        edge_count,
1594        max_row_degree,
1595        words: bitset_words(node_count) as usize,
1596        edge_storage_words: edge_targets.len().max(1),
1597    })
1598}
1599
1600/// Validate a batch of packed frontiers for queue-driven CSR traversal.
1601///
1602/// Returns the exact packed frontier word count implied by `node_count`, so
1603/// dispatch wrappers can size resident scratch without duplicating the
1604/// primitive's batch-shape contract.
1605///
1606/// # Errors
1607///
1608/// Returns an actionable diagnostic for zero-node graphs, empty batches, zero
1609/// queue capacity, or any query frontier whose packed bitset width does not
1610/// match `node_count`.
1611pub fn validate_frontier_queue_batch(
1612    node_count: u32,
1613    frontiers: &[&[u32]],
1614    queue_capacity: u32,
1615) -> Result<usize, String> {
1616    if node_count == 0 {
1617        return Err("Fix: resident CSR queue batch requires node_count > 0.".to_string());
1618    }
1619    if frontiers.is_empty() {
1620        return Err("Fix: resident CSR queue batch requires at least one frontier.".to_string());
1621    }
1622    if queue_capacity == 0 {
1623        return Err("Fix: resident CSR queue batch requires queue_capacity > 0.".to_string());
1624    }
1625
1626    let expected_words = bitset_words(node_count) as usize;
1627    for (query_index, frontier) in frontiers.iter().enumerate() {
1628        if frontier.len() != expected_words {
1629            return Err(format!(
1630                "Fix: resident CSR queue batch query {query_index} expected {expected_words} frontier word(s) for node_count={node_count} but received {}.",
1631                frontier.len()
1632            ));
1633        }
1634    }
1635    Ok(expected_words)
1636}
1637
1638/// Validate one packed frontier for queue-driven CSR traversal.
1639///
1640/// Returns the exact packed frontier word count implied by `node_count`, so a
1641/// resident dispatch wrapper can size scratch without duplicating queue and
1642/// frontier-shape policy.
1643///
1644/// # Errors
1645///
1646/// Returns an actionable diagnostic for zero-node graphs, zero queue capacity,
1647/// or a frontier whose packed bitset width does not match `node_count`.
1648pub fn validate_frontier_queue_query(
1649    node_count: u32,
1650    frontier: &[u32],
1651    queue_capacity: u32,
1652) -> Result<usize, String> {
1653    validate_frontier_queue_batch(node_count, &[frontier], queue_capacity).map_err(|error| {
1654        error
1655            .replace("resident CSR queue batch", "resident CSR queue query")
1656            .replace("query 0", "query")
1657    })
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663    use vyre_foundation::transform::visit::{walk_exprs, walk_nodes};
1664
1665    #[test]
1666    fn cpu_queue_preserves_node_order_and_reports_overflow_pressure() {
1667        let (queue, len) = frontier_to_queue_cpu(&[0b10111], 5, 3);
1668        assert_eq!(queue, vec![0, 1, 2]);
1669        assert_eq!(len, 4);
1670    }
1671
1672    #[test]
1673    fn cpu_queue_traverse_expands_only_queued_sources() {
1674        let edge_offsets = vec![0, 2, 3, 3, 3];
1675        let edge_targets = vec![1, 2, 3];
1676        let edge_kind_mask = vec![1, 2, 1];
1677        let out = csr_queue_forward_traverse_cpu(
1678            &[0, 1],
1679            2,
1680            &edge_offsets,
1681            &edge_targets,
1682            &edge_kind_mask,
1683            4,
1684            1,
1685        );
1686        assert_eq!(out, vec![0b1010]);
1687    }
1688
1689    #[test]
1690    fn packed_word_queue_reserves_once_per_nonzero_word() {
1691        let program = frontier_words_to_queue_parallel("frontier", "queue", "len", 35, 16);
1692
1693        assert_eq!(
1694            count_atomic_exprs(&program),
1695            1,
1696            "packed-word queue materialization should have one static queue reservation site"
1697        );
1698        assert_eq!(
1699            loop_atomic_count(&program, "qw_rank"),
1700            Some(0),
1701            "per-active-bit scatter loop must not issue atomics after the word-level reservation"
1702        );
1703        assert!(
1704            assignment_contains_u32(&program, "qw_remaining", 0b111),
1705            "the packed-word materializer must mask tail bits before popcounting the final frontier word"
1706        );
1707    }
1708
1709    #[test]
1710    fn emitted_programs_have_stable_shapes() {
1711        let queue_len_init = frontier_queue_len_init("len");
1712        assert_eq!(queue_len_init.workgroup_size, [1, 1, 1]);
1713        assert_eq!(queue_len_init.buffers.len(), 1);
1714        let queue = frontier_to_queue("frontier", "queue", "len", 64, 8);
1715        assert_eq!(queue.workgroup_size, [256, 1, 1]);
1716        assert_eq!(queue.buffers.len(), 3);
1717        let parallel_queue = frontier_to_queue_parallel("frontier", "queue", "len", 64, 8);
1718        assert_eq!(parallel_queue.workgroup_size, [256, 1, 1]);
1719        assert_eq!(parallel_queue.buffers.len(), 3);
1720        let word_queue = frontier_words_to_queue_parallel("frontier", "queue", "len", 64, 8);
1721        assert_eq!(word_queue.workgroup_size, [256, 1, 1]);
1722        assert_eq!(word_queue.buffers.len(), 3);
1723        assert_eq!(word_queue.buffers[0].count, 2);
1724        let word_queue_clear =
1725            frontier_words_to_queue_clear_out_parallel("frontier", "queue", "len", "out", 64, 8);
1726        assert_eq!(word_queue_clear.workgroup_size, [256, 1, 1]);
1727        assert_eq!(word_queue_clear.buffers.len(), 4);
1728        assert_eq!(word_queue_clear.buffers[0].count, 2);
1729        assert_eq!(word_queue_clear.buffers[3].name.as_ref(), "out");
1730        assert_eq!(word_queue_clear.buffers[3].count, 2);
1731        let word_scan =
1732            frontier_word_counts_scan_pass_a("frontier", "partials", "block_totals", 64);
1733        assert_eq!(word_scan.workgroup_size, [1024, 1, 1]);
1734        assert_eq!(word_scan.buffers.len(), 5);
1735        assert_eq!(word_scan.buffers[0].count, 2);
1736        assert_eq!(word_scan.buffers[1].count, 1024);
1737        assert_eq!(word_scan.buffers[2].count, 1);
1738        let block_offsets = frontier_word_block_offsets_in_place("block_totals", 32_897);
1739        assert_eq!(block_offsets.workgroup_size, [1024, 1, 1]);
1740        assert_eq!(block_offsets.buffers.len(), 3);
1741        assert_eq!(block_offsets.buffers[0].count, 2);
1742        let huge_block_offsets = frontier_word_block_offsets_in_place("block_totals", 33_554_433);
1743        assert_eq!(huge_block_offsets.workgroup_size, [1, 1, 1]);
1744        assert_eq!(huge_block_offsets.buffers.len(), 1);
1745        assert_eq!(huge_block_offsets.buffers[0].count, 1025);
1746        let prefix_queue = frontier_word_block_prefix_to_queue_parallel(
1747            "frontier",
1748            "partials",
1749            "block_totals",
1750            "queue",
1751            "len",
1752            64,
1753            8,
1754        );
1755        assert_eq!(prefix_queue.workgroup_size, [256, 1, 1]);
1756        assert_eq!(prefix_queue.buffers.len(), 5);
1757        assert_eq!(prefix_queue.buffers[0].count, 2);
1758        assert_eq!(prefix_queue.buffers[1].count, 1024);
1759        assert_eq!(prefix_queue.buffers[2].count, 1);
1760        let offset_queue = frontier_word_block_offsets_to_queue_parallel(
1761            "frontier",
1762            "partials",
1763            "block_offsets",
1764            "queue",
1765            "len",
1766            32_897,
1767            8,
1768        );
1769        assert_eq!(offset_queue.workgroup_size, [256, 1, 1]);
1770        assert_eq!(offset_queue.buffers.len(), 5);
1771        assert_eq!(offset_queue.buffers[0].count, 1029);
1772        assert_eq!(offset_queue.buffers[1].count, 2048);
1773        assert_eq!(offset_queue.buffers[2].count, 2);
1774        assert!(
1775            !format!("{:?}", offset_queue.entry()).contains("fwq_prev_block"),
1776            "precomputed-offset scatter must not retain the per-word previous-block loop"
1777        );
1778        let traverse = csr_queue_forward_traverse(
1779            "queue", "len", "offsets", "targets", "kinds", "out", 64, 7, 8, 1,
1780        );
1781        assert_eq!(traverse.workgroup_size, [256, 1, 1]);
1782        assert_eq!(traverse.buffers.len(), 6);
1783    }
1784
1785    #[test]
1786    fn frontier_queue_sizing_overflow_returns_error_without_panic() {
1787        let byte_result = std::panic::catch_unwind(|| {
1788            try_u32_byte_range_with_word_size(2, usize::MAX, "test frontier queue bytes")
1789        });
1790        assert!(
1791            byte_result.is_ok(),
1792            "checked frontier byte sizing must return an error instead of panicking"
1793        );
1794        let err = byte_result.unwrap().unwrap_err().to_string();
1795        assert!(
1796            err.contains("overflows output byte range"),
1797            "Fix: byte sizing overflow must name the byte-range contract, got: {err}"
1798        );
1799        assert!(
1800            err.contains("Shard the frontier queue"),
1801            "Fix: byte sizing overflow must tell the operator how to recover, got: {err}"
1802        );
1803
1804        let product_result = std::panic::catch_unwind(|| {
1805            checked_frontier_u32_product(u32::MAX, 2, "test partial word count")
1806        });
1807        assert!(
1808            product_result.is_ok(),
1809            "checked frontier word products must return an error instead of panicking"
1810        );
1811        let err = product_result.unwrap().unwrap_err().to_string();
1812        assert!(
1813            err.contains("overflows u32 word count"),
1814            "Fix: word-count overflow must name the u32 product contract, got: {err}"
1815        );
1816    }
1817
1818    #[test]
1819    fn csr_queue_traverse_rejects_offset_count_overflow_without_panic() {
1820        let result = std::panic::catch_unwind(|| {
1821            csr_queue_forward_traverse(
1822                "queue",
1823                "len",
1824                "offsets",
1825                "targets",
1826                "kinds",
1827                "out",
1828                u32::MAX,
1829                0,
1830                1,
1831                1,
1832            )
1833        });
1834        assert!(
1835            result.is_ok(),
1836            "csr_queue_forward_traverse must emit an invalid program instead of panicking"
1837        );
1838
1839        let program = result.unwrap();
1840        assert_eq!(program.workgroup_size, [1, 1, 1]);
1841        assert_eq!(program.buffers.len(), 1);
1842        assert_eq!(program.buffers[0].name.as_ref(), "out");
1843        let entry = format!("{:?}", program.entry());
1844        assert!(
1845            entry.contains("node_count + 1 overflows u32"),
1846            "Fix: invalid CSR queue program must preserve the offset overflow diagnostic, got: {entry}"
1847        );
1848    }
1849
1850    fn count_atomic_exprs(program: &Program) -> usize {
1851        let mut count = 0;
1852        walk_exprs(program, |expr| {
1853            if matches!(expr, Expr::Atomic { .. }) {
1854                count += 1;
1855            }
1856        });
1857        count
1858    }
1859
1860    fn loop_atomic_count(program: &Program, loop_var: &str) -> Option<usize> {
1861        let mut count = None;
1862        walk_nodes(program, |node| {
1863            if count.is_some() {
1864                return;
1865            }
1866            if let Node::Loop { var, body, .. } = node {
1867                if var.as_ref() == loop_var {
1868                    let loop_program = Program::wrapped(Vec::new(), [1, 1, 1], body.clone());
1869                    count = Some(count_atomic_exprs(&loop_program));
1870                }
1871            }
1872        });
1873        count
1874    }
1875
1876    fn assignment_contains_u32(program: &Program, target: &str, value: u32) -> bool {
1877        let mut found = false;
1878        walk_nodes(program, |node| {
1879            if found {
1880                return;
1881            }
1882            if let Node::Assign { name, value: expr } = node {
1883                if name.as_ref() == target && expr_contains_u32(expr, value) {
1884                    found = true;
1885                }
1886            }
1887        });
1888        found
1889    }
1890
1891    fn expr_contains_u32(expr: &Expr, value: u32) -> bool {
1892        let mut found = false;
1893        let expr_program = Program::wrapped(
1894            Vec::new(),
1895            [1, 1, 1],
1896            vec![Node::let_bind("__expr_probe", expr.clone())],
1897        );
1898        walk_exprs(&expr_program, |expr| {
1899            if matches!(expr, Expr::LitU32(found_value) if *found_value == value) {
1900                found = true;
1901            }
1902        });
1903        found
1904    }
1905
1906    #[test]
1907    fn validate_csr_queue_graph_accepts_zero_edge_graph_and_canonical_graph() {
1908        assert_eq!(
1909            validate_csr_queue_graph(3, &[0, 0, 0, 0], &[], &[]).unwrap(),
1910            CsrQueueGraphLayout {
1911                node_count: 3,
1912                edge_count: 0,
1913                max_row_degree: 0,
1914                words: 1,
1915                edge_storage_words: 1,
1916            }
1917        );
1918        assert_eq!(
1919            validate_csr_queue_graph(4, &[0, 2, 3, 3, 3], &[1, 2, 3], &[1, 2, 1]).unwrap(),
1920            CsrQueueGraphLayout {
1921                node_count: 4,
1922                edge_count: 3,
1923                max_row_degree: 2,
1924                words: 1,
1925                edge_storage_words: 3,
1926            }
1927        );
1928    }
1929
1930    #[test]
1931    fn validate_csr_queue_graph_rejects_malformed_inputs() {
1932        let err = validate_csr_queue_graph(0, &[0], &[], &[]).unwrap_err();
1933        assert!(err.contains("node_count > 0"));
1934
1935        let err = validate_csr_queue_graph(2, &[0, 1, 1], &[1], &[]).unwrap_err();
1936        assert!(err.contains("edge_targets.len() == edge_kind_mask.len()"));
1937
1938        let err = validate_csr_queue_graph(2, &[0, 2, 1], &[1], &[1]).unwrap_err();
1939        assert!(err.contains("offsets must be monotonic"));
1940
1941        let err = validate_csr_queue_graph(2, &[0, 1, 1], &[5], &[1]).unwrap_err();
1942        assert!(err.contains("outside node_count"));
1943    }
1944
1945    #[test]
1946    fn validate_frontier_queue_batch_accepts_canonical_frontiers() {
1947        let frontiers: [&[u32]; 2] = [&[1, 0], &[0, 2]];
1948
1949        let words = validate_frontier_queue_batch(64, &frontiers, 8)
1950            .expect("Fix: two 64-node frontiers should be valid");
1951
1952        assert_eq!(words, 2);
1953    }
1954
1955    #[test]
1956    fn validate_frontier_queue_batch_rejects_invalid_batch_shapes() {
1957        let frontier: [&[u32]; 1] = [&[1]];
1958
1959        let err = validate_frontier_queue_batch(0, &frontier, 8).unwrap_err();
1960        assert!(err.contains("node_count > 0"));
1961
1962        let empty: [&[u32]; 0] = [];
1963        let err = validate_frontier_queue_batch(64, &empty, 8).unwrap_err();
1964        assert!(err.contains("at least one frontier"));
1965
1966        let err = validate_frontier_queue_batch(64, &frontier, 0).unwrap_err();
1967        assert!(err.contains("queue_capacity > 0"));
1968
1969        let err = validate_frontier_queue_batch(64, &frontier, 8).unwrap_err();
1970        assert!(err.contains("query 0 expected 2 frontier word"));
1971    }
1972
1973    #[test]
1974    fn validate_frontier_queue_query_delegates_single_frontier_contract() {
1975        assert_eq!(validate_frontier_queue_query(64, &[1, 0], 8).unwrap(), 2);
1976
1977        let err = validate_frontier_queue_query(64, &[1], 8).unwrap_err();
1978        assert!(err.contains("query expected 2 frontier word"));
1979
1980        let err = validate_frontier_queue_query(64, &[1, 0], 0).unwrap_err();
1981        assert!(err.contains("queue_capacity > 0"));
1982    }
1983}