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    ///
104    /// # Panics
105    /// Panics when the graph shape cannot be expressed as buffer declarations. Callers
106    /// that must recover use the checked twin below.
107    #[must_use]
108    pub fn read_only_buffers(&self) -> Vec<BufferDecl> {
109        // Fail fast: an overflowing graph shape must NOT silently degrade to an
110        // empty buffer set (`unwrap_or_default`), that would hand the GPU
111        // dispatch a degenerate, mis-sized ABI with no signal. Callers needing
112        // to handle oversized graphs use `try_read_only_buffers`.
113        self.try_read_only_buffers()
114            .unwrap_or_else(|error| panic!("{error}"))
115    }
116
117    /// Emit the canonical read-only ProgramGraph bindings with checked
118    /// offset-buffer sizing.
119    pub fn try_read_only_buffers(&self) -> Result<Vec<BufferDecl>, String> {
120        let edge_offset_count = self.node_count.checked_add(1).ok_or_else(|| {
121            format!(
122                "ProgramGraphShape node_count={} overflows edge-offset buffer count. Fix: shard the graph before GPU dispatch.",
123                self.node_count
124            )
125        })?;
126        Ok(read_only_buffers_with_counts(
127            self.node_count,
128            edge_offset_count,
129            self.edge_count,
130            self.node_count,
131        ))
132    }
133}
134
135fn read_only_buffers_with_counts(
136    node_count: u32,
137    edge_offset_count: u32,
138    edge_count: u32,
139    node_tag_count: u32,
140) -> Vec<BufferDecl> {
141    vec![
142        BufferDecl::storage(
143            NAME_NODES,
144            BINDING_NODES,
145            BufferAccess::ReadOnly,
146            DataType::U32,
147        )
148        .with_count(node_count),
149        BufferDecl::storage(
150            NAME_EDGE_OFFSETS,
151            BINDING_EDGE_OFFSETS,
152            BufferAccess::ReadOnly,
153            DataType::U32,
154        )
155        .with_count(edge_offset_count),
156        BufferDecl::storage(
157            NAME_EDGE_TARGETS,
158            BINDING_EDGE_TARGETS,
159            BufferAccess::ReadOnly,
160            DataType::U32,
161        )
162        .with_count(edge_count.max(1)),
163        BufferDecl::storage(
164            NAME_EDGE_KIND_MASK,
165            BINDING_EDGE_KIND_MASK,
166            BufferAccess::ReadOnly,
167            DataType::U32,
168        )
169        .with_count(edge_count.max(1)),
170        BufferDecl::storage(
171            NAME_NODE_TAGS,
172            BINDING_NODE_TAGS,
173            BufferAccess::ReadOnly,
174            DataType::U32,
175        )
176        .with_count(node_tag_count),
177    ]
178}
179
180/// Error kinds surfaced by [`validate_program_graph`].
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum GraphValidationError {
183    /// `edge_offsets` length != `node_count + 1`.
184    EdgeOffsetsLen {
185        /// Expected length.
186        expected: usize,
187        /// Actual length.
188        got: usize,
189    },
190    /// `edge_targets` length != `edge_count`.
191    EdgeTargetsLen {
192        /// Expected length.
193        expected: usize,
194        /// Actual length.
195        got: usize,
196    },
197    /// `edge_kind_mask` length != `edge_count`.
198    EdgeKindMaskLen {
199        /// Expected length.
200        expected: usize,
201        /// Actual length.
202        got: usize,
203    },
204    /// `node_tags` length != `node_count`.
205    NodeTagsLen {
206        /// Expected length.
207        expected: usize,
208        /// Actual length.
209        got: usize,
210    },
211    /// `nodes` length != `node_count`.
212    NodesLen {
213        /// Expected length.
214        expected: usize,
215        /// Actual length.
216        got: usize,
217    },
218    /// `edge_targets[i]` >= `node_count`.
219    EdgeOutOfRange {
220        /// Index into `edge_targets`.
221        index: usize,
222        /// Observed destination.
223        target: u32,
224        /// Total node count.
225        node_count: u32,
226    },
227    /// Offsets not monotonically non-decreasing.
228    NonMonotonicOffsets {
229        /// Index at which the violation was first detected.
230        index: usize,
231    },
232    /// Final CSR offset does not match the declared edge count.
233    EdgeCountMismatch {
234        /// Declared edge count from the shape.
235        expected: usize,
236        /// Final offset stored in `edge_offsets[node_count]`.
237        got: usize,
238    },
239}
240
241/// Validate an in-memory `ProgramGraph` against the wire invariants.
242///
243/// Called by conformance harnesses on synthetic fixtures and by downstream graph pipelines
244/// on freshly-emitted graphs before dispatch. The backend dispatcher
245/// rejects any graph whose CSR breaks these invariants.
246pub fn validate_program_graph(
247    shape: ProgramGraphShape,
248    nodes: &[u32],
249    edge_offsets: &[u32],
250    edge_targets: &[u32],
251    edge_kind_mask: &[u32],
252    node_tags: &[u32],
253) -> Result<(), GraphValidationError> {
254    let n = shape.node_count as usize;
255    let e = shape.edge_count as usize;
256    if nodes.len() != n {
257        return Err(GraphValidationError::NodesLen {
258            expected: n,
259            got: nodes.len(),
260        });
261    }
262    if edge_offsets.len() != n + 1 {
263        return Err(GraphValidationError::EdgeOffsetsLen {
264            expected: n + 1,
265            got: edge_offsets.len(),
266        });
267    }
268    // edge_targets / edge_kind_mask are validated at the GPU-buffer shape:
269    // `max(edge_count, 1)`. A zero-edge graph still carries a single placeholder
270    // entry because `read_only_buffers_with_counts` emits `count = edge_count.max(1)`
271    // (GPU minimum buffer size), and validation runs on those padded buffers. This
272    // padding tolerance is the INTENTIONAL contract, the zero_edge/mask/length
273    // adversarial conformance tests assert exactly len == max(edge_count, 1) and
274    // reject an empty slice for a zero-edge graph. (A cycle-3 swarm agent briefly
275    // changed this to `== edge_count` citing the module-doc wording; that inverted
276    // the tested contract and the real padded-buffer flow, so it was reverted.)
277    let expected_edge_len = e.max(1);
278    if edge_targets.len() != expected_edge_len {
279        return Err(GraphValidationError::EdgeTargetsLen {
280            expected: expected_edge_len,
281            got: edge_targets.len(),
282        });
283    }
284    if edge_kind_mask.len() != expected_edge_len {
285        return Err(GraphValidationError::EdgeKindMaskLen {
286            expected: expected_edge_len,
287            got: edge_kind_mask.len(),
288        });
289    }
290    if node_tags.len() != n {
291        return Err(GraphValidationError::NodeTagsLen {
292            expected: n,
293            got: node_tags.len(),
294        });
295    }
296    if let Some(&first) = edge_offsets.first() {
297        if first != 0 {
298            return Err(GraphValidationError::NonMonotonicOffsets { index: 0 });
299        }
300    }
301    for window in edge_offsets.windows(2).enumerate() {
302        let (index, pair) = window;
303        if pair[1] < pair[0] {
304            return Err(GraphValidationError::NonMonotonicOffsets { index });
305        }
306    }
307    let final_offset = edge_offsets.last().copied().unwrap_or_default() as usize;
308    if final_offset != e {
309        return Err(GraphValidationError::EdgeCountMismatch {
310            expected: e,
311            got: final_offset,
312        });
313    }
314    for (index, &target) in edge_targets.iter().take(e).enumerate() {
315        if target >= shape.node_count {
316            return Err(GraphValidationError::EdgeOutOfRange {
317                index,
318                target,
319                node_count: shape.node_count,
320            });
321        }
322    }
323    Ok(())
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn read_only_buffers_has_canonical_layout() {
332        let bufs = ProgramGraphShape::new(4, 6).read_only_buffers();
333        assert_eq!(bufs.len(), 5);
334        assert_eq!(bufs[0].name(), NAME_NODES);
335        assert_eq!(bufs[1].name(), NAME_EDGE_OFFSETS);
336        assert_eq!(bufs[2].name(), NAME_EDGE_TARGETS);
337        assert_eq!(bufs[3].name(), NAME_EDGE_KIND_MASK);
338        assert_eq!(bufs[4].name(), NAME_NODE_TAGS);
339        assert_eq!(bufs[1].count(), 5); // node_count + 1
340        assert_eq!(bufs[2].count(), 6); // edge_count
341    }
342
343    #[test]
344    fn checked_read_only_buffers_rejects_edge_offset_overflow() {
345        let error = ProgramGraphShape::new(u32::MAX, 0)
346            .try_read_only_buffers()
347            .expect_err("checked ProgramGraphShape buffers must reject offset overflow");
348
349        assert!(
350            error.contains("overflows edge-offset buffer count"),
351            "error should describe the graph shape overflow: {error}"
352        );
353    }
354
355    #[test]
356    fn legacy_read_only_buffers_fail_fast_on_edge_offset_overflow() {
357        let panic = std::panic::catch_unwind(|| {
358            let _ = ProgramGraphShape::new(u32::MAX, 0).read_only_buffers();
359        })
360        .expect_err("legacy read_only_buffers must fail fast on edge-offset overflow");
361
362        let message = panic_payload_message(panic);
363        assert!(
364            message.contains("overflows edge-offset buffer count"),
365            "error should describe the graph shape overflow: {message}"
366        );
367    }
368
369    fn panic_payload_message(payload: Box<dyn std::any::Any + Send>) -> String {
370        if let Some(message) = payload.downcast_ref::<&str>() {
371            message.to_string()
372        } else if let Some(message) = payload.downcast_ref::<String>() {
373            message.clone()
374        } else {
375            format!("{payload:?}")
376        }
377    }
378
379    #[test]
380    fn validate_rejects_oob_edge_target() {
381        // 3 nodes, 2 edges; one edge points at node 5 (out of range).
382        let err = validate_program_graph(
383            ProgramGraphShape::new(3, 2),
384            &[0, 0, 0],
385            &[0, 1, 2, 2],
386            &[1, 5],
387            &[0, 0],
388            &[0, 0, 0],
389        )
390        .unwrap_err();
391        assert!(matches!(
392            err,
393            GraphValidationError::EdgeOutOfRange { target: 5, .. }
394        ));
395    }
396
397    #[test]
398    fn validate_rejects_non_monotonic_offsets() {
399        let err = validate_program_graph(
400            ProgramGraphShape::new(2, 1),
401            &[0, 0],
402            &[2, 1, 1], // 2 → 1 is a decrease
403            &[0],
404            &[0],
405            &[0, 0],
406        )
407        .unwrap_err();
408        assert!(matches!(
409            err,
410            GraphValidationError::NonMonotonicOffsets { .. }
411        ));
412    }
413
414    #[test]
415    fn validate_passes_canonical_small_graph() {
416        // 3 nodes, 2 edges: 0→1, 1→2
417        let ok = validate_program_graph(
418            ProgramGraphShape::new(3, 2),
419            &[0, 0, 0],
420            &[0, 1, 2, 2],
421            &[1, 2],
422            &[1, 1],
423            &[0, 0, 0],
424        );
425        assert_eq!(ok, Ok(()));
426    }
427
428    /// A zero-edge graph carries a single placeholder edge entry: validation
429    /// runs on the GPU-padded buffers (`read_only_buffers_with_counts` emits
430    /// `count = edge_count.max(1)`), so `edge_targets`/`edge_kind_mask` must be
431    /// length `max(edge_count, 1) == 1`, NOT empty. This is the same contract the
432    /// `zero_edge_contracts` / `mask_contracts` adversarial conformance tests pin.
433    #[test]
434    fn validate_zero_edge_graph_requires_placeholder_length_one() {
435        // 2 nodes, 0 edges: the single placeholder entry is mandatory.
436        let ok = validate_program_graph(
437            ProgramGraphShape::new(2, 0),
438            &[0, 0],
439            &[0, 0, 0],
440            &[0],
441            &[0],
442            &[0, 0],
443        );
444        assert_eq!(
445            ok,
446            Ok(()),
447            "zero-edge graph must validate with a length-1 placeholder"
448        );
449
450        // Empty edge slices are rejected (they violate the max(1) GPU-buffer shape).
451        let err = validate_program_graph(
452            ProgramGraphShape::new(2, 0),
453            &[0, 0],
454            &[0, 0, 0],
455            &[],
456            &[],
457            &[0, 0],
458        )
459        .unwrap_err();
460        assert!(
461            matches!(err, GraphValidationError::EdgeTargetsLen { expected: 1, got: 0 }),
462            "zero-edge graph with empty edge_targets must fail EdgeTargetsLen {{expected:1, got:0}}, got {err:?}"
463        );
464    }
465}