Skip to main content

vyre_primitives/graph/
program_graph.rs

1//! Canonical ProgramGraph ABI  -  the 5-buffer CSR bundle every graph
2//! primitive in Tier 2.5 consumes.
3//!
4//! Downstream analyzers emit a `ProgramGraph` from their native ASTs. Every
5//! vyre graph primitive takes exactly this buffer shape so a new
6//! primitive is "here's the transfer body," not "redeclare the four
7//! buffers you want." One ABI makes the primitives composable  -
8//! `csr_forward_traverse` into `bitset_fixpoint` into `reduce_count`
9//! with no glue.
10//!
11//! # Wire shape
12//!
13//! ```text
14//! +----------------------------------------------------------+
15//! | nodes:            u32 buffer    (count = node_count)     |
16//! |                   each word = NodeKind tag               |
17//! | edge_offsets:     u32 buffer    (count = node_count+1)   |
18//! |                   edge_offsets[i]..edge_offsets[i+1]     |
19//! |                   is the range into edge_targets for     |
20//! |                   outgoing edges of node `i`             |
21//! | edge_targets:     u32 buffer    (count = edge_count)     |
22//! |                   each word = destination node index     |
23//! | edge_kind_mask:   u32 buffer    (count = edge_count)     |
24//! |                   each word = bitmask over EdgeKind      |
25//! | node_tags:        u32 buffer    (count = node_count)     |
26//! |                   each word = bitmask over TagFamily     |
27//! +----------------------------------------------------------+
28//! ```
29//!
30//! Edge-kind masks let a single `csr_forward_traverse` restrict to
31//! (say) just Assignment + CallArg edges by AND-ing against the
32//! per-edge mask. Node tags let `label_family_to_nodeset` emit a
33//! frontier bitset without touching the edges.
34//!
35//! # Invariants
36//!
37//! - `edge_offsets.len() == node_count + 1`
38//! - `edge_targets.len() == edge_count == edge_offsets[node_count]`
39//! - `edge_kind_mask.len() == edge_count`
40//! - `node_tags.len() == node_count`
41//! - `nodes.len() == node_count`
42//! - Every `edge_targets[i]` must satisfy `< node_count` or the
43//!   primitive raises `Node::Trap`.
44//!
45//! These invariants are checked by `validate_program_graph` at
46//! registration / dispatch time and by the frozen wire format in
47//! `vyre-spec`.
48
49use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType};
50
51/// Binding index for the node-kind array.
52pub const BINDING_NODES: u32 = 0;
53/// Binding index for the CSR row-pointer array.
54pub const BINDING_EDGE_OFFSETS: u32 = 1;
55/// Binding index for the CSR column array.
56pub const BINDING_EDGE_TARGETS: u32 = 2;
57/// Binding index for the per-edge kind mask.
58pub const BINDING_EDGE_KIND_MASK: u32 = 3;
59/// Binding index for the per-node tag mask.
60pub const BINDING_NODE_TAGS: u32 = 4;
61
62/// First binding index a primitive is free to use for primitive-
63/// specific buffers (frontier bitsets, output arrays, scratch).
64pub const BINDING_PRIMITIVE_START: u32 = 5;
65
66/// Canonical buffer name constants  -  primitives refer to these so
67/// every graph-consuming Program shares a single ABI symbol set.
68/// Downstream analysis paths emit CSR blobs under the same names.
69pub const NAME_NODES: &str = "pg_nodes";
70/// Canonical name for `edge_offsets`.
71pub const NAME_EDGE_OFFSETS: &str = "pg_edge_offsets";
72/// Canonical name for `edge_targets`.
73pub const NAME_EDGE_TARGETS: &str = "pg_edge_targets";
74/// Canonical name for `edge_kind_mask`.
75pub const NAME_EDGE_KIND_MASK: &str = "pg_edge_kind_mask";
76/// Canonical name for `node_tags`.
77pub const NAME_NODE_TAGS: &str = "pg_node_tags";
78
79/// Statically-sized CSR dimensions baked into a primitive's
80/// [`BufferDecl`] counts so the backend can allocate + layout-validate
81/// up front.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub struct ProgramGraphShape {
84    /// Total node count.
85    pub node_count: u32,
86    /// Total edge count.
87    pub edge_count: u32,
88}
89
90impl ProgramGraphShape {
91    /// Build a shape from a node + edge count.
92    #[must_use]
93    pub fn new(node_count: u32, edge_count: u32) -> Self {
94        Self {
95            node_count,
96            edge_count,
97        }
98    }
99
100    /// Emit the five canonical [`BufferDecl`] entries for a primitive
101    /// that consumes a read-only ProgramGraph. Primitives add their
102    /// own RW output buffers starting at [`BINDING_PRIMITIVE_START`].
103    #[must_use]
104    pub fn read_only_buffers(&self) -> Vec<BufferDecl> {
105        // Fail fast: an overflowing graph shape must NOT silently degrade to an
106        // empty buffer set (`unwrap_or_default`), that would hand the GPU
107        // dispatch a degenerate, mis-sized ABI with no signal. Callers needing
108        // to handle oversized graphs use `try_read_only_buffers`.
109        self.try_read_only_buffers()
110            .unwrap_or_else(|error| panic!("{error}"))
111    }
112
113    /// Emit the canonical read-only ProgramGraph bindings with checked
114    /// offset-buffer sizing.
115    pub fn try_read_only_buffers(&self) -> Result<Vec<BufferDecl>, String> {
116        let edge_offset_count = self.node_count.checked_add(1).ok_or_else(|| {
117            format!(
118                "ProgramGraphShape node_count={} overflows edge-offset buffer count. Fix: shard the graph before GPU dispatch.",
119                self.node_count
120            )
121        })?;
122        Ok(read_only_buffers_with_counts(
123            self.node_count,
124            edge_offset_count,
125            self.edge_count,
126            self.node_count,
127        ))
128    }
129}
130
131fn read_only_buffers_with_counts(
132    node_count: u32,
133    edge_offset_count: u32,
134    edge_count: u32,
135    node_tag_count: u32,
136) -> Vec<BufferDecl> {
137    vec![
138        BufferDecl::storage(
139            NAME_NODES,
140            BINDING_NODES,
141            BufferAccess::ReadOnly,
142            DataType::U32,
143        )
144        .with_count(node_count),
145        BufferDecl::storage(
146            NAME_EDGE_OFFSETS,
147            BINDING_EDGE_OFFSETS,
148            BufferAccess::ReadOnly,
149            DataType::U32,
150        )
151        .with_count(edge_offset_count),
152        BufferDecl::storage(
153            NAME_EDGE_TARGETS,
154            BINDING_EDGE_TARGETS,
155            BufferAccess::ReadOnly,
156            DataType::U32,
157        )
158        .with_count(edge_count.max(1)),
159        BufferDecl::storage(
160            NAME_EDGE_KIND_MASK,
161            BINDING_EDGE_KIND_MASK,
162            BufferAccess::ReadOnly,
163            DataType::U32,
164        )
165        .with_count(edge_count.max(1)),
166        BufferDecl::storage(
167            NAME_NODE_TAGS,
168            BINDING_NODE_TAGS,
169            BufferAccess::ReadOnly,
170            DataType::U32,
171        )
172        .with_count(node_tag_count),
173    ]
174}
175
176/// Error kinds surfaced by [`validate_program_graph`].
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178pub enum GraphValidationError {
179    /// `edge_offsets` length != `node_count + 1`.
180    EdgeOffsetsLen {
181        /// Expected length.
182        expected: usize,
183        /// Actual length.
184        got: usize,
185    },
186    /// `edge_targets` length != `edge_count`.
187    EdgeTargetsLen {
188        /// Expected length.
189        expected: usize,
190        /// Actual length.
191        got: usize,
192    },
193    /// `edge_kind_mask` length != `edge_count`.
194    EdgeKindMaskLen {
195        /// Expected length.
196        expected: usize,
197        /// Actual length.
198        got: usize,
199    },
200    /// `node_tags` length != `node_count`.
201    NodeTagsLen {
202        /// Expected length.
203        expected: usize,
204        /// Actual length.
205        got: usize,
206    },
207    /// `nodes` length != `node_count`.
208    NodesLen {
209        /// Expected length.
210        expected: usize,
211        /// Actual length.
212        got: usize,
213    },
214    /// `edge_targets[i]` >= `node_count`.
215    EdgeOutOfRange {
216        /// Index into `edge_targets`.
217        index: usize,
218        /// Observed destination.
219        target: u32,
220        /// Total node count.
221        node_count: u32,
222    },
223    /// Offsets not monotonically non-decreasing.
224    NonMonotonicOffsets {
225        /// Index at which the violation was first detected.
226        index: usize,
227    },
228    /// Final CSR offset does not match the declared edge count.
229    EdgeCountMismatch {
230        /// Declared edge count from the shape.
231        expected: usize,
232        /// Final offset stored in `edge_offsets[node_count]`.
233        got: usize,
234    },
235}
236
237/// Validate an in-memory `ProgramGraph` against the wire invariants.
238///
239/// Called by conformance harnesses on synthetic fixtures and by downstream graph pipelines
240/// on freshly-emitted graphs before dispatch. The backend dispatcher
241/// rejects any graph whose CSR breaks these invariants.
242pub fn validate_program_graph(
243    shape: ProgramGraphShape,
244    nodes: &[u32],
245    edge_offsets: &[u32],
246    edge_targets: &[u32],
247    edge_kind_mask: &[u32],
248    node_tags: &[u32],
249) -> Result<(), GraphValidationError> {
250    let n = shape.node_count as usize;
251    let e = shape.edge_count as usize;
252    if nodes.len() != n {
253        return Err(GraphValidationError::NodesLen {
254            expected: n,
255            got: nodes.len(),
256        });
257    }
258    if edge_offsets.len() != n + 1 {
259        return Err(GraphValidationError::EdgeOffsetsLen {
260            expected: n + 1,
261            got: edge_offsets.len(),
262        });
263    }
264    // edge_targets / edge_kind_mask are validated at the GPU-buffer shape:
265    // `max(edge_count, 1)`. A zero-edge graph still carries a single placeholder
266    // entry because `read_only_buffers_with_counts` emits `count = edge_count.max(1)`
267    // (GPU minimum buffer size), and validation runs on those padded buffers. This
268    // padding tolerance is the INTENTIONAL contract, the zero_edge/mask/length
269    // adversarial conformance tests assert exactly len == max(edge_count, 1) and
270    // reject an empty slice for a zero-edge graph. (A cycle-3 swarm agent briefly
271    // changed this to `== edge_count` citing the module-doc wording; that inverted
272    // the tested contract and the real padded-buffer flow, so it was reverted.)
273    let expected_edge_len = e.max(1);
274    if edge_targets.len() != expected_edge_len {
275        return Err(GraphValidationError::EdgeTargetsLen {
276            expected: expected_edge_len,
277            got: edge_targets.len(),
278        });
279    }
280    if edge_kind_mask.len() != expected_edge_len {
281        return Err(GraphValidationError::EdgeKindMaskLen {
282            expected: expected_edge_len,
283            got: edge_kind_mask.len(),
284        });
285    }
286    if node_tags.len() != n {
287        return Err(GraphValidationError::NodeTagsLen {
288            expected: n,
289            got: node_tags.len(),
290        });
291    }
292    if let Some(&first) = edge_offsets.first() {
293        if first != 0 {
294            return Err(GraphValidationError::NonMonotonicOffsets { index: 0 });
295        }
296    }
297    for window in edge_offsets.windows(2).enumerate() {
298        let (index, pair) = window;
299        if pair[1] < pair[0] {
300            return Err(GraphValidationError::NonMonotonicOffsets { index });
301        }
302    }
303    let final_offset = edge_offsets.last().copied().unwrap_or_default() as usize;
304    if final_offset != e {
305        return Err(GraphValidationError::EdgeCountMismatch {
306            expected: e,
307            got: final_offset,
308        });
309    }
310    for (index, &target) in edge_targets.iter().take(e).enumerate() {
311        if target >= shape.node_count {
312            return Err(GraphValidationError::EdgeOutOfRange {
313                index,
314                target,
315                node_count: shape.node_count,
316            });
317        }
318    }
319    Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn read_only_buffers_has_canonical_layout() {
328        let bufs = ProgramGraphShape::new(4, 6).read_only_buffers();
329        assert_eq!(bufs.len(), 5);
330        assert_eq!(bufs[0].name(), NAME_NODES);
331        assert_eq!(bufs[1].name(), NAME_EDGE_OFFSETS);
332        assert_eq!(bufs[2].name(), NAME_EDGE_TARGETS);
333        assert_eq!(bufs[3].name(), NAME_EDGE_KIND_MASK);
334        assert_eq!(bufs[4].name(), NAME_NODE_TAGS);
335        assert_eq!(bufs[1].count(), 5); // node_count + 1
336        assert_eq!(bufs[2].count(), 6); // edge_count
337    }
338
339    #[test]
340    fn checked_read_only_buffers_rejects_edge_offset_overflow() {
341        let error = ProgramGraphShape::new(u32::MAX, 0)
342            .try_read_only_buffers()
343            .expect_err("checked ProgramGraphShape buffers must reject offset overflow");
344
345        assert!(
346            error.contains("overflows edge-offset buffer count"),
347            "error should describe the graph shape overflow: {error}"
348        );
349    }
350
351    #[test]
352    fn legacy_read_only_buffers_fail_fast_on_edge_offset_overflow() {
353        let panic = std::panic::catch_unwind(|| {
354            let _ = ProgramGraphShape::new(u32::MAX, 0).read_only_buffers();
355        })
356        .expect_err("legacy read_only_buffers must fail fast on edge-offset overflow");
357
358        let message = panic_payload_message(panic);
359        assert!(
360            message.contains("overflows edge-offset buffer count"),
361            "error should describe the graph shape overflow: {message}"
362        );
363    }
364
365    fn panic_payload_message(payload: Box<dyn std::any::Any + Send>) -> String {
366        if let Some(message) = payload.downcast_ref::<&str>() {
367            message.to_string()
368        } else if let Some(message) = payload.downcast_ref::<String>() {
369            message.clone()
370        } else {
371            format!("{payload:?}")
372        }
373    }
374
375    #[test]
376    fn program_graph_shape_source_has_checked_buffers_without_panics() {
377        let source = include_str!("program_graph.rs");
378        let production = source
379            .split("/// Error kinds surfaced")
380            .next()
381            .expect("Fix: ProgramGraphShape source must precede validation errors");
382
383        assert!(
384            production.contains("pub fn try_read_only_buffers(")
385                && !production.contains("inert_")
386                && !production.contains("Err(_) =>"),
387            "Fix: ProgramGraphShape buffer ABI must expose checked sizing and must not emit inert placeholder buffers."
388        );
389    }
390
391    #[test]
392    fn validate_rejects_oob_edge_target() {
393        // 3 nodes, 2 edges; one edge points at node 5 (out of range).
394        let err = validate_program_graph(
395            ProgramGraphShape::new(3, 2),
396            &[0, 0, 0],
397            &[0, 1, 2, 2],
398            &[1, 5],
399            &[0, 0],
400            &[0, 0, 0],
401        )
402        .unwrap_err();
403        assert!(matches!(
404            err,
405            GraphValidationError::EdgeOutOfRange { target: 5, .. }
406        ));
407    }
408
409    #[test]
410    fn validate_rejects_non_monotonic_offsets() {
411        let err = validate_program_graph(
412            ProgramGraphShape::new(2, 1),
413            &[0, 0],
414            &[2, 1, 1], // 2 → 1 is a decrease
415            &[0],
416            &[0],
417            &[0, 0],
418        )
419        .unwrap_err();
420        assert!(matches!(
421            err,
422            GraphValidationError::NonMonotonicOffsets { .. }
423        ));
424    }
425
426    #[test]
427    fn validate_passes_canonical_small_graph() {
428        // 3 nodes, 2 edges: 0→1, 1→2
429        let ok = validate_program_graph(
430            ProgramGraphShape::new(3, 2),
431            &[0, 0, 0],
432            &[0, 1, 2, 2],
433            &[1, 2],
434            &[1, 1],
435            &[0, 0, 0],
436        );
437        assert_eq!(ok, Ok(()));
438    }
439
440    /// A zero-edge graph carries a single placeholder edge entry: validation
441    /// runs on the GPU-padded buffers (`read_only_buffers_with_counts` emits
442    /// `count = edge_count.max(1)`), so `edge_targets`/`edge_kind_mask` must be
443    /// length `max(edge_count, 1) == 1`, NOT empty. This is the same contract the
444    /// `zero_edge_contracts` / `mask_contracts` adversarial conformance tests pin.
445    #[test]
446    fn validate_zero_edge_graph_requires_placeholder_length_one() {
447        // 2 nodes, 0 edges: the single placeholder entry is mandatory.
448        let ok = validate_program_graph(
449            ProgramGraphShape::new(2, 0),
450            &[0, 0],
451            &[0, 0, 0],
452            &[0],
453            &[0],
454            &[0, 0],
455        );
456        assert_eq!(
457            ok,
458            Ok(()),
459            "zero-edge graph must validate with a length-1 placeholder"
460        );
461
462        // Empty edge slices are rejected (they violate the max(1) GPU-buffer shape).
463        let err = validate_program_graph(
464            ProgramGraphShape::new(2, 0),
465            &[0, 0],
466            &[0, 0, 0],
467            &[],
468            &[],
469            &[0, 0],
470        )
471        .unwrap_err();
472        assert!(
473            matches!(err, GraphValidationError::EdgeTargetsLen { expected: 1, got: 0 }),
474            "zero-edge graph with empty edge_targets must fail EdgeTargetsLen {{expected:1, got:0}}, got {err:?}"
475        );
476    }
477}