Skip to main content

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}