vyre_foundation/analysis/graph_view.rs
1//! P7.5 - Graph-IR compatibility bridge.
2//!
3//! Statement-IR (`Program` + `Node` + `Expr`) is the canonical
4//! form in 0.6 - wire-format stable, every backend speaks it.
5//! Graph-IR is a pure VIEW on top: lets whole-program optimization
6//! passes (kernel fusion, dead-subregion elimination) walk the IR
7//! as a DAG of `DataflowNode`s connected by `DataEdge`s.
8//!
9//! **Contract freeze:** the graph-view types (`GraphNode`,
10//! `DataflowKind`, `DataEdge`, `NodeGraph`) are `#[non_exhaustive]`
11//! and live in this module permanently. External crates that
12//! want a graph-IR pass (sparse-graph coloring, auto-fusion,
13//! parallelism scheduling) pin against this surface without
14//! forcing a statement-IR rewrite.
15//!
16//! **No wire-format impact.** `to_graph(program)` is a
17//! lossless analysis; `from_graph(graph)` rebuilds an equivalent
18//! statement-IR Program. Round-trip is byte-identity under
19//! canonicalization.
20//!
21//! Today's deliverable: the types + a minimal `to_graph` /
22//! `from_graph` walker that treats each top-level Node as a
23//! graph node with explicit ordering edges. Richer dataflow
24//! analysis (reaching-definition edges, implicit-dependency
25//! discovery) land as optimization passes without changing the
26//! graph types.
27
28use crate::ir_inner::model::expr::Ident;
29use crate::ir_inner::model::node::Node;
30use crate::ir_inner::model::program::Program;
31use core::fmt;
32
33/// Validation error returned when a `NodeGraph` fails structural
34/// checks before lowering to statement-IR.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum GraphValidateError {
38 /// A cycle was detected in the graph.
39 Cycle {
40 /// Node ids forming the cycle.
41 path: Vec<u32>,
42 },
43 /// An edge references a node id that does not exist.
44 DanglingEdge {
45 /// Source node id.
46 from: u32,
47 /// Target node id.
48 to: u32,
49 },
50 /// A Phi node has no valid predecessors.
51 OrphanPhi {
52 /// The Phi node id.
53 node_id: u32,
54 },
55}
56
57impl fmt::Display for GraphValidateError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 Self::Cycle { path } => {
61 write!(
62 f,
63 "graph contains a cycle involving nodes {path:?}. Fix: remove cyclic dependencies so the graph is a valid DAG."
64 )
65 }
66 Self::DanglingEdge { from, to } => {
67 write!(
68 f,
69 "edge from {from} to {to} references a non-existent node. Fix: ensure all edge endpoints exist in the graph's node list."
70 )
71 }
72 Self::OrphanPhi { node_id } => {
73 write!(
74 f,
75 "Phi node {node_id} has no valid predecessors. Fix: ensure every Phi node references at least one existing predecessor node."
76 )
77 }
78 }
79 }
80}
81
82impl std::error::Error for GraphValidateError {}
83
84/// One node in the graph-IR view.
85#[derive(Debug, Clone)]
86#[non_exhaustive]
87pub struct GraphNode {
88 /// Stable id inside this graph. Issued sequentially during
89 /// `to_graph` for reproducibility across runs.
90 pub id: u32,
91 /// Discriminant + payload. Currently carries a reference back
92 /// to the statement-IR Node; graph-native variants land in
93 /// follow-on passes (autodiff-tape, sparse-region, etc.).
94 pub kind: DataflowKind,
95}
96
97impl GraphNode {
98 /// Construct a `GraphNode` from explicit fields (V7-EXT-025).
99 #[must_use]
100 pub fn new(id: u32, kind: DataflowKind) -> Self {
101 Self { id, kind }
102 }
103}
104
105/// Kind of a graph node.
106#[derive(Debug, Clone)]
107#[non_exhaustive]
108pub enum DataflowKind {
109 /// A statement-IR node executed as-is.
110 Statement(Node),
111 /// A synthetic "phi" introduced by later dataflow-analysis
112 /// passes. Carries the set of graph-node ids that feed it.
113 Phi(Vec<u32>),
114 /// An explicit no-op barrier for scheduling passes that want
115 /// to pin ordering without executing anything.
116 Barrier,
117}
118
119/// Directed edge between two `GraphNode`s.
120#[derive(Debug, Clone, PartialEq, Eq)]
121#[non_exhaustive]
122pub struct DataEdge {
123 /// Source node id.
124 pub from: u32,
125 /// Destination node id.
126 pub to: u32,
127 /// Kind of the dependency.
128 pub kind: EdgeKind,
129}
130
131impl DataEdge {
132 /// Construct a `DataEdge` from explicit fields (V7-EXT-026).
133 #[must_use]
134 pub fn new(from: u32, to: u32, kind: EdgeKind) -> Self {
135 Self { from, to, kind }
136 }
137}
138
139/// Dependency kinds an edge can express.
140#[derive(Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum EdgeKind {
143 /// Pure ordering dependency (statement-IR sequence).
144 Ordering,
145 /// Reaching-definition: `to` reads a variable defined by `from`.
146 /// Producers that run reaching-defs analysis populate this edge.
147 Def {
148 /// The variable name that flows along this edge.
149 name: Ident,
150 },
151 /// Control-flow dependency (branch → body).
152 Control,
153}
154
155/// Graph-IR view over a statement-IR Program.
156#[derive(Debug, Clone, Default)]
157#[non_exhaustive]
158pub struct NodeGraph {
159 /// Nodes indexed by their `id`.
160 pub nodes: Vec<GraphNode>,
161 /// Edges between nodes.
162 pub edges: Vec<DataEdge>,
163 /// Original workgroup size. Preserved through view conversion.
164 pub workgroup_size: [u32; 3],
165 /// Original buffer declarations. Preserved through view conversion.
166 pub buffers: Vec<crate::ir_inner::model::program::BufferDecl>,
167}
168
169impl NodeGraph {
170 /// Construct a `NodeGraph` from explicit node / edge vectors.
171 /// Used by external tooling that synthesizes graphs without
172 /// going through `from_program` (V7-EXT-027).
173 ///
174 /// `workgroup_size` defaults to [1, 1, 1] and buffers defaults to
175 /// empty. For full control use struct-literal syntax inside the
176 /// defining crate.
177 #[must_use]
178 pub fn new(nodes: Vec<GraphNode>, edges: Vec<DataEdge>) -> Self {
179 Self {
180 nodes,
181 edges,
182 workgroup_size: [1, 1, 1],
183 buffers: Vec::new(),
184 }
185 }
186
187 /// Lift a statement-IR `Program` into its graph-IR view.
188 ///
189 /// The 0.6 lifting emits one `GraphNode::Statement` per top-
190 /// level `Node::entry()` node, connected by `Ordering` edges in
191 /// document order. Later passes refine edges with reaching-
192 /// definition / control-flow / dataflow analyses.
193 ///
194 /// `VYRE_IR_HOTSPOTS` HIGH (`graph_view.rs:205`): the previous
195 /// implementation cloned every top-level node via `n.clone()`.
196 /// This helper now delegates to `from_program_owned` after
197 /// cloning the inner structure cheaply via `Arc` refcount bumps,
198 /// so the hot path (when the caller owns the Program) can move
199 /// directly into the graph without the per-node clone.
200 #[must_use]
201 pub fn from_program(program: &Program) -> Self {
202 Self::from_program_owned(program.clone())
203 }
204
205 /// Build the graph by consuming the Program - moves the entry
206 /// `Vec<Node>` out of its `Arc` when uniquely owned and avoids
207 /// cloning each node. Use this whenever the caller holds the
208 /// only `Program` reference.
209 #[must_use]
210 pub fn from_program_owned(program: Program) -> Self {
211 let workgroup_size = program.workgroup_size();
212 let buffers = program.buffers().to_vec();
213 let entry_vec = program.into_entry_vec();
214 let mut nodes = Vec::with_capacity(entry_vec.len());
215 let mut edges = Vec::with_capacity(entry_vec.len().saturating_sub(1));
216 for (i, n) in entry_vec.into_iter().enumerate() {
217 #[allow(clippy::cast_possible_truncation)]
218 let id = i as u32;
219 nodes.push(GraphNode {
220 id,
221 kind: DataflowKind::Statement(n),
222 });
223 if id > 0 {
224 edges.push(DataEdge {
225 from: id - 1,
226 to: id,
227 kind: EdgeKind::Ordering,
228 });
229 }
230 }
231 Self {
232 nodes,
233 edges,
234 workgroup_size,
235 buffers,
236 }
237 }
238
239 /// Lower the graph view back into a statement-IR Program.
240 /// Preserves document-order of `GraphNode::Statement` variants;
241 /// `Phi` and synthetic `Barrier` variants are dropped (they
242 /// don't round-trip to statement-IR by design).
243 ///
244 /// # Errors
245 ///
246 /// Returns `GraphValidateError::DanglingEdge` if an edge references
247 /// a non-existent node id, `GraphValidateError::Cycle` if the graph
248 /// contains a directed cycle, or `GraphValidateError::OrphanPhi` if
249 /// a Phi node has no predecessors.
250 pub fn try_into_program(self) -> Result<Program, GraphValidateError> {
251 let node_count = u32::try_from(self.nodes.len()).unwrap_or(u32::MAX);
252
253 // 1. Check for dangling edges.
254 for edge in &self.edges {
255 if edge.from >= node_count || edge.to >= node_count {
256 return Err(GraphValidateError::DanglingEdge {
257 from: edge.from,
258 to: edge.to,
259 });
260 }
261 }
262
263 // 2. Check for orphan Phi nodes.
264 for node in &self.nodes {
265 if let DataflowKind::Phi(predecessors) = &node.kind {
266 if predecessors.is_empty() {
267 return Err(GraphValidateError::OrphanPhi { node_id: node.id });
268 }
269 for &pred in predecessors {
270 if pred >= node_count {
271 return Err(GraphValidateError::OrphanPhi { node_id: node.id });
272 }
273 }
274 }
275 }
276
277 // 3. Check for cycles via an explicit-stack (iterative) DFS. A recursive
278 // DFS recurses once per node along a path, so a deep or adversarial
279 // linear graph (e.g. a 200k-statement chain round-tripped through
280 // to_graph) would overflow the call stack and panic. Validation must
281 // reject malformed topology *without panicking* at any scale
282 // (FINDING-FOUNDATION-1), so the traversal state lives on the heap.
283 let mut adj: Vec<Vec<u32>> = vec![Vec::new(); self.nodes.len()];
284 for edge in &self.edges {
285 adj[edge.from as usize].push(edge.to);
286 }
287
288 // 0 = unvisited, 1 = on the current DFS path (gray), 2 = fully explored (black).
289 let mut state = vec![0u8; self.nodes.len()];
290 // Gray nodes on the current root->frontier path; used to reconstruct the
291 // cycle when a back edge is found.
292 let mut path: Vec<u32> = Vec::new();
293 // Explicit DFS frames: (node, index of the next child to visit).
294 let mut stack: Vec<(u32, usize)> = Vec::new();
295
296 for root in 0..node_count {
297 if state[root as usize] != 0 {
298 continue;
299 }
300 state[root as usize] = 1;
301 path.push(root);
302 stack.push((root, 0));
303
304 while let Some(&(node, child_idx)) = stack.last() {
305 let children = &adj[node as usize];
306 if child_idx < children.len() {
307 // Advance this frame's cursor before descending.
308 stack
309 .last_mut()
310 .expect("stack is non-empty inside `while let Some(..) = stack.last()`")
311 .1 += 1;
312 let next = children[child_idx];
313 match state[next as usize] {
314 0 => {
315 state[next as usize] = 1;
316 path.push(next);
317 stack.push((next, 0));
318 }
319 1 => {
320 // Back edge to a gray node: a directed cycle.
321 let cycle_start = path.iter().position(|&n| n == next).unwrap_or(0);
322 return Err(GraphValidateError::Cycle {
323 path: path[cycle_start..].to_vec(),
324 });
325 }
326 _ => {} // black: fully explored, no cycle through it.
327 }
328 } else {
329 state[node as usize] = 2;
330 path.pop();
331 stack.pop();
332 }
333 }
334 }
335
336 let entry: Vec<Node> = self
337 .nodes
338 .into_iter()
339 .filter_map(|gn| match gn.kind {
340 DataflowKind::Statement(n) => Some(n),
341 DataflowKind::Phi(_) => None,
342 DataflowKind::Barrier => Some(Node::barrier()),
343 })
344 .collect();
345 Ok(Program::wrapped(self.buffers, self.workgroup_size, entry))
346 }
347}
348
349/// Convenience - `to_graph(program)` style call.
350#[must_use]
351pub fn to_graph(program: &Program) -> NodeGraph {
352 NodeGraph::from_program(program)
353}
354
355/// Convenience - `from_graph(graph)` style call.
356///
357/// # Errors
358///
359/// Returns `GraphValidateError` if the graph contains dangling edges,
360/// cycles, or orphan Phi nodes.
361pub fn from_graph(graph: NodeGraph) -> Result<Program, GraphValidateError> {
362 graph.try_into_program()
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::ir::{BufferDecl, DataType, Expr, Node, Program};
369
370 fn trivial() -> Program {
371 Program::wrapped(
372 vec![BufferDecl::read_write("out", 0, DataType::U32).with_count(1)],
373 [1, 1, 1],
374 vec![
375 Node::let_bind("x", Expr::u32(42)),
376 Node::store("out", Expr::u32(0), Expr::var("x")),
377 ],
378 )
379 }
380
381 #[test]
382 fn graph_view_mirrors_top_level_nodes() {
383 let p = trivial();
384 let g = to_graph(&p);
385 assert_eq!(g.nodes.len(), p.entry().len());
386 assert_eq!(g.workgroup_size, p.workgroup_size());
387 }
388
389 #[test]
390 fn graph_edges_are_ordering_in_sequence() {
391 let p = trivial();
392 let g = to_graph(&p);
393 assert_eq!(g.edges.len(), g.nodes.len() - 1);
394 for (i, e) in g.edges.iter().enumerate() {
395 assert_eq!(e.from, i as u32);
396 assert_eq!(e.to, (i + 1) as u32);
397 assert!(matches!(e.kind, EdgeKind::Ordering));
398 }
399 }
400
401 #[test]
402 fn round_trip_is_byte_identical_under_canonicalize() {
403 let p = trivial();
404 let g = to_graph(&p);
405 let p2 = from_graph(g).unwrap();
406 // canonicalize both (to normalize operand ordering etc.)
407 let p_c = crate::optimizer::passes::algebraic::canonicalize_engine::run(p);
408 let p2_c = crate::optimizer::passes::algebraic::canonicalize_engine::run(p2);
409 assert_eq!(p_c.to_wire().unwrap(), p2_c.to_wire().unwrap());
410 }
411
412 #[test]
413 fn phi_node_dropped_on_lowering() {
414 let mut g = NodeGraph {
415 workgroup_size: [1, 1, 1],
416 ..Default::default()
417 };
418 g.buffers
419 .push(BufferDecl::read_write("out", 0, DataType::U32).with_count(1));
420 g.nodes.push(GraphNode {
421 id: 0,
422 kind: DataflowKind::Statement(Node::store("out", Expr::u32(0), Expr::u32(1))),
423 });
424 g.nodes.push(GraphNode {
425 id: 1,
426 kind: DataflowKind::Phi(vec![0]),
427 });
428 let p = from_graph(g).unwrap();
429 assert_eq!(
430 p.entry().len(),
431 1,
432 "Phi must not round-trip to statement-IR"
433 );
434 }
435}