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 {:?}. Fix: remove cyclic dependencies so the graph is a valid DAG.",
64 path
65 )
66 }
67 Self::DanglingEdge { from, to } => {
68 write!(
69 f,
70 "edge from {} to {} references a non-existent node. Fix: ensure all edge endpoints exist in the graph's node list.",
71 from, to
72 )
73 }
74 Self::OrphanPhi { node_id } => {
75 write!(
76 f,
77 "Phi node {} has no valid predecessors. Fix: ensure every Phi node references at least one existing predecessor node.",
78 node_id
79 )
80 }
81 }
82 }
83}
84
85impl std::error::Error for GraphValidateError {}
86
87/// One node in the graph-IR view.
88#[derive(Debug, Clone)]
89#[non_exhaustive]
90pub struct GraphNode {
91 /// Stable id inside this graph. Issued sequentially during
92 /// `to_graph` for reproducibility across runs.
93 pub id: u32,
94 /// Discriminant + payload. Currently carries a reference back
95 /// to the statement-IR Node; graph-native variants land in
96 /// follow-on passes (autodiff-tape, sparse-region, etc.).
97 pub kind: DataflowKind,
98}
99
100impl GraphNode {
101 /// Construct a `GraphNode` from explicit fields (V7-EXT-025).
102 #[must_use]
103 pub fn new(id: u32, kind: DataflowKind) -> Self {
104 Self { id, kind }
105 }
106}
107
108/// Kind of a graph node.
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub enum DataflowKind {
112 /// A statement-IR node executed as-is.
113 Statement(Node),
114 /// A synthetic "phi" introduced by later dataflow-analysis
115 /// passes. Carries the set of graph-node ids that feed it.
116 Phi(Vec<u32>),
117 /// An explicit no-op barrier for scheduling passes that want
118 /// to pin ordering without executing anything.
119 Barrier,
120}
121
122/// Directed edge between two `GraphNode`s.
123#[derive(Debug, Clone, PartialEq, Eq)]
124#[non_exhaustive]
125pub struct DataEdge {
126 /// Source node id.
127 pub from: u32,
128 /// Destination node id.
129 pub to: u32,
130 /// Kind of the dependency.
131 pub kind: EdgeKind,
132}
133
134impl DataEdge {
135 /// Construct a `DataEdge` from explicit fields (V7-EXT-026).
136 #[must_use]
137 pub fn new(from: u32, to: u32, kind: EdgeKind) -> Self {
138 Self { from, to, kind }
139 }
140}
141
142/// Dependency kinds an edge can express.
143#[derive(Debug, Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum EdgeKind {
146 /// Pure ordering dependency (statement-IR sequence).
147 Ordering,
148 /// Reaching-definition: `to` reads a variable defined by `from`.
149 /// Producers that run reaching-defs analysis populate this edge.
150 Def {
151 /// The variable name that flows along this edge.
152 name: Ident,
153 },
154 /// Control-flow dependency (branch → body).
155 Control,
156}
157
158/// Graph-IR view over a statement-IR Program.
159#[derive(Debug, Clone, Default)]
160#[non_exhaustive]
161pub struct NodeGraph {
162 /// Nodes indexed by their `id`.
163 pub nodes: Vec<GraphNode>,
164 /// Edges between nodes.
165 pub edges: Vec<DataEdge>,
166 /// Original workgroup size. Preserved through view conversion.
167 pub workgroup_size: [u32; 3],
168 /// Original buffer declarations. Preserved through view conversion.
169 pub buffers: Vec<crate::ir_inner::model::program::BufferDecl>,
170}
171
172impl NodeGraph {
173 /// Construct a `NodeGraph` from explicit node / edge vectors.
174 /// Used by external tooling that synthesizes graphs without
175 /// going through `from_program` (V7-EXT-027).
176 ///
177 /// workgroup_size defaults to [1, 1, 1] and buffers defaults to
178 /// empty. For full control use struct-literal syntax inside the
179 /// defining crate.
180 #[must_use]
181 pub fn new(nodes: Vec<GraphNode>, edges: Vec<DataEdge>) -> Self {
182 Self {
183 nodes,
184 edges,
185 workgroup_size: [1, 1, 1],
186 buffers: Vec::new(),
187 }
188 }
189
190 /// Lift a statement-IR `Program` into its graph-IR view.
191 ///
192 /// The 0.6 lifting emits one `GraphNode::Statement` per top-
193 /// level `Node::entry()` node, connected by `Ordering` edges in
194 /// document order. Later passes refine edges with reaching-
195 /// definition / control-flow / dataflow analyses.
196 ///
197 /// VYRE_IR_HOTSPOTS HIGH (graph_view.rs:205): the previous
198 /// implementation cloned every top-level node via `n.clone()`.
199 /// This helper now delegates to `from_program_owned` after
200 /// cloning the inner structure cheaply via `Arc` refcount bumps,
201 /// so the hot path (when the caller owns the Program) can move
202 /// directly into the graph without the per-node clone.
203 #[must_use]
204 pub fn from_program(program: &Program) -> Self {
205 Self::from_program_owned(program.clone())
206 }
207
208 /// Build the graph by consuming the Program — moves the entry
209 /// `Vec<Node>` out of its `Arc` when uniquely owned and avoids
210 /// cloning each node. Use this whenever the caller holds the
211 /// only `Program` reference.
212 #[must_use]
213 pub fn from_program_owned(program: Program) -> Self {
214 let workgroup_size = program.workgroup_size();
215 let buffers = program.buffers().to_vec();
216 let entry_vec = program.into_entry_vec();
217 let mut nodes = Vec::with_capacity(entry_vec.len());
218 let mut edges = Vec::with_capacity(entry_vec.len().saturating_sub(1));
219 for (i, n) in entry_vec.into_iter().enumerate() {
220 #[allow(clippy::cast_possible_truncation)]
221 let id = i as u32;
222 nodes.push(GraphNode {
223 id,
224 kind: DataflowKind::Statement(n),
225 });
226 if id > 0 {
227 edges.push(DataEdge {
228 from: id - 1,
229 to: id,
230 kind: EdgeKind::Ordering,
231 });
232 }
233 }
234 Self {
235 nodes,
236 edges,
237 workgroup_size,
238 buffers,
239 }
240 }
241
242 /// Lower the graph view back into a statement-IR Program.
243 /// Preserves document-order of `GraphNode::Statement` variants;
244 /// `Phi` and synthetic `Barrier` variants are dropped (they
245 /// don't round-trip to statement-IR by design).
246 ///
247 /// # Errors
248 ///
249 /// Returns `GraphValidateError::DanglingEdge` if an edge references
250 /// a non-existent node id, `GraphValidateError::Cycle` if the graph
251 /// contains a directed cycle, or `GraphValidateError::OrphanPhi` if
252 /// a Phi node has no predecessors.
253 pub fn try_into_program(self) -> Result<Program, GraphValidateError> {
254 let node_count = self.nodes.len() as u32;
255
256 // 1. Check for dangling edges.
257 for edge in &self.edges {
258 if edge.from >= node_count || edge.to >= node_count {
259 return Err(GraphValidateError::DanglingEdge {
260 from: edge.from,
261 to: edge.to,
262 });
263 }
264 }
265
266 // 2. Check for orphan Phi nodes.
267 for node in &self.nodes {
268 if let DataflowKind::Phi(predecessors) = &node.kind {
269 if predecessors.is_empty() {
270 return Err(GraphValidateError::OrphanPhi { node_id: node.id });
271 }
272 for &pred in predecessors {
273 if pred >= node_count {
274 return Err(GraphValidateError::OrphanPhi { node_id: node.id });
275 }
276 }
277 }
278 }
279
280 // 3. Check for cycles via DFS.
281 let mut adj: Vec<Vec<u32>> = vec![Vec::new(); self.nodes.len()];
282 for edge in &self.edges {
283 adj[edge.from as usize].push(edge.to);
284 }
285
286 let mut state = vec![0u8; self.nodes.len()]; // 0 = unvisited, 1 = visiting, 2 = done
287 let mut path = Vec::new();
288
289 fn dfs(
290 node: u32,
291 adj: &[Vec<u32>],
292 state: &mut [u8],
293 path: &mut Vec<u32>,
294 ) -> Option<Vec<u32>> {
295 let idx = node as usize;
296 if state[idx] == 1 {
297 let cycle_start = path.iter().position(|&n| n == node).unwrap_or(0);
298 return Some(path[cycle_start..].to_vec());
299 }
300 if state[idx] == 2 {
301 return None;
302 }
303 state[idx] = 1;
304 path.push(node);
305 for &next in &adj[idx] {
306 if let Some(cycle) = dfs(next, adj, state, path) {
307 return Some(cycle);
308 }
309 }
310 path.pop();
311 state[idx] = 2;
312 None
313 }
314
315 for i in 0..self.nodes.len() as u32 {
316 if state[i as usize] == 0 {
317 if let Some(cycle_path) = dfs(i, &adj, &mut state, &mut path) {
318 return Err(GraphValidateError::Cycle { path: cycle_path });
319 }
320 }
321 }
322
323 let entry: Vec<Node> = self
324 .nodes
325 .into_iter()
326 .filter_map(|gn| match gn.kind {
327 DataflowKind::Statement(n) => Some(n),
328 DataflowKind::Phi(_) => None,
329 DataflowKind::Barrier => Some(Node::barrier()),
330 })
331 .collect();
332 Ok(Program::wrapped(self.buffers, self.workgroup_size, entry))
333 }
334}
335
336/// Convenience — `to_graph(program)` style call.
337#[must_use]
338pub fn to_graph(program: &Program) -> NodeGraph {
339 NodeGraph::from_program(program)
340}
341
342/// Convenience — `from_graph(graph)` style call.
343///
344/// # Errors
345///
346/// Returns `GraphValidateError` if the graph contains dangling edges,
347/// cycles, or orphan Phi nodes.
348pub fn from_graph(graph: NodeGraph) -> Result<Program, GraphValidateError> {
349 graph.try_into_program()
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::ir::{BufferDecl, DataType, Expr, Node, Program};
356
357 fn trivial() -> Program {
358 Program::wrapped(
359 vec![BufferDecl::read_write("out", 0, DataType::U32).with_count(1)],
360 [1, 1, 1],
361 vec![
362 Node::let_bind("x", Expr::u32(42)),
363 Node::store("out", Expr::u32(0), Expr::var("x")),
364 ],
365 )
366 }
367
368 #[test]
369 fn graph_view_mirrors_top_level_nodes() {
370 let p = trivial();
371 let g = to_graph(&p);
372 assert_eq!(g.nodes.len(), p.entry().len());
373 assert_eq!(g.workgroup_size, p.workgroup_size());
374 }
375
376 #[test]
377 fn graph_edges_are_ordering_in_sequence() {
378 let p = trivial();
379 let g = to_graph(&p);
380 assert_eq!(g.edges.len(), g.nodes.len() - 1);
381 for (i, e) in g.edges.iter().enumerate() {
382 assert_eq!(e.from, i as u32);
383 assert_eq!(e.to, (i + 1) as u32);
384 assert!(matches!(e.kind, EdgeKind::Ordering));
385 }
386 }
387
388 #[test]
389 fn round_trip_is_byte_identical_under_canonicalize() {
390 let p = trivial();
391 let g = to_graph(&p);
392 let p2 = from_graph(g).unwrap();
393 // canonicalize both (to normalize operand ordering etc.)
394 let p_c = crate::optimizer::passes::algebraic::canonicalize_engine::run(p);
395 let p2_c = crate::optimizer::passes::algebraic::canonicalize_engine::run(p2);
396 assert_eq!(p_c.to_wire().unwrap(), p2_c.to_wire().unwrap());
397 }
398
399 #[test]
400 fn phi_node_dropped_on_lowering() {
401 let mut g = NodeGraph {
402 workgroup_size: [1, 1, 1],
403 ..Default::default()
404 };
405 g.buffers
406 .push(BufferDecl::read_write("out", 0, DataType::U32).with_count(1));
407 g.nodes.push(GraphNode {
408 id: 0,
409 kind: DataflowKind::Statement(Node::store("out", Expr::u32(0), Expr::u32(1))),
410 });
411 g.nodes.push(GraphNode {
412 id: 1,
413 kind: DataflowKind::Phi(vec![0]),
414 });
415 let p = from_graph(g).unwrap();
416 assert_eq!(
417 p.entry().len(),
418 1,
419 "Phi must not round-trip to statement-IR"
420 );
421 }
422}