Skip to main content

pe_graph/
graph.rs

1//! Graph definition — `StateGraph<S>` builder and validation.
2//!
3//! A `StateGraph` defines the topology: nodes (async functions) and edges
4//! (fixed or conditional transitions). Call `compile()` to validate the
5//! structure and produce a `CompiledGraph` ready for execution.
6
7use crate::compiled::CompiledGraph;
8use pe_core::error::PeError;
9use pe_core::node::NodeFn;
10use pe_core::state::State;
11use pe_core::types::{END, START};
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::sync::Arc;
14
15/// Fixed edge: always transitions from one node to another.
16#[derive(Debug, Clone)]
17pub(crate) struct Edge {
18    pub from: String,
19    pub to: String,
20}
21
22/// Type alias for a routing function that reads state and returns next node names.
23pub(crate) type RouterFn<S> = Arc<dyn Fn(&S) -> Vec<String> + Send + Sync>;
24
25/// Conditional edge: router function reads state and returns target node names.
26pub(crate) struct ConditionalEdge<S: State> {
27    pub from: String,
28    pub router: RouterFn<S>,
29}
30
31/// Declarative graph definition with typed nodes and edges.
32///
33/// Build a graph using the fluent API, then call `compile()` to validate
34/// and freeze it into a `CompiledGraph` for execution.
35///
36/// # Example
37///
38/// ```ignore
39/// use pe_graph::{StateGraph, START, END};
40///
41/// let graph = StateGraph::new()
42///     .add_node("prepare", prepare_node)
43///     .add_node("chat", chat_node)
44///     .add_edge(START, "prepare")
45///     .add_edge("prepare", "chat")
46///     .add_edge("chat", END)
47///     .compile()?;
48/// ```
49pub struct StateGraph<S: State> {
50    pub(crate) nodes: HashMap<String, Arc<dyn NodeFn<S>>>,
51    pub(crate) edges: Vec<Edge>,
52    pub(crate) conditional_edges: Vec<ConditionalEdge<S>>,
53}
54
55impl<S: State> StateGraph<S> {
56    /// Create an empty graph definition.
57    pub fn new() -> Self {
58        Self {
59            nodes: HashMap::new(),
60            edges: Vec::new(),
61            conditional_edges: Vec::new(),
62        }
63    }
64
65    /// Add a named node to the graph.
66    ///
67    /// Reserved names (`START`, `END`) are rejected at `compile()` time,
68    /// not here — so the builder stays infallible and chainable.
69    pub fn add_node(mut self, name: impl Into<String>, node: impl NodeFn<S> + 'static) -> Self {
70        let name = name.into();
71        self.nodes.insert(name, Arc::new(node));
72        self
73    }
74
75    /// Add a fixed edge: `from` always transitions to `to` after completing.
76    ///
77    /// Use `START` for the first node, `END` for terminal nodes.
78    pub fn add_edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
79        self.edges.push(Edge {
80            from: from.into(),
81            to: to.into(),
82        });
83        self
84    }
85
86    /// Add a conditional edge: router reads state and returns next node names.
87    ///
88    /// Return `vec![END.to_string()]` to terminate. Return multiple names
89    /// for parallel execution in the next superstep.
90    pub fn add_conditional_edge(
91        mut self,
92        from: impl Into<String>,
93        router: impl Fn(&S) -> Vec<String> + Send + Sync + 'static,
94    ) -> Self {
95        self.conditional_edges.push(ConditionalEdge {
96            from: from.into(),
97            router: Arc::new(router),
98        });
99        self
100    }
101
102    /// Get the fixed-edge successor node names for a given node.
103    ///
104    /// Returns successors reachable via fixed edges (not conditional).
105    /// Used by `resume()` and `Command::Update` to skip re-running the
106    /// interrupted node and continue to its successors.
107    ///
108    /// Includes END as a successor — the Pregel engine handles END
109    /// gracefully (no node found → empty results → graph completes).
110    /// Without this, a node whose only edge is `node → END` would
111    /// return empty, causing the caller to re-run the interrupted node.
112    pub(crate) fn fixed_successors(&self, node_name: &str) -> Vec<String> {
113        self.edges
114            .iter()
115            .filter(|e| e.from == node_name)
116            .map(|e| e.to.clone())
117            .collect()
118    }
119
120    /// Validate the graph structure and freeze it into a `CompiledGraph`.
121    ///
122    /// Validation checks:
123    /// - START has at least one outgoing edge
124    /// - All fixed edge targets/sources exist as node names (or START/END)
125    /// - No conditional edges from START
126    /// - All nodes are reachable from START
127    #[must_use = "CompiledGraph is the executable form — don't discard it"]
128    pub fn compile(self) -> Result<CompiledGraph<S>, PeError> {
129        self.validate()?;
130        Ok(CompiledGraph::new(Arc::new(self)))
131    }
132
133    /// Validate graph structure. Returns error on first violation found.
134    fn validate(&self) -> Result<(), PeError> {
135        let node_names: HashSet<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
136
137        // No nodes with reserved names
138        if node_names.contains(START) || node_names.contains(END) {
139            return Err(PeError::GraphValue {
140                details: format!(
141                    "Cannot use reserved names '{}' or '{}' as node names",
142                    START, END
143                ),
144            });
145        }
146
147        // START must have at least one outgoing fixed edge
148        let has_start_edge = self.edges.iter().any(|e| e.from == START);
149        if !has_start_edge {
150            return Err(PeError::GraphValue {
151                details: "START has no outgoing edges — add at least one edge from START".into(),
152            });
153        }
154
155        // No conditional edges from START (no state to route on yet)
156        if self.conditional_edges.iter().any(|ce| ce.from == START) {
157            return Err(PeError::GraphValue {
158                details: "Conditional edges from START are not allowed — use a router node instead"
159                    .into(),
160            });
161        }
162
163        // All fixed edge targets must be known nodes or END
164        // All fixed edge sources must be known nodes or START
165        for edge in &self.edges {
166            if edge.from != START && !node_names.contains(edge.from.as_str()) {
167                return Err(PeError::GraphValue {
168                    details: format!("Edge source '{}' is not a known node", edge.from),
169                });
170            }
171            if edge.to != END && !node_names.contains(edge.to.as_str()) {
172                return Err(PeError::GraphValue {
173                    details: format!("Edge target '{}' is not a known node", edge.to),
174                });
175            }
176        }
177
178        // Conditional edge sources must be known nodes
179        for ce in &self.conditional_edges {
180            if !node_names.contains(ce.from.as_str()) {
181                return Err(PeError::GraphValue {
182                    details: format!("Conditional edge source '{}' is not a known node", ce.from),
183                });
184            }
185        }
186
187        // All nodes must be reachable from START via BFS
188        // Conditional edges can reach any node, so treat them as wildcards
189        let reachable = self.reachable_from_start();
190        for name in node_names {
191            if !reachable.contains(name) {
192                return Err(PeError::UnreachableNode {
193                    node: name.to_string(),
194                });
195            }
196        }
197
198        Ok(())
199    }
200
201    /// BFS from START. Fixed edges are followed directly.
202    /// Conditional edges from a reachable node make ALL nodes reachable
203    /// (since we can't evaluate the router at compile time).
204    fn reachable_from_start(&self) -> HashSet<String> {
205        let mut reachable = HashSet::new();
206        let mut queue = VecDeque::new();
207
208        // Seed with START's fixed edge targets
209        for edge in &self.edges {
210            if edge.from == START && edge.to != END {
211                queue.push_back(edge.to.clone());
212            }
213        }
214
215        // If START has a conditional edge, all nodes are reachable
216        // (already rejected in validate, but defensive)
217        let cond_sources: HashSet<&str> = self
218            .conditional_edges
219            .iter()
220            .map(|ce| ce.from.as_str())
221            .collect();
222
223        while let Some(node) = queue.pop_front() {
224            if !reachable.insert(node.clone()) {
225                continue; // already visited
226            }
227
228            // If this node has a conditional edge, all nodes are potentially reachable
229            if cond_sources.contains(node.as_str()) {
230                for name in self.nodes.keys() {
231                    queue.push_back(name.clone());
232                }
233            }
234
235            // Follow fixed edges from this node
236            for edge in &self.edges {
237                if edge.from == node && edge.to != END {
238                    queue.push_back(edge.to.clone());
239                }
240            }
241        }
242
243        reachable
244    }
245}
246
247impl<S: State> Default for StateGraph<S> {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::tests::{AppendNode, TestState};
257
258    #[test]
259    fn test_valid_linear_graph_compiles() {
260        let graph = StateGraph::<TestState>::new()
261            .add_node("a", AppendNode::new("a", "hello"))
262            .add_node("b", AppendNode::new("b", "world"))
263            .add_edge(START, "a")
264            .add_edge("a", "b")
265            .add_edge("b", END)
266            .compile();
267
268        assert!(graph.is_ok());
269    }
270
271    #[test]
272    fn test_no_start_edge_rejected() {
273        let result = StateGraph::<TestState>::new()
274            .add_node("a", AppendNode::new("a", "hello"))
275            .add_edge("a", END)
276            .compile();
277
278        assert!(result.is_err());
279        let err = result.unwrap_err().to_string();
280        assert!(err.contains("START"), "Error should mention START: {err}");
281    }
282
283    #[test]
284    fn test_unknown_edge_target_rejected() {
285        let result = StateGraph::<TestState>::new()
286            .add_node("a", AppendNode::new("a", "hello"))
287            .add_edge(START, "a")
288            .add_edge("a", "nonexistent")
289            .compile();
290
291        assert!(result.is_err());
292        let err = result.unwrap_err().to_string();
293        assert!(
294            err.contains("nonexistent"),
295            "Error should mention missing node: {err}"
296        );
297    }
298
299    #[test]
300    fn test_unreachable_node_rejected() {
301        let result = StateGraph::<TestState>::new()
302            .add_node("a", AppendNode::new("a", "hello"))
303            .add_node("orphan", AppendNode::new("orphan", "lost"))
304            .add_edge(START, "a")
305            .add_edge("a", END)
306            .compile();
307
308        assert!(result.is_err());
309        let err = result.unwrap_err().to_string();
310        assert!(
311            err.contains("orphan"),
312            "Error should mention unreachable node: {err}"
313        );
314    }
315
316    #[test]
317    fn test_conditional_from_start_rejected() {
318        let result = StateGraph::<TestState>::new()
319            .add_node("a", AppendNode::new("a", "hello"))
320            .add_edge(START, "a")
321            .add_conditional_edge(START, |_: &TestState| vec!["a".into()])
322            .add_edge("a", END)
323            .compile();
324
325        assert!(result.is_err());
326        let err = result.unwrap_err().to_string();
327        assert!(
328            err.contains("Conditional edges from START"),
329            "Error should explain restriction: {err}"
330        );
331    }
332
333    #[test]
334    fn test_conditional_edge_makes_nodes_reachable() {
335        // "b" is only reachable through conditional edge from "a"
336        let graph = StateGraph::<TestState>::new()
337            .add_node("a", AppendNode::new("a", "hello"))
338            .add_node("b", AppendNode::new("b", "world"))
339            .add_edge(START, "a")
340            .add_conditional_edge("a", |_: &TestState| vec![END.into()])
341            .add_edge("b", END)
342            .compile();
343
344        assert!(
345            graph.is_ok(),
346            "Nodes reachable via conditional edges should be valid"
347        );
348    }
349
350    #[test]
351    fn test_reserved_name_rejected_at_compile() {
352        let err = StateGraph::<TestState>::new()
353            .add_node(START, AppendNode::new(START, "bad"))
354            .add_edge(START, START)
355            .compile()
356            .unwrap_err()
357            .to_string();
358
359        assert!(
360            err.contains("reserved name"),
361            "Error should mention reserved name: {err}"
362        );
363    }
364}