1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
//! Graph compilation logic and validation.
//!
//! This module contains the logic for compiling a GraphBuilder into an
//! executable App, including structural validation and actionable errors.
use crate::app::App;
use crate::types::NodeKind;
use rustc_hash::FxHashMap;
/// Errors that can occur when compiling a graph.
#[derive(Debug, thiserror::Error)]
#[cfg_attr(feature = "diagnostics", derive(miette::Diagnostic))]
pub enum GraphCompileError {
/// No entry edge was defined from the virtual Start node.
#[error("missing entry: no edge or conditional edge originates from Start")]
MissingEntry,
/// An edge references a node that is not registered in the graph.
#[error("unknown node referenced in edge: {0}")]
UnknownNode(NodeKind),
/// An edge originates from the virtual End node, which is terminal.
#[error("invalid edge: cannot originate from End")]
EdgeFromEnd,
/// A cycle was detected in the graph.
#[error("cycle detected in graph: {}", .cycle.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(" -> "))]
CycleDetected {
/// The cycle path showing nodes forming the cycle.
cycle: Vec<NodeKind>,
},
/// One or more nodes are unreachable from the Start node.
#[error("unreachable nodes detected (no path from Start): {}", .nodes.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", "))]
UnreachableNodes {
/// List of nodes with no path from Start.
nodes: Vec<NodeKind>,
},
/// One or more nodes have no path to the End node.
#[error("nodes with no path to End: {}", .nodes.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", "))]
NoPathToEnd {
/// List of nodes that cannot reach End.
nodes: Vec<NodeKind>,
},
/// A duplicate edge was detected.
#[error("duplicate edge detected: {} -> {}", .from, .to)]
DuplicateEdge {
/// The source node of the duplicate edge.
from: NodeKind,
/// The target node of the duplicate edge.
to: NodeKind,
},
}
/// Compilation logic for GraphBuilder.
impl super::builder::GraphBuilder {
/// Compiles the graph into an executable application.
///
/// Validates the graph configuration and converts it into an [`App`] that
/// can execute workflows. This method performs validation checks to prevent
/// common topology issues (missing entry, cycles, unknown nodes, duplicates).
///
/// # Returns
///
/// - `Ok(App)`: Successfully compiled application ready for execution
/// - `Err(GraphCompileError)`: Structural validation failed; inspect the variant
///
/// # Examples
///
/// Basic pattern with error propagation:
/// ```
/// use weavegraph::graphs::GraphBuilder;
/// use weavegraph::types::NodeKind;
///
/// # struct MyNode;
/// # #[async_trait::async_trait]
/// # impl weavegraph::node::Node for MyNode {
/// # async fn run(&self, _: weavegraph::state::StateSnapshot, _: weavegraph::node::NodeContext) -> Result<weavegraph::node::NodePartial, weavegraph::node::NodeError> {
/// # Ok(weavegraph::node::NodePartial::default())
/// # }
/// # }
///
/// let app = GraphBuilder::new()
/// .add_node(NodeKind::Custom("process".into()), MyNode)
/// .add_edge(NodeKind::Start, NodeKind::Custom("process".into()))
/// .add_edge(NodeKind::Custom("process".into()), NodeKind::End)
/// .compile()?;
/// # Ok::<_, weavegraph::graphs::GraphCompileError>(())
/// ```
///
/// Explicit handling with pattern matching:
/// ```
/// use weavegraph::graphs::{GraphBuilder, GraphCompileError};
/// use weavegraph::types::NodeKind;
///
/// let result = GraphBuilder::new()
/// .add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
/// .compile();
///
/// match result {
/// Ok(_app) => {}
/// Err(GraphCompileError::MissingEntry) => {
/// eprintln!("graph has no entry edge from Start");
/// }
/// Err(GraphCompileError::UnknownNode(nk)) => {
/// eprintln!("unknown node referenced: {nk}");
/// }
/// Err(e) => {
/// eprintln!("graph validation failed: {e}");
/// }
/// }
/// ```
pub fn compile(self) -> Result<App, GraphCompileError> {
// Validate without consuming self
self.validate()?;
let (nodes, edges, conditional_edges, runtime_config, reducer_registry) = self.into_parts();
Ok(App::from_parts(
nodes,
edges,
conditional_edges,
runtime_config,
reducer_registry,
))
}
/// Detects cycles in the graph using DFS with color marking.
///
/// Only checks unconditional edges, as conditional edge targets are runtime-determined.
/// Returns the first cycle found as a path of nodes.
fn detect_cycle(&self) -> Option<Vec<NodeKind>> {
#[derive(Clone, Copy, PartialEq)]
enum Color {
White, // Not visited
Gray, // Currently visiting
Black, // Fully visited
}
let mut colors: FxHashMap<NodeKind, Color> = FxHashMap::default();
let mut path: Vec<NodeKind> = Vec::new();
// Initialize all nodes as White
for from in self.edges_ref().keys() {
colors.entry(from.clone()).or_insert(Color::White);
}
for tos in self.edges_ref().values() {
for to in tos {
colors.entry(to.clone()).or_insert(Color::White);
}
}
// DFS helper function
fn dfs(
node: &NodeKind,
colors: &mut FxHashMap<NodeKind, Color>,
path: &mut Vec<NodeKind>,
edges: &FxHashMap<NodeKind, Vec<NodeKind>>,
) -> Option<Vec<NodeKind>> {
colors.insert(node.clone(), Color::Gray);
path.push(node.clone());
if let Some(neighbors) = edges.get(node) {
for neighbor in neighbors {
match colors.get(neighbor).copied().unwrap_or(Color::White) {
Color::White => {
if let Some(cycle) = dfs(neighbor, colors, path, edges) {
return Some(cycle);
}
}
Color::Gray => {
// Found a back edge - extract the cycle
if let Some(cycle_start) = path.iter().position(|n| n == neighbor) {
let mut cycle = path[cycle_start..].to_vec();
cycle.push(neighbor.clone()); // Complete the cycle
return Some(cycle);
}
}
Color::Black => {
// Already fully explored, skip
}
}
}
}
path.pop();
colors.insert(node.clone(), Color::Black);
None
}
// Try DFS from each unvisited node
for node in colors.clone().keys() {
if colors.get(node).copied().unwrap_or(Color::White) == Color::White
&& let Some(cycle) = dfs(node, &mut colors, &mut path, self.edges_ref())
{
return Some(cycle);
}
}
None
}
/// Detects unreachable nodes (nodes with no path from Start).
///
/// Only checks unconditional edges. Returns registered Custom nodes that
/// cannot be reached from Start via unconditional edges.
fn detect_unreachable_nodes(&self) -> Vec<NodeKind> {
use std::collections::VecDeque;
let mut reachable: FxHashMap<NodeKind, bool> = FxHashMap::default();
let mut queue: VecDeque<NodeKind> = VecDeque::new();
// Start BFS from Start node
queue.push_back(NodeKind::Start);
reachable.insert(NodeKind::Start, true);
while let Some(node) = queue.pop_front() {
if let Some(neighbors) = self.edges_ref().get(&node) {
for neighbor in neighbors {
if !reachable.contains_key(neighbor) {
reachable.insert(neighbor.clone(), true);
queue.push_back(neighbor.clone());
}
}
}
}
// Find registered Custom nodes that are not reachable
let mut unreachable: Vec<NodeKind> = self
.nodes_ref()
.keys()
.filter(|node| !reachable.contains_key(node))
.cloned()
.collect();
unreachable.sort_by_key(|a| a.to_string());
unreachable
}
/// Detects nodes with no path to End.
///
/// Only checks unconditional edges. Returns registered Custom nodes that
/// cannot reach End via unconditional edges.
fn detect_no_path_to_end(&self) -> Vec<NodeKind> {
use std::collections::VecDeque;
// Build reverse graph (for backward traversal from End)
let mut reverse_edges: FxHashMap<NodeKind, Vec<NodeKind>> = FxHashMap::default();
for (from, tos) in self.edges_ref() {
for to in tos {
reverse_edges
.entry(to.clone())
.or_default()
.push(from.clone());
}
}
let mut can_reach_end: FxHashMap<NodeKind, bool> = FxHashMap::default();
let mut queue: VecDeque<NodeKind> = VecDeque::new();
// Start BFS from End node (backward)
queue.push_back(NodeKind::End);
can_reach_end.insert(NodeKind::End, true);
while let Some(node) = queue.pop_front() {
if let Some(predecessors) = reverse_edges.get(&node) {
for predecessor in predecessors {
if !can_reach_end.contains_key(predecessor) {
can_reach_end.insert(predecessor.clone(), true);
queue.push_back(predecessor.clone());
}
}
}
}
// Find registered Custom nodes that cannot reach End
let mut no_path: Vec<NodeKind> = self
.nodes_ref()
.keys()
.filter(|node| !can_reach_end.contains_key(node))
.cloned()
.collect();
no_path.sort_by_key(|a| a.to_string());
no_path
}
/// Detects duplicate edges in the graph.
///
/// Returns the first duplicate edge found.
fn detect_duplicate_edge(&self) -> Option<(NodeKind, NodeKind)> {
use rustc_hash::FxHashSet;
for (from, tos) in self.edges_ref() {
let mut seen: FxHashSet<NodeKind> = FxHashSet::default();
for to in tos {
if !seen.insert(to.clone()) {
// Found a duplicate
return Some((from.clone(), to.clone()));
}
}
}
None
}
/// Validates the graph for common structural issues.
///
/// Validation rules:
/// - There must be at least one entry edge from Start (unconditional or conditional)
/// - No edge may originate from End
/// - Any Custom node referenced by an edge (as from/to) must be registered
/// - The graph must not contain cycles (checked on unconditional edges only)
/// - All registered nodes must be reachable from Start (unconditional edges only)
/// - All registered nodes must have a path to End (unconditional edges only)
/// - No duplicate edges are allowed
pub fn validate(&self) -> Result<(), GraphCompileError> {
// Rule 1: Entry edge from Start exists (either unconditional or conditional)
let has_start_edge = self
.edges_ref()
.get(&NodeKind::Start)
.map(|v| !v.is_empty())
.unwrap_or(false)
|| self
.conditional_edges_ref()
.iter()
.any(|ce| ce.from() == &NodeKind::Start);
if !has_start_edge {
return Err(GraphCompileError::MissingEntry);
}
// Rule 2: Detect cycles in unconditional edges
if let Some(cycle) = self.detect_cycle() {
return Err(GraphCompileError::CycleDetected { cycle });
}
// Rule 3 and 4: Reachability validations (skip when conditional edges exist)
let has_conditional = !self.conditional_edges_ref().is_empty();
if !has_conditional {
// Detect unreachable nodes
let unreachable = self.detect_unreachable_nodes();
if !unreachable.is_empty() {
return Err(GraphCompileError::UnreachableNodes { nodes: unreachable });
}
// Detect nodes with no path to End
let no_path_to_end = self.detect_no_path_to_end();
if !no_path_to_end.is_empty() {
return Err(GraphCompileError::NoPathToEnd {
nodes: no_path_to_end,
});
}
}
// Rule 5: Detect duplicate edges
if let Some((from, to)) = self.detect_duplicate_edge() {
return Err(GraphCompileError::DuplicateEdge { from, to });
}
// Rule 6 and 7: Validate each unconditional edge
for (from, tos) in self.edges_ref() {
// End cannot have outgoing edges
if matches!(from, NodeKind::End) {
return Err(GraphCompileError::EdgeFromEnd);
}
// If from is Custom, it must be registered
if let NodeKind::Custom(_) = from
&& !self.nodes_ref().contains_key(from)
{
return Err(GraphCompileError::UnknownNode(from.clone()));
}
for to in tos {
// If to is Custom, it must be registered
if let NodeKind::Custom(_) = to
&& !self.nodes_ref().contains_key(to)
{
return Err(GraphCompileError::UnknownNode(to.clone()));
}
}
}
Ok(())
}
}