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}