Skip to main content

vyre_primitives/graph/persistent_bfs/
cpu_ref.rs

1#[cfg(any(test, feature = "cpu-parity"))]
2use super::validate::validate_persistent_bfs_inputs;
3
4/// Convergence outcome of one persistent-BFS CPU reference run.
5///
6/// The `changed` flag alone (`1` if any step added new nodes) cannot tell a
7/// caller whether the fixpoint was actually reached or the loop merely ran
8/// out of `max_iters` while still growing. This struct separates the two so a
9/// consumer can enforce a loud non-convergence policy: a run that exhausts
10/// `max_iters` while still adding nodes returns `converged = false` and an
11/// under-approximated frontier, which the caller can reject instead of
12/// silently trusting a partial closure.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[cfg(any(test, feature = "cpu-parity"))]
15pub struct PersistentBfsConvergence {
16    /// Sticky flag: `1` if any step added new nodes, else `0`.
17    pub changed: u32,
18    /// `true` if a step added nothing (the fixpoint was reached) before
19    /// `max_iters` was exhausted; `false` if the loop ran all `max_iters`
20    /// steps while still adding nodes, in which case the frontier is an
21    /// under-approximation of the true closure.
22    pub converged: bool,
23    /// Number of traversal steps actually run: the step at which the loop
24    /// stopped. Equals `max_iters` exactly when `converged` is `false`.
25    pub stop_iter: u32,
26}
27
28/// CPU reference: run BFS up to `max_iters` steps, accumulating into a
29/// running bitset.  Returns the final frontier and a sticky `changed`
30/// flag (`1` if any step added new nodes, else `0`).
31#[must_use]
32#[cfg(any(test, feature = "cpu-parity"))]
33pub fn cpu_ref(
34    node_count: u32,
35    edge_offsets: &[u32],
36    edge_targets: &[u32],
37    edge_kind_mask: &[u32],
38    frontier_in: &[u32],
39    allow_mask: u32,
40    max_iters: u32,
41) -> (Vec<u32>, u32) {
42    try_cpu_ref(
43        node_count,
44        edge_offsets,
45        edge_targets,
46        edge_kind_mask,
47        frontier_in,
48        allow_mask,
49        max_iters,
50    )
51    .expect(
52        "Fix: reject malformed CSR/frontier via try_cpu_ref; parity wrappers must not pass hostile layouts",
53    )
54}
55
56/// Fallible CPU reference for persistent BFS.
57///
58/// This is the primitive-owned entry point for parity wrappers that must reject
59/// hostile CSR/frontier inputs without panicking.
60#[cfg(any(test, feature = "cpu-parity"))]
61pub fn try_cpu_ref(
62    node_count: u32,
63    edge_offsets: &[u32],
64    edge_targets: &[u32],
65    edge_kind_mask: &[u32],
66    frontier_in: &[u32],
67    allow_mask: u32,
68    max_iters: u32,
69) -> Result<(Vec<u32>, u32), String> {
70    let mut out = Vec::new();
71    let changed = try_cpu_ref_into(
72        node_count,
73        edge_offsets,
74        edge_targets,
75        edge_kind_mask,
76        frontier_in,
77        allow_mask,
78        max_iters,
79        &mut out,
80    )?;
81    Ok((out, changed))
82}
83
84/// Caller-owned workspace for repeated persistent-BFS CPU oracle runs.
85///
86/// Conformance and CUDA parity sweeps call this oracle across large generated
87/// graph corpora. Reusing the per-iteration frontier scratch avoids a heap
88/// allocation per proof case while preserving the allocating compatibility API.
89#[cfg(any(test, feature = "cpu-parity"))]
90#[derive(Debug, Default, Clone)]
91pub(crate) struct PersistentBfsCpuScratch {
92    /// Temporary frontier produced by one CSR expansion step.
93    pub step: Vec<u32>,
94}
95
96#[cfg(any(test, feature = "cpu-parity"))]
97impl PersistentBfsCpuScratch {
98    /// Create an empty reusable persistent-BFS workspace.
99    pub(crate) fn new() -> Self {
100        Self::default()
101    }
102}
103
104/// CPU reference into caller-owned output storage.
105///
106/// Runs BFS up to `max_iters` steps, accumulating into `frontier_out`. Returns
107/// a sticky changed flag (`1` if any step added new nodes, else `0`).
108#[cfg(any(test, feature = "cpu-parity"))]
109pub(crate) fn cpu_ref_into(
110    node_count: u32,
111    edge_offsets: &[u32],
112    edge_targets: &[u32],
113    edge_kind_mask: &[u32],
114    frontier_in: &[u32],
115    allow_mask: u32,
116    max_iters: u32,
117    frontier_out: &mut Vec<u32>,
118) -> u32 {
119    let mut scratch = PersistentBfsCpuScratch::default();
120    try_cpu_ref_into_with_scratch(
121        node_count,
122        edge_offsets,
123        edge_targets,
124        edge_kind_mask,
125        frontier_in,
126        allow_mask,
127        max_iters,
128        frontier_out,
129        &mut scratch,
130    )
131    .expect(
132        "Fix: reject malformed CSR/frontier via try_cpu_ref_into; parity wrappers must not pass hostile layouts",
133    )
134}
135
136/// Fallible CPU reference into caller-owned output storage.
137///
138/// On error, `frontier_out` is left unchanged. This lets integration tests and
139/// dispatch wrappers treat malformed graph/frontier data as a typed finding
140/// instead of a panic or partially clobbered oracle output.
141#[cfg(any(test, feature = "cpu-parity"))]
142pub fn try_cpu_ref_into(
143    node_count: u32,
144    edge_offsets: &[u32],
145    edge_targets: &[u32],
146    edge_kind_mask: &[u32],
147    frontier_in: &[u32],
148    allow_mask: u32,
149    max_iters: u32,
150    frontier_out: &mut Vec<u32>,
151) -> Result<u32, String> {
152    let mut scratch = PersistentBfsCpuScratch::default();
153    try_cpu_ref_into_with_scratch(
154        node_count,
155        edge_offsets,
156        edge_targets,
157        edge_kind_mask,
158        frontier_in,
159        allow_mask,
160        max_iters,
161        frontier_out,
162        &mut scratch,
163    )
164}
165
166/// Fallible CPU reference into caller-owned output and scratch storage.
167///
168/// On validation error, `frontier_out` and `scratch` are left unchanged. This
169/// lets integration tests and dispatch wrappers treat malformed graph/frontier
170/// data as a typed finding instead of a panic or partially clobbered oracle
171/// state.
172#[cfg(any(test, feature = "cpu-parity"))]
173pub(crate) fn try_cpu_ref_into_with_scratch(
174    node_count: u32,
175    edge_offsets: &[u32],
176    edge_targets: &[u32],
177    edge_kind_mask: &[u32],
178    frontier_in: &[u32],
179    allow_mask: u32,
180    max_iters: u32,
181    frontier_out: &mut Vec<u32>,
182    scratch: &mut PersistentBfsCpuScratch,
183) -> Result<u32, String> {
184    Ok(try_cpu_ref_converged_into_with_scratch(
185        node_count,
186        edge_offsets,
187        edge_targets,
188        edge_kind_mask,
189        frontier_in,
190        allow_mask,
191        max_iters,
192        frontier_out,
193        scratch,
194        None,
195    )?
196    .changed)
197}
198
199/// Fallible CPU reference reporting convergence into caller-owned output and
200/// scratch storage.
201///
202/// This is the single owner of the persistent-BFS accumulation loop. The
203/// sticky-`changed`-only wrappers above delegate here and drop the extra
204/// convergence detail; callers that must distinguish a reached fixpoint from a
205/// `max_iters` exhaustion use this entry point directly.
206///
207/// `density_active`, when `Some`, is filled with exactly `max_iters` entries
208/// where entry `i` is the popcount of the frontier after traversal step `i`.
209/// Once the closure converges every later entry repeats the converged popcount,
210/// mirroring the device density buffer (whose loop keeps running the remaining
211/// budget over an unchanged frontier). This is the CPU source of truth for the
212/// device per-iteration density readback; it shares the one accumulation loop so
213/// the popcount trajectory cannot diverge from the convergence trajectory.
214///
215/// On validation error, `frontier_out` and `scratch` are left unchanged.
216#[cfg(any(test, feature = "cpu-parity"))]
217pub(crate) fn try_cpu_ref_converged_into_with_scratch(
218    node_count: u32,
219    edge_offsets: &[u32],
220    edge_targets: &[u32],
221    edge_kind_mask: &[u32],
222    frontier_in: &[u32],
223    allow_mask: u32,
224    max_iters: u32,
225    frontier_out: &mut Vec<u32>,
226    scratch: &mut PersistentBfsCpuScratch,
227    mut density_active: Option<&mut Vec<u32>>,
228) -> Result<PersistentBfsConvergence, String> {
229    let layout = validate_persistent_bfs_inputs(
230        node_count,
231        edge_offsets,
232        edge_targets,
233        edge_kind_mask,
234        frontier_in,
235    )?;
236    let words = layout.words;
237    crate::graph::scratch::reserve_graph_items(
238        frontier_out,
239        words,
240        "persistent BFS CPU oracle",
241        "frontier output",
242    )?;
243    crate::graph::scratch::reserve_graph_items(
244        &mut scratch.step,
245        words,
246        "persistent BFS CPU oracle",
247        "per-iteration frontier scratch",
248    )?;
249    frontier_out.clear();
250    frontier_out.extend_from_slice(frontier_in);
251    frontier_out.resize(words, 0);
252    scratch.step.clear();
253    scratch.step.resize(words, 0);
254    if let Some(density) = density_active.as_deref_mut() {
255        density.clear();
256        density.reserve(max_iters as usize);
257    }
258    let mut changed = 0u32;
259    let mut converged = false;
260    let mut stop_iter = 0u32;
261
262    for iter in 0..max_iters {
263        crate::graph::csr_forward_traverse::cpu_ref_into(
264            node_count,
265            edge_offsets,
266            edge_targets,
267            edge_kind_mask,
268            frontier_out,
269            allow_mask,
270            &mut scratch.step,
271        );
272        stop_iter = iter + 1;
273        let mut step_changed = false;
274        for w in 0..words {
275            let old = frontier_out[w];
276            frontier_out[w] |= scratch.step[w];
277            if frontier_out[w] != old {
278                step_changed = true;
279            }
280        }
281        if let Some(density) = density_active.as_deref_mut() {
282            density.push(frontier_out.iter().map(|w| w.count_ones()).sum());
283        }
284        if step_changed {
285            changed = 1;
286        } else {
287            converged = true;
288            break;
289        }
290    }
291    // Pad the density trajectory to the full budget: after convergence the device
292    // loop keeps running the remaining iterations over an unchanged frontier, so
293    // every entry past `stop_iter` repeats the converged popcount.
294    if let Some(density) = density_active {
295        let fill = density.last().copied().unwrap_or(0);
296        while (density.len() as u32) < max_iters {
297            density.push(fill);
298        }
299    }
300    Ok(PersistentBfsConvergence {
301        changed,
302        converged,
303        stop_iter,
304    })
305}
306
307/// Fallible CPU reference reporting convergence, allocating a fresh frontier.
308///
309/// Like [`try_cpu_ref`] but returns a [`PersistentBfsConvergence`] so a caller
310/// can reject an under-approximated closure (`converged == false`) loudly
311/// instead of silently trusting a frontier the loop never drove to a fixpoint.
312/// This is the CPU source of truth for the device converged readback.
313#[cfg(any(test, feature = "cpu-parity"))]
314pub fn try_cpu_ref_converged(
315    node_count: u32,
316    edge_offsets: &[u32],
317    edge_targets: &[u32],
318    edge_kind_mask: &[u32],
319    frontier_in: &[u32],
320    allow_mask: u32,
321    max_iters: u32,
322) -> Result<(Vec<u32>, PersistentBfsConvergence), String> {
323    let mut out = Vec::new();
324    let mut scratch = PersistentBfsCpuScratch::default();
325    let outcome = try_cpu_ref_converged_into_with_scratch(
326        node_count,
327        edge_offsets,
328        edge_targets,
329        edge_kind_mask,
330        frontier_in,
331        allow_mask,
332        max_iters,
333        &mut out,
334        &mut scratch,
335        None,
336    )?;
337    Ok((out, outcome))
338}
339
340/// Fallible CPU reference reporting the per-iteration frontier-density
341/// trajectory, allocating a fresh frontier and density array.
342///
343/// Returns the converged frontier, the [`PersistentBfsConvergence`] outcome, and
344/// a `max_iters`-length `active` array where `active[i]` is the popcount of the
345/// frontier after traversal step `i` (flat once the closure converges). This is
346/// the CPU source of truth for the device density readback emitted by
347/// [`super::program::persistent_bfs_with_density`]: a host caller reconstructs
348/// every per-iteration frontier-density aggregate from `active` plus the seed
349/// popcount without a per-step device round-trip, and this oracle proves the
350/// device trajectory matches the reference bit-for-bit.
351#[cfg(any(test, feature = "cpu-parity"))]
352pub fn try_cpu_ref_density(
353    node_count: u32,
354    edge_offsets: &[u32],
355    edge_targets: &[u32],
356    edge_kind_mask: &[u32],
357    frontier_in: &[u32],
358    allow_mask: u32,
359    max_iters: u32,
360) -> Result<(Vec<u32>, PersistentBfsConvergence, Vec<u32>), String> {
361    let mut out = Vec::new();
362    let mut active = Vec::new();
363    let mut scratch = PersistentBfsCpuScratch::default();
364    let outcome = try_cpu_ref_converged_into_with_scratch(
365        node_count,
366        edge_offsets,
367        edge_targets,
368        edge_kind_mask,
369        frontier_in,
370        allow_mask,
371        max_iters,
372        &mut out,
373        &mut scratch,
374        Some(&mut active),
375    )?;
376    Ok((out, outcome, active))
377}