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}