Skip to main content

vyre_primitives/graph/
csr_bidirectional.rs

1//! `csr_bidirectional`  -  one BFS step over BOTH forward + backward
2//! edges of a ProgramGraph CSR. Used for undirected reachability
3//! (e.g. component discovery, alias unification).
4
5use super::padded_u32_slice_fingerprint as csr_bidirectional_padded_slice_fingerprint;
6use vyre_foundation::execution_plan::fusion::fuse_programs;
7use vyre_foundation::ir::{DataType, Program};
8
9use crate::graph::csr_backward_traverse::csr_backward_traverse;
10use crate::graph::csr_forward_traverse::{bitset_words, csr_forward_traverse};
11use crate::graph::csr_frontier_step::csr_frontier_step_dispatch_grid;
12use crate::graph::program_graph::ProgramGraphShape;
13
14/// Canonical op id.
15pub const OP_ID: &str = "vyre-primitives::graph::csr_bidirectional";
16/// Canonical dispatch input label for graph node scratch.
17pub const CSR_BIDIRECTIONAL_NODES_BUFFER: &str = "csr_bidirectional nodes";
18/// Canonical dispatch input label for CSR offsets.
19pub const CSR_BIDIRECTIONAL_OFFSETS_BUFFER: &str = "csr_bidirectional edge_offsets";
20/// Canonical dispatch input label for CSR targets.
21pub const CSR_BIDIRECTIONAL_TARGETS_BUFFER: &str = "csr_bidirectional edge_targets";
22/// Canonical dispatch input label for edge-kind masks.
23pub const CSR_BIDIRECTIONAL_EDGE_KIND_BUFFER: &str = "csr_bidirectional edge_kind_mask";
24/// Canonical dispatch input label for node tags.
25pub const CSR_BIDIRECTIONAL_NODE_TAGS_BUFFER: &str = "csr_bidirectional node_tags";
26/// Canonical dispatch input label for the incoming frontier.
27pub const CSR_BIDIRECTIONAL_FRONTIER_IN_BUFFER: &str = "csr_bidirectional frontier_in";
28/// Canonical dispatch output label for the outgoing frontier.
29pub const CSR_BIDIRECTIONAL_FRONTIER_OUT_BUFFER: &str = "csr_bidirectional frontier_out";
30
31/// Build a Program: emit one forward step + one backward step,
32/// fused into one Region. Both writes target `frontier_out` so a
33/// single dispatch covers both directions.
34#[must_use]
35pub fn csr_bidirectional(
36    shape: ProgramGraphShape,
37    frontier_in: &str,
38    frontier_out: &str,
39    edge_kind_mask: u32,
40) -> Program {
41    let fwd = csr_forward_traverse(shape, frontier_in, frontier_out, edge_kind_mask);
42    let bwd = csr_backward_traverse(shape, frontier_in, frontier_out, edge_kind_mask);
43    fuse_programs(&[fwd, bwd]).unwrap_or_else(|error| {
44        crate::invalid_output_program(
45            OP_ID,
46            frontier_out,
47            DataType::U32,
48            format!("Fix: csr_bidirectional forward+backward fusion failed: {error}"),
49        )
50    })
51}
52
53/// CPU reference: union of forward + backward one-step reach.
54#[must_use]
55#[cfg(any(test, feature = "cpu-parity"))]
56pub fn cpu_ref(
57    node_count: u32,
58    edge_offsets: &[u32],
59    edge_targets: &[u32],
60    edge_kind_mask: &[u32],
61    frontier_in: &[u32],
62    allow_mask: u32,
63) -> Vec<u32> {
64    try_cpu_ref(
65        node_count,
66        edge_offsets,
67        edge_targets,
68        edge_kind_mask,
69        frontier_in,
70        allow_mask,
71    )
72    .unwrap_or_else(|err| panic!("csr_bidirectional CPU oracle received malformed input. {err}"))
73}
74
75/// Fallible CPU reference for the union of forward + backward one-step reach.
76///
77/// This variant is suitable for fuzzing/conformance and wrapper validation
78/// because malformed CSR/frontier shapes return an actionable error instead of
79/// panicking.
80#[cfg(any(test, feature = "cpu-parity"))]
81pub fn try_cpu_ref(
82    node_count: u32,
83    edge_offsets: &[u32],
84    edge_targets: &[u32],
85    edge_kind_mask: &[u32],
86    frontier_in: &[u32],
87    allow_mask: u32,
88) -> Result<Vec<u32>, String> {
89    let mut out = Vec::new();
90    try_cpu_ref_into(
91        node_count,
92        edge_offsets,
93        edge_targets,
94        edge_kind_mask,
95        frontier_in,
96        allow_mask,
97        &mut out,
98    )?;
99    Ok(out)
100}
101
102/// CPU reference writing the unioned forward/backward step into caller-owned storage.
103#[cfg(any(test, feature = "cpu-parity"))]
104pub fn cpu_ref_into(
105    node_count: u32,
106    edge_offsets: &[u32],
107    edge_targets: &[u32],
108    edge_kind_mask: &[u32],
109    frontier_in: &[u32],
110    allow_mask: u32,
111    out: &mut Vec<u32>,
112) {
113    try_cpu_ref_into(
114        node_count,
115        edge_offsets,
116        edge_targets,
117        edge_kind_mask,
118        frontier_in,
119        allow_mask,
120        out,
121    )
122    .unwrap_or_else(|err| panic!("csr_bidirectional CPU oracle received malformed input. {err}"));
123}
124
125/// Fallible CPU reference writing one bidirectional step into caller storage.
126///
127/// The output buffer is not cleared or resized until validation and reservation
128/// both succeed, so hostile malformed inputs cannot destroy reusable scratch.
129#[cfg(any(test, feature = "cpu-parity"))]
130pub fn try_cpu_ref_into(
131    node_count: u32,
132    edge_offsets: &[u32],
133    edge_targets: &[u32],
134    edge_kind_mask: &[u32],
135    frontier_in: &[u32],
136    allow_mask: u32,
137    out: &mut Vec<u32>,
138) -> Result<(), String> {
139    let layout = validate_csr_inputs(
140        node_count,
141        edge_offsets,
142        edge_targets,
143        edge_kind_mask,
144        frontier_in,
145    )?;
146    crate::graph::scratch::reserve_graph_items_with(
147        out,
148        layout.words,
149        "csr_bidirectional CPU oracle",
150        "bidirectional step output",
151        |message| message,
152    )?;
153    cpu_ref_into_validated(
154        layout,
155        edge_offsets,
156        edge_targets,
157        edge_kind_mask,
158        frontier_in,
159        allow_mask,
160        out,
161    )
162}
163
164#[cfg(any(test, feature = "cpu-parity"))]
165fn cpu_ref_into_validated(
166    layout: CsrBidirectionalLayout,
167    edge_offsets: &[u32],
168    edge_targets: &[u32],
169    edge_kind_mask: &[u32],
170    frontier_in: &[u32],
171    allow_mask: u32,
172    out: &mut Vec<u32>,
173) -> Result<(), String> {
174    out.clear();
175    out.resize(layout.words, 0);
176    for src in 0..layout.node_words {
177        let src_word = src / 32;
178        let src_bit = 1u32 << (src % 32);
179        let src_in_frontier =
180            src_word < frontier_in.len() && (frontier_in[src_word] & src_bit) != 0;
181        let edge_start = csr_bidir_u32_to_usize(edge_offsets[src], "edge start offset")?;
182        let edge_end = csr_bidir_u32_to_usize(edge_offsets[src + 1], "edge end offset")?;
183        let mut backward_hit = false;
184        for edge in edge_start..edge_end.min(edge_targets.len()).min(edge_kind_mask.len()) {
185            if edge_kind_mask[edge] & allow_mask == 0 {
186                continue;
187            }
188            let dst = csr_bidir_u32_to_usize(edge_targets[edge], "edge target")?;
189            let dst_word = dst / 32;
190            let dst_bit = 1u32 << (dst % 32);
191            if src_in_frontier && dst < layout.node_words {
192                out[dst_word] |= dst_bit;
193            }
194            if dst_word < frontier_in.len() && (frontier_in[dst_word] & dst_bit) != 0 {
195                backward_hit = true;
196            }
197        }
198        if backward_hit && src_word < out.len() {
199            out[src_word] |= src_bit;
200        }
201    }
202    Ok(())
203}
204
205/// Validated dispatch layout for bidirectional CSR traversal.
206///
207/// The primitive owns these derived values so dispatch wrappers do not fork
208/// CSR/frontier layout rules.
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210pub struct CsrBidirectionalLayout {
211    /// Number of nodes accepted by the primitive.
212    pub node_count: u32,
213    /// Number of `u32` frontier words required for `node_count`.
214    pub words: usize,
215    /// Number of node-index words required by graph-indexed scratch buffers.
216    pub node_words: usize,
217    /// Exact edge count declared by `edge_offsets[node_count]`.
218    pub edge_count: u32,
219    /// Number of u32 words required by physical edge buffers after padding.
220    pub edge_storage_words: usize,
221}
222
223/// Primitive-owned dispatch plan for a bidirectional CSR step.
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct CsrBidirectionalDispatchPlan {
226    /// Validated CSR/frontier layout.
227    pub layout: CsrBidirectionalLayout,
228    /// Edge-kind mask accepted by this step.
229    pub allow_mask: u32,
230    /// Dispatch grid override.
231    pub grid: [u32; 3],
232    /// Words required by graph-node scratch buffers.
233    pub node_words: usize,
234    /// Words required by padded edge buffers.
235    pub edge_storage_words: usize,
236    /// Words required by input/output frontiers.
237    pub frontier_words: usize,
238}
239
240/// Primitive-owned program identity for bidirectional CSR dispatch.
241#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub struct CsrBidirectionalProgramKey {
243    /// Validated CSR/frontier layout represented by this program.
244    pub layout: CsrBidirectionalLayout,
245    /// Edge-kind mask accepted by this step.
246    pub allow_mask: u32,
247}
248
249/// Primitive-owned identity for reusable bidirectional CSR static inputs.
250///
251/// Dispatch wrappers stage node scratch and frontier buffers dynamically, but
252/// CSR offsets, targets, and edge-kind masks are static graph inputs. This key
253/// keeps content identity next to the primitive-owned layout and padded edge
254/// storage contract.
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub struct CsrBidirectionalStaticInputKey {
257    /// Program identity selected by the primitive dispatch planner.
258    pub program_key: CsrBidirectionalProgramKey,
259    /// Words in the CSR offsets buffer.
260    pub edge_offset_words: usize,
261    /// Words in each padded edge-indexed input.
262    pub edge_storage_words: usize,
263    /// Stable fingerprint of the edge-offset upload.
264    pub edge_offsets_hash: u64,
265    /// Stable fingerprint of the padded edge-target upload.
266    pub edge_targets_hash: u64,
267    /// Stable fingerprint of the padded edge-kind upload.
268    pub edge_kind_mask_hash: u64,
269}
270
271impl CsrBidirectionalDispatchPlan {
272    /// Stable key for caching the generated primitive program.
273    #[must_use]
274    pub const fn program_key(&self) -> CsrBidirectionalProgramKey {
275        CsrBidirectionalProgramKey {
276            layout: self.layout,
277            allow_mask: self.allow_mask,
278        }
279    }
280
281    /// Build the fused forward/backward traversal program for this plan.
282    #[must_use]
283    pub fn program(&self) -> Program {
284        csr_bidirectional(
285            ProgramGraphShape::new(self.layout.node_count, self.layout.edge_count.max(1)),
286            CSR_BIDIRECTIONAL_FRONTIER_IN_BUFFER,
287            CSR_BIDIRECTIONAL_FRONTIER_OUT_BUFFER,
288            self.allow_mask,
289        )
290    }
291
292    /// Return true when both logical edge arrays already match the physical
293    /// edge-buffer storage required by this plan and can be dispatched without
294    /// staging padded scratch.
295    #[must_use]
296    pub const fn edge_buffers_can_dispatch_unpadded(
297        &self,
298        edge_targets_len: usize,
299        edge_kind_mask_len: usize,
300    ) -> bool {
301        can_dispatch_edge_buffers_without_padding(
302            edge_targets_len,
303            edge_kind_mask_len,
304            self.edge_storage_words,
305        )
306    }
307
308    /// Return the primitive-owned cache identity for this plan's static CSR graph inputs.
309    ///
310    /// # Errors
311    ///
312    /// Returns an actionable diagnostic when the supplied CSR slices no longer
313    /// match the validated dispatch plan shape.
314    pub fn static_input_key(
315        &self,
316        edge_offsets: &[u32],
317        edge_targets: &[u32],
318        edge_kind_mask: &[u32],
319    ) -> Result<CsrBidirectionalStaticInputKey, String> {
320        let expected_offsets = self.layout.node_words.checked_add(1).ok_or_else(|| {
321            format!(
322                "Fix: csr_bidirectional static key node_words + 1 overflows usize for node_words={}.",
323                self.layout.node_words
324            )
325        })?;
326        if edge_offsets.len() != expected_offsets {
327            return Err(format!(
328                "Fix: csr_bidirectional static key expected {expected_offsets} offset word(s), got {}.",
329                edge_offsets.len()
330            ));
331        }
332        let expected_edges = self.layout.edge_count as usize;
333        if edge_targets.len() != expected_edges {
334            return Err(format!(
335                "Fix: csr_bidirectional static key expected {expected_edges} edge target word(s), got {}.",
336                edge_targets.len()
337            ));
338        }
339        if edge_kind_mask.len() != expected_edges {
340            return Err(format!(
341                "Fix: csr_bidirectional static key expected {expected_edges} edge kind word(s), got {}.",
342                edge_kind_mask.len()
343            ));
344        }
345        Ok(CsrBidirectionalStaticInputKey {
346            program_key: self.program_key(),
347            edge_offset_words: expected_offsets,
348            edge_storage_words: self.edge_storage_words,
349            edge_offsets_hash: csr_bidirectional_padded_slice_fingerprint(
350                edge_offsets,
351                expected_offsets,
352            ),
353            edge_targets_hash: csr_bidirectional_padded_slice_fingerprint(
354                edge_targets,
355                self.edge_storage_words,
356            ),
357            edge_kind_mask_hash: csr_bidirectional_padded_slice_fingerprint(
358                edge_kind_mask,
359                self.edge_storage_words,
360            ),
361        })
362    }
363}
364
365/// Return true when both edge arrays have the exact required physical edge
366/// storage width and can be borrowed directly by dispatch wrappers.
367///
368/// Empty logical edge arrays intentionally return false for the canonical
369/// one-word padded storage case, keeping that padding contract owned by the
370/// primitive instead of each dispatch consumer.
371#[must_use]
372pub const fn can_dispatch_edge_buffers_without_padding(
373    edge_targets_len: usize,
374    edge_kind_mask_len: usize,
375    edge_storage_words: usize,
376) -> bool {
377    edge_targets_len == edge_storage_words && edge_kind_mask_len == edge_storage_words
378}
379
380/// Validate the public CSR/frontier inputs consumed by the bidirectional
381/// traversal primitive.
382///
383/// Returns the full dispatch layout so wrappers can build padded device buffers
384/// without re-parsing the CSR contract locally.
385///
386/// # Errors
387///
388/// Returns an actionable diagnostic when offsets, edge arrays, frontier width,
389/// or destinations violate the primitive's contract.
390pub fn validate_csr_inputs(
391    node_count: u32,
392    edge_offsets: &[u32],
393    edge_targets: &[u32],
394    edge_kind_mask: &[u32],
395    frontier_in: &[u32],
396) -> Result<CsrBidirectionalLayout, String> {
397    let expected_offsets = (node_count as usize).checked_add(1).ok_or_else(|| {
398        format!(
399            "Fix: csr_bidirectional node_count + 1 overflows usize for node_count={node_count}."
400        )
401    })?;
402    if edge_offsets.len() != expected_offsets {
403        return Err(format!(
404            "Fix: csr_bidirectional requires edge_offsets.len() == node_count + 1, got len={}, node_count={node_count}.",
405            edge_offsets.len()
406        ));
407    }
408
409    let expected_frontier_words = bitset_words(node_count) as usize;
410    if frontier_in.len() != expected_frontier_words {
411        return Err(format!(
412            "Fix: csr_bidirectional expected frontier length {expected_frontier_words} words for {node_count} nodes, got {}.",
413            frontier_in.len()
414        ));
415    }
416
417    if edge_targets.len() != edge_kind_mask.len() {
418        return Err(format!(
419            "Fix: csr_bidirectional requires edge_targets.len() == edge_kind_mask.len(), got {} vs {}.",
420            edge_targets.len(),
421            edge_kind_mask.len()
422        ));
423    }
424
425    if let Some(&first) = edge_offsets.first() {
426        if first != 0 {
427            return Err(format!(
428                "Fix: csr_bidirectional requires edge_offsets[0] == 0, got {first}."
429            ));
430        }
431    }
432    for (index, pair) in edge_offsets.windows(2).enumerate() {
433        if pair[0] > pair[1] {
434            return Err(format!(
435                "Fix: csr_bidirectional offsets must be monotonic; offsets[{index}]={} > offsets[{}]={}.",
436                pair[0],
437                index + 1,
438                pair[1]
439            ));
440        }
441    }
442
443    let edge_count = edge_offsets[expected_offsets - 1] as usize;
444    if edge_targets.len() != edge_count {
445        return Err(format!(
446            "Fix: csr_bidirectional final offset declares edge_count={edge_count}, but targets_len={} and kind_mask_len={}.",
447            edge_targets.len(),
448            edge_kind_mask.len()
449        ));
450    }
451    for (index, &target) in edge_targets.iter().enumerate() {
452        if target >= node_count {
453            return Err(format!(
454                "Fix: csr_bidirectional edge_targets[{index}]={target} is outside node_count {node_count}."
455            ));
456        }
457    }
458    let edge_count = u32::try_from(edge_count).map_err(|_| {
459        format!("Fix: csr_bidirectional edge count {edge_count} exceeds u32 index space.")
460    })?;
461    Ok(CsrBidirectionalLayout {
462        node_count,
463        words: expected_frontier_words,
464        node_words: node_count as usize,
465        edge_count,
466        edge_storage_words: edge_targets.len().max(1),
467    })
468}
469
470/// Validate inputs and return the complete dispatch plan for one bidirectional step.
471pub fn plan_csr_bidirectional_step(
472    node_count: u32,
473    edge_offsets: &[u32],
474    edge_targets: &[u32],
475    edge_kind_mask: &[u32],
476    frontier_in: &[u32],
477    allow_mask: u32,
478) -> Result<CsrBidirectionalDispatchPlan, String> {
479    let layout = validate_csr_inputs(
480        node_count,
481        edge_offsets,
482        edge_targets,
483        edge_kind_mask,
484        frontier_in,
485    )?;
486    Ok(CsrBidirectionalDispatchPlan {
487        node_words: layout.node_words,
488        edge_storage_words: layout.edge_storage_words,
489        frontier_words: layout.words,
490        grid: csr_frontier_step_dispatch_grid(layout.node_count),
491        allow_mask,
492        layout,
493    })
494}
495
496/// Run a bidirectional CSR closure loop from a primitive-owned dispatch plan.
497///
498/// The caller supplies one step executor: CPU references can execute the
499/// validated primitive oracle, while GPU wrappers can dispatch a prepared
500/// program. Initialization, max-iteration handling, frontier merge semantics,
501/// and reusable-buffer reservation stay single-sourced here.
502///
503/// # Errors
504///
505/// Returns caller-mapped errors for malformed seed width, reservation failure,
506/// step execution failure, or frontier shape drift.
507#[allow(clippy::too_many_arguments)]
508pub fn run_csr_bidirectional_closure_plan_with_step<E, MapError, Step>(
509    plan: &CsrBidirectionalDispatchPlan,
510    seed: &[u32],
511    max_iters: u32,
512    current: &mut Vec<u32>,
513    next: &mut Vec<u32>,
514    mut map_error: MapError,
515    mut step: Step,
516) -> Result<(), E>
517where
518    MapError: FnMut(String) -> E,
519    Step: FnMut(&[u32], &mut Vec<u32>) -> Result<(), E>,
520{
521    if seed.len() != plan.frontier_words {
522        return Err(map_error(format!(
523            "Fix: csr_bidirectional closure expected seed length {} words for {} nodes, got {}.",
524            plan.frontier_words,
525            plan.layout.node_count,
526            seed.len()
527        )));
528    }
529    crate::graph::scratch::reserve_graph_items_with(
530        current,
531        plan.frontier_words,
532        "csr_bidirectional closure runner",
533        "current frontier",
534        |message| map_error(message),
535    )?;
536    crate::graph::scratch::reserve_graph_items_with(
537        next,
538        plan.frontier_words,
539        "csr_bidirectional closure runner",
540        "next frontier",
541        |message| map_error(message),
542    )?;
543
544    current.clear();
545    current.extend_from_slice(seed);
546    next.clear();
547    if plan.layout.node_count == 0 || max_iters == 0 {
548        return Ok(());
549    }
550
551    for _ in 0..max_iters {
552        next.clear();
553        step(current, next)?;
554        if !try_merge_frontier_or_changed(current, next).map_err(&mut map_error)? {
555            return Ok(());
556        }
557    }
558    Ok(())
559}
560
561#[cfg(test)]
562mod dispatch_plan_tests {
563    use super::*;
564
565    #[test]
566    fn dispatch_plan_owns_buffer_sizes_grid_and_mask() {
567        let plan = plan_csr_bidirectional_step(
568            4,
569            &[0, 1, 2, 3, 3],
570            &[1, 2, 3],
571            &[1, 1, 1],
572            &[0b0010],
573            0x55AA_00FF,
574        )
575        .expect("Fix: valid bidirectional CSR step should produce dispatch plan");
576
577        // 4 nodes / 256 threads per workgroup = ceil(4/256) = 1 block, not 4.
578        assert_eq!(plan.grid, [1, 1, 1]);
579        assert_eq!(plan.node_words, 4);
580        assert_eq!(plan.edge_storage_words, 3);
581        assert_eq!(plan.frontier_words, 1);
582        assert_eq!(plan.allow_mask, 0x55AA_00FF);
583        assert_eq!(plan.layout.edge_count, 3);
584    }
585
586    /// Regression: grid X was set to `node_count` instead of
587    /// `ceil(node_count / CSR_FRONTIER_STEP_WORKGROUP_SIZE[0])`.
588    /// For node_count=257 the old code emitted [257,1,1] (257 blocks × 256
589    /// threads = 65,792 invocations) instead of [2,1,1] (2 blocks × 256
590    /// threads = 512 invocations), a 256x over-dispatch.
591    #[test]
592    fn grid_x_is_ceil_node_count_div_workgroup_size_not_node_count() {
593        // 4 nodes: ceil(4/256) == 1
594        let plan_small = plan_csr_bidirectional_step(
595            4,
596            &[0, 1, 2, 3, 3],
597            &[1, 2, 3],
598            &[1, 1, 1],
599            &[0b0010],
600            u32::MAX,
601        )
602        .expect("Fix: valid 4-node bidirectional CSR step should produce dispatch plan");
603        assert_eq!(
604            plan_small.grid,
605            [1, 1, 1],
606            "4 nodes: expected ceil(4/256)=1 block, got {:?}",
607            plan_small.grid
608        );
609
610        // 257 nodes: ceil(257/256) == 2; old buggy code emits [257,1,1].
611        // Build a chain: 0→1→2→…→256 (257 nodes, 256 edges).
612        let mut offsets = Vec::with_capacity(258);
613        offsets.push(0u32);
614        for i in 0..256u32 {
615            offsets.push(i + 1);
616        }
617        offsets.push(256u32); // node 256 has no outgoing edge
618        let targets: Vec<u32> = (1u32..=256).collect();
619        let kinds: Vec<u32> = vec![1u32; 256];
620        let frontier = vec![0u32; crate::bitset::bitset_words(257) as usize];
621
622        let plan_large =
623            plan_csr_bidirectional_step(257, &offsets, &targets, &kinds, &frontier, u32::MAX)
624                .expect("Fix: valid 257-node bidirectional CSR step should produce dispatch plan");
625        assert_eq!(
626            plan_large.grid,
627            [2, 1, 1],
628            "257 nodes: expected ceil(257/256)=2 blocks, got {:?}",
629            plan_large.grid
630        );
631    }
632
633    #[test]
634    fn dispatch_plan_pads_empty_edges_without_zero_sized_buffers() {
635        let plan = plan_csr_bidirectional_step(1, &[0, 0], &[], &[], &[0], u32::MAX)
636            .expect("Fix: edgeless one-node graph should still have dispatch buffers");
637
638        assert_eq!(plan.grid, [1, 1, 1]);
639        assert_eq!(plan.edge_storage_words, 1);
640        assert_eq!(plan.frontier_words, 1);
641        assert_eq!(plan.layout.edge_count, 0);
642        assert!(!plan.edge_buffers_can_dispatch_unpadded(0, 0));
643    }
644
645    #[test]
646    fn edge_buffer_unpadded_policy_is_primitive_owned() {
647        assert!(can_dispatch_edge_buffers_without_padding(3, 3, 3));
648        assert!(!can_dispatch_edge_buffers_without_padding(0, 0, 1));
649        assert!(!can_dispatch_edge_buffers_without_padding(3, 2, 3));
650        assert!(!can_dispatch_edge_buffers_without_padding(2, 3, 3));
651    }
652
653    #[test]
654    fn static_input_key_tracks_graph_content_and_padded_edge_storage() {
655        let plan = plan_csr_bidirectional_step(
656            4,
657            &[0, 1, 2, 3, 3],
658            &[1, 2, 3],
659            &[1, 1, 1],
660            &[0b0010],
661            0x55AA_00FF,
662        )
663        .expect("Fix: valid bidirectional CSR step should produce dispatch plan");
664
665        let first = plan
666            .static_input_key(&[0, 1, 2, 3, 3], &[1, 2, 3], &[1, 1, 1])
667            .expect("Fix: matching static CSR slices should key");
668        let same = plan
669            .static_input_key(&[0, 1, 2, 3, 3], &[1, 2, 3], &[1, 1, 1])
670            .expect("Fix: matching static CSR slices should key");
671        let changed_targets = plan
672            .static_input_key(&[0, 1, 2, 3, 3], &[2, 3, 0], &[1, 1, 1])
673            .expect("Fix: same-shape target content should key");
674        let changed_kind = plan
675            .static_input_key(&[0, 1, 2, 3, 3], &[1, 2, 3], &[1, 2, 1])
676            .expect("Fix: same-shape kind content should key");
677
678        assert_eq!(first, same);
679        assert_ne!(first, changed_targets);
680        assert_ne!(first, changed_kind);
681        assert_eq!(first.program_key, plan.program_key());
682        assert_eq!(first.edge_offset_words, 5);
683        assert_eq!(first.edge_storage_words, 3);
684    }
685
686    #[test]
687    fn static_input_key_normalizes_empty_edges_to_padded_upload() {
688        let plan = plan_csr_bidirectional_step(1, &[0, 0], &[], &[], &[0], u32::MAX)
689            .expect("Fix: edgeless one-node graph should still have dispatch buffers");
690        let key = plan
691            .static_input_key(&[0, 0], &[], &[])
692            .expect("Fix: empty edge buffers should key through padded primitive storage");
693
694        assert_eq!(key.edge_offset_words, 2);
695        assert_eq!(key.edge_storage_words, 1);
696    }
697
698    #[test]
699    fn static_input_key_rejects_shape_drift() {
700        let plan = plan_csr_bidirectional_step(
701            4,
702            &[0, 1, 2, 3, 3],
703            &[1, 2, 3],
704            &[1, 1, 1],
705            &[0b0010],
706            u32::MAX,
707        )
708        .expect("Fix: valid bidirectional CSR step should produce dispatch plan");
709
710        let err = plan
711            .static_input_key(&[0, 1, 2, 3], &[1, 2, 3], &[1, 1, 1])
712            .unwrap_err();
713        assert!(err.contains("expected 5 offset word"));
714
715        let err = plan
716            .static_input_key(&[0, 1, 2, 3, 3], &[1, 2], &[1, 1, 1])
717            .unwrap_err();
718        assert!(err.contains("expected 3 edge target"));
719
720        let err = plan
721            .static_input_key(&[0, 1, 2, 3, 3], &[1, 2, 3], &[1, 1])
722            .unwrap_err();
723        assert!(err.contains("expected 3 edge kind"));
724    }
725
726    #[test]
727    fn closure_runner_stops_after_fixpoint_and_reuses_buffers() {
728        let plan = plan_csr_bidirectional_step(4, &[0, 0, 0, 0, 0], &[], &[], &[0b0001], u32::MAX)
729            .expect("Fix: valid empty-edge CSR plan should build");
730        let mut current = Vec::with_capacity(4);
731        let mut next = Vec::with_capacity(4);
732        let mut calls = 0usize;
733
734        run_csr_bidirectional_closure_plan_with_step(
735            &plan,
736            &[0b0001],
737            9,
738            &mut current,
739            &mut next,
740            |message| message,
741            |_frontier, out| {
742                calls += 1;
743                out.extend_from_slice(&[0]);
744                Ok(())
745            },
746        )
747        .expect("Fix: closure runner should accept matching frontier shapes");
748
749        assert_eq!(calls, 1);
750        assert_eq!(current, vec![0b0001]);
751        assert!(current.capacity() >= 4);
752        assert!(next.capacity() >= 4);
753    }
754
755    #[test]
756    fn closure_runner_rejects_seed_width_drift_without_clobbering_buffers() {
757        let plan = plan_csr_bidirectional_step(4, &[0, 0, 0, 0, 0], &[], &[], &[0], u32::MAX)
758            .expect("Fix: valid empty-edge CSR plan should build");
759        let mut current = vec![0xAA55_AA55];
760        let mut next = vec![0x55AA_55AA];
761
762        let err = run_csr_bidirectional_closure_plan_with_step(
763            &plan,
764            &[0, 1],
765            1,
766            &mut current,
767            &mut next,
768            |message| message,
769            |_frontier, _out| Ok(()),
770        )
771        .expect_err("seed width drift must be rejected before mutation");
772
773        assert!(err.contains("expected seed length"));
774        assert_eq!(current, vec![0xAA55_AA55]);
775        assert_eq!(next, vec![0x55AA_55AA]);
776    }
777}
778
779/// CPU reference: iterate bidirectional one-step reach to fixpoint or `max_iters`.
780///
781/// This computes the connected-neighborhood closure of `seed` under
782/// `allow_mask` using the same one-step oracle as [`cpu_ref`]. It lives in
783/// primitives so consumers do not fork fixpoint semantics.
784#[must_use]
785#[cfg(any(test, feature = "cpu-parity"))]
786pub fn cpu_ref_closure(
787    node_count: u32,
788    edge_offsets: &[u32],
789    edge_targets: &[u32],
790    edge_kind_mask: &[u32],
791    seed: &[u32],
792    allow_mask: u32,
793    max_iters: u32,
794) -> Vec<u32> {
795    try_cpu_ref_closure(
796        node_count,
797        edge_offsets,
798        edge_targets,
799        edge_kind_mask,
800        seed,
801        allow_mask,
802        max_iters,
803    )
804    .unwrap_or_else(|err| {
805        panic!("csr_bidirectional closure CPU oracle received malformed input. {err}")
806    })
807}
808
809/// Fallible CPU reference: bidirectional closure to fixpoint or `max_iters`.
810#[cfg(any(test, feature = "cpu-parity"))]
811pub fn try_cpu_ref_closure(
812    node_count: u32,
813    edge_offsets: &[u32],
814    edge_targets: &[u32],
815    edge_kind_mask: &[u32],
816    seed: &[u32],
817    allow_mask: u32,
818    max_iters: u32,
819) -> Result<Vec<u32>, String> {
820    let mut current = Vec::new();
821    let mut next = Vec::new();
822    try_cpu_ref_closure_into(
823        node_count,
824        edge_offsets,
825        edge_targets,
826        edge_kind_mask,
827        seed,
828        allow_mask,
829        max_iters,
830        &mut current,
831        &mut next,
832    )?;
833    Ok(current)
834}
835
836/// CPU reference: closure into caller-owned buffers.
837#[allow(clippy::too_many_arguments)]
838#[cfg(any(test, feature = "cpu-parity"))]
839pub fn cpu_ref_closure_into(
840    node_count: u32,
841    edge_offsets: &[u32],
842    edge_targets: &[u32],
843    edge_kind_mask: &[u32],
844    seed: &[u32],
845    allow_mask: u32,
846    max_iters: u32,
847    current: &mut Vec<u32>,
848    next: &mut Vec<u32>,
849) {
850    try_cpu_ref_closure_into(
851        node_count,
852        edge_offsets,
853        edge_targets,
854        edge_kind_mask,
855        seed,
856        allow_mask,
857        max_iters,
858        current,
859        next,
860    )
861    .unwrap_or_else(|err| {
862        panic!("csr_bidirectional closure CPU oracle received malformed input. {err}")
863    });
864}
865
866/// Fallible CPU reference: closure into caller-owned buffers.
867#[allow(clippy::too_many_arguments)]
868#[cfg(any(test, feature = "cpu-parity"))]
869pub fn try_cpu_ref_closure_into(
870    node_count: u32,
871    edge_offsets: &[u32],
872    edge_targets: &[u32],
873    edge_kind_mask: &[u32],
874    seed: &[u32],
875    allow_mask: u32,
876    max_iters: u32,
877    current: &mut Vec<u32>,
878    next: &mut Vec<u32>,
879) -> Result<(), String> {
880    try_cpu_ref_closure_into_with_step_hook(
881        node_count,
882        edge_offsets,
883        edge_targets,
884        edge_kind_mask,
885        seed,
886        allow_mask,
887        max_iters,
888        current,
889        next,
890        || {},
891    )
892}
893
894/// CPU reference: closure into caller-owned buffers with a per-step hook.
895///
896/// Consumers use `on_step` for telemetry only; closure semantics remain owned
897/// by this primitive module.
898#[allow(clippy::too_many_arguments)]
899#[cfg(any(test, feature = "cpu-parity"))]
900pub fn cpu_ref_closure_into_with_step_hook<F>(
901    node_count: u32,
902    edge_offsets: &[u32],
903    edge_targets: &[u32],
904    edge_kind_mask: &[u32],
905    seed: &[u32],
906    allow_mask: u32,
907    max_iters: u32,
908    current: &mut Vec<u32>,
909    next: &mut Vec<u32>,
910    mut on_step: F,
911) where
912    F: FnMut(),
913{
914    try_cpu_ref_closure_into_with_step_hook(
915        node_count,
916        edge_offsets,
917        edge_targets,
918        edge_kind_mask,
919        seed,
920        allow_mask,
921        max_iters,
922        current,
923        next,
924        &mut on_step,
925    )
926    .unwrap_or_else(|err| {
927        panic!("csr_bidirectional closure CPU oracle received malformed input. {err}")
928    });
929}
930
931/// Fallible CPU reference: closure into caller-owned buffers with a per-step hook.
932#[allow(clippy::too_many_arguments)]
933#[cfg(any(test, feature = "cpu-parity"))]
934pub fn try_cpu_ref_closure_into_with_step_hook<F>(
935    node_count: u32,
936    edge_offsets: &[u32],
937    edge_targets: &[u32],
938    edge_kind_mask: &[u32],
939    seed: &[u32],
940    allow_mask: u32,
941    max_iters: u32,
942    current: &mut Vec<u32>,
943    next: &mut Vec<u32>,
944    mut on_step: F,
945) -> Result<(), String>
946where
947    F: FnMut(),
948{
949    let plan = plan_csr_bidirectional_step(
950        node_count,
951        edge_offsets,
952        edge_targets,
953        edge_kind_mask,
954        seed,
955        allow_mask,
956    )?;
957    run_csr_bidirectional_closure_plan_with_step(
958        &plan,
959        seed,
960        max_iters,
961        current,
962        next,
963        |message| message,
964        |frontier, out| {
965            on_step();
966            cpu_ref_into_validated(
967                plan.layout,
968                edge_offsets,
969                edge_targets,
970                edge_kind_mask,
971                frontier,
972                allow_mask,
973                out,
974            )
975        },
976    )
977}
978
979/// Merge a bidirectional step frontier into the accumulated closure.
980///
981/// Returns `true` when at least one bit was newly set. This helper owns the
982/// fixpoint-merge semantics so dispatch consumers do not fork closure logic.
983///
984/// # Panics
985///
986/// Panics when the two frontier slices differ in length. That is a caller
987/// contract violation: both slices must be bitsets for the same `node_count`.
988#[must_use]
989pub fn merge_frontier_or_changed(current: &mut [u32], next: &[u32]) -> bool {
990    // Fail fast on a caller contract violation (mismatched bitset lengths).
991    // `unwrap_or(false)` would silently report "no change" for an unmergeable
992    // pair, hiding a fixpoint bug. Use `try_merge_frontier_or_changed` to handle
993    // it structurally.
994    try_merge_frontier_or_changed(current, next).unwrap_or_else(|error| panic!("{error}"))
995}
996
997/// Fallible variant of [`merge_frontier_or_changed`].
998pub fn try_merge_frontier_or_changed(current: &mut [u32], next: &[u32]) -> Result<bool, String> {
999    if current.len() != next.len() {
1000        return Err(format!(
1001            "Fix: bidirectional frontier merge requires equal bitset word counts, got current={} next={}.",
1002            current.len(),
1003            next.len()
1004        ));
1005    }
1006    let mut changed = false;
1007    for (dst, src) in current.iter_mut().zip(next.iter()) {
1008        let merged = *dst | *src;
1009        changed |= merged != *dst;
1010        *dst = merged;
1011    }
1012    Ok(changed)
1013}
1014
1015fn csr_bidir_u32_to_usize(value: u32, label: &'static str) -> Result<usize, String> {
1016    usize::try_from(value).map_err(|source| {
1017        format!("Fix: csr_bidirectional {label} value {value} cannot fit host usize: {source}.")
1018    })
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    fn linear_graph() -> (Vec<u32>, Vec<u32>, Vec<u32>) {
1026        // 0 -> 1 -> 2 -> 3
1027        (vec![0, 1, 2, 3, 3], vec![1, 2, 3], vec![1, 1, 1])
1028    }
1029
1030    #[test]
1031    fn forward_step_propagates() {
1032        let (off, tgt, msk) = linear_graph();
1033        let out = cpu_ref(4, &off, &tgt, &msk, &[0b0001], 0xFFFF_FFFF);
1034        // 0's forward neighbor = 1 → bit 1 set.
1035        assert!(out[0] & 0b0010 != 0);
1036    }
1037
1038    #[test]
1039    fn empty_seed_yields_empty_step() {
1040        let (off, tgt, msk) = linear_graph();
1041        let out = cpu_ref(4, &off, &tgt, &msk, &[0], 0xFFFF_FFFF);
1042        assert_eq!(out, vec![0]);
1043    }
1044
1045    #[test]
1046    fn allow_mask_zero_blocks_all() {
1047        let (off, tgt, msk) = linear_graph();
1048        let out = cpu_ref(4, &off, &tgt, &msk, &[0b0001], 0);
1049        assert_eq!(out, vec![0]);
1050    }
1051
1052    #[test]
1053    fn bidirectional_includes_both_directions() {
1054        let (off, tgt, msk) = linear_graph();
1055        // From {1}, forward reaches {2}; backward reaches {0}.
1056        let out = cpu_ref(4, &off, &tgt, &msk, &[0b0010], 0xFFFF_FFFF);
1057        assert!(out[0] & 0b0001 != 0, "bwd should reach node 0");
1058        assert!(out[0] & 0b0100 != 0, "fwd should reach node 2");
1059    }
1060
1061    #[test]
1062    fn closure_reaches_full_linear_component() {
1063        let (off, tgt, msk) = linear_graph();
1064        let out = cpu_ref_closure(4, &off, &tgt, &msk, &[0b0001], 0xFFFF_FFFF, 5);
1065        assert_eq!(out, vec![0b1111]);
1066    }
1067
1068    #[test]
1069    fn closure_into_reuses_caller_buffers() {
1070        let (off, tgt, msk) = linear_graph();
1071        let mut current = Vec::with_capacity(8);
1072        let mut next = Vec::with_capacity(8);
1073        cpu_ref_closure_into(
1074            4,
1075            &off,
1076            &tgt,
1077            &msk,
1078            &[0b0001],
1079            0xFFFF_FFFF,
1080            5,
1081            &mut current,
1082            &mut next,
1083        );
1084        assert_eq!(current, vec![0b1111]);
1085        assert_eq!(current.capacity(), 8);
1086        assert_eq!(next.capacity(), 8);
1087    }
1088
1089    #[test]
1090    fn merge_frontier_reports_change_and_or_merges_words() {
1091        let mut current = [0b0001u32, 0b1000];
1092        let next = [0b0110u32, 0b1000];
1093        assert!(merge_frontier_or_changed(&mut current, &next));
1094        assert_eq!(current, [0b0111, 0b1000]);
1095        assert!(!merge_frontier_or_changed(&mut current, &next));
1096    }
1097
1098    #[test]
1099    fn try_merge_frontier_rejects_mismatched_word_counts_without_panic() {
1100        let mut current = [0u32];
1101        let next = [1u32, 2];
1102        let err = try_merge_frontier_or_changed(&mut current, &next)
1103            .expect_err("mismatched frontier word counts must be a typed error");
1104        assert!(err.contains("equal bitset word counts"));
1105        assert_eq!(current, [0u32]);
1106    }
1107
1108    #[test]
1109    #[should_panic(
1110        expected = "Fix: bidirectional frontier merge requires equal bitset word counts"
1111    )]
1112    fn merge_frontier_rejects_mismatched_word_counts() {
1113        let mut current = [0u32];
1114        let next = [1u32, 2];
1115        let _ = merge_frontier_or_changed(&mut current, &next);
1116    }
1117
1118    #[test]
1119    fn validate_csr_inputs_accepts_empty_and_canonical_graphs() {
1120        assert_eq!(
1121            validate_csr_inputs(0, &[0], &[], &[], &[]).unwrap(),
1122            CsrBidirectionalLayout {
1123                node_count: 0,
1124                words: 0,
1125                node_words: 0,
1126                edge_count: 0,
1127                edge_storage_words: 1,
1128            }
1129        );
1130
1131        let (off, tgt, msk) = linear_graph();
1132        assert_eq!(
1133            validate_csr_inputs(4, &off, &tgt, &msk, &[0]).unwrap(),
1134            CsrBidirectionalLayout {
1135                node_count: 4,
1136                words: 1,
1137                node_words: 4,
1138                edge_count: 3,
1139                edge_storage_words: 3,
1140            }
1141        );
1142    }
1143
1144    #[test]
1145    fn validate_csr_inputs_rejects_frontier_and_csr_contract_violations() {
1146        let err = validate_csr_inputs(2, &[0, 1, 1], &[1], &[1], &[]).unwrap_err();
1147        assert!(err.contains("expected frontier length"));
1148
1149        let err = validate_csr_inputs(2, &[0, 1, 1], &[1], &[], &[0]).unwrap_err();
1150        assert!(err.contains("edge_targets.len() == edge_kind_mask.len()"));
1151
1152        let err = validate_csr_inputs(2, &[0, 2, 1], &[1], &[1], &[0]).unwrap_err();
1153        assert!(err.contains("offsets must be monotonic"));
1154
1155        let err = validate_csr_inputs(2, &[0, 1, 1], &[5], &[1], &[0]).unwrap_err();
1156        assert!(err.contains("outside node_count"));
1157    }
1158
1159    #[test]
1160    fn try_cpu_ref_into_rejects_bad_csr_without_clobbering_output() {
1161        let mut out = vec![0xCAFE_BABEu32];
1162        let capacity = out.capacity();
1163        let err = try_cpu_ref_into(2, &[0, 1, 1], &[1], &[], &[0], u32::MAX, &mut out)
1164            .expect_err("mismatched edge arrays must return an error");
1165        assert!(err.contains("edge_targets.len() == edge_kind_mask.len()"));
1166        assert_eq!(out, vec![0xCAFE_BABEu32]);
1167        assert_eq!(out.capacity(), capacity);
1168    }
1169
1170    #[test]
1171    fn try_cpu_ref_closure_rejects_bad_seed_without_clobbering_buffers() {
1172        let (off, tgt, msk) = linear_graph();
1173        let mut current = vec![0xCAFE_BABEu32];
1174        let mut next = vec![0xDEAD_BEEFu32];
1175        let current_capacity = current.capacity();
1176        let next_capacity = next.capacity();
1177        let err = try_cpu_ref_closure_into(
1178            4,
1179            &off,
1180            &tgt,
1181            &msk,
1182            &[],
1183            u32::MAX,
1184            4,
1185            &mut current,
1186            &mut next,
1187        )
1188        .expect_err("bad seed width must be rejected");
1189        assert!(err.contains("expected frontier length"));
1190        assert_eq!(current, vec![0xCAFE_BABEu32]);
1191        assert_eq!(next, vec![0xDEAD_BEEFu32]);
1192        assert_eq!(current.capacity(), current_capacity);
1193        assert_eq!(next.capacity(), next_capacity);
1194    }
1195
1196    #[test]
1197    fn fallible_cpu_reference_matches_compatibility_wrappers() {
1198        let (off, tgt, msk) = linear_graph();
1199        let step = try_cpu_ref(4, &off, &tgt, &msk, &[0b0010], u32::MAX)
1200            .expect("Fix: operation must return Err on failure; tests may use expect only with Fix: recovery text - valid step should succeed");
1201        assert_eq!(step, cpu_ref(4, &off, &tgt, &msk, &[0b0010], u32::MAX));
1202
1203        let closure = try_cpu_ref_closure(4, &off, &tgt, &msk, &[0b0001], u32::MAX, 5)
1204            .expect("Fix: operation must return Err on failure; tests may use expect only with Fix: recovery text - valid closure should succeed");
1205        assert_eq!(
1206            closure,
1207            cpu_ref_closure(4, &off, &tgt, &msk, &[0b0001], u32::MAX, 5)
1208        );
1209    }
1210
1211    #[test]
1212    fn cpu_ref_into_validates_before_resizing_output() {
1213        let mut out = vec![0xCAFE_BABEu32];
1214        let original_capacity = out.capacity();
1215
1216        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1217            cpu_ref_into(u32::MAX, &[0], &[], &[], &[], u32::MAX, &mut out);
1218        }));
1219
1220        assert!(result.is_err(), "malformed CSR must still be rejected");
1221        assert_eq!(
1222            out,
1223            vec![0xCAFE_BABEu32],
1224            "invalid input must not clear or resize caller output before validation"
1225        );
1226        assert_eq!(
1227            out.capacity(),
1228            original_capacity,
1229            "invalid input must not allocate based on hostile node_count"
1230        );
1231    }
1232}