graph_sp/inspector/
mod.rs

1//! Graph inspection and analysis tools.
2
3use crate::core::{Graph, Result};
4use std::collections::{HashMap, HashSet};
5
6/// Graph inspector for analyzing and optimizing graphs
7pub struct Inspector;
8
9impl Inspector {
10    /// Analyze a graph and return statistics
11    pub fn analyze(graph: &Graph) -> GraphAnalysis {
12        let node_count = graph.node_count();
13        let edge_count = graph.edge_count();
14
15        // Calculate depth and width
16        let (depth, width) = Self::calculate_dimensions(graph);
17
18        // Find source and sink nodes
19        let sources = Self::find_source_nodes(graph);
20        let sinks = Self::find_sink_nodes(graph);
21
22        // Calculate complexity metrics
23        let avg_connections = if node_count > 0 {
24            edge_count as f64 / node_count as f64
25        } else {
26            0.0
27        };
28
29        GraphAnalysis {
30            node_count,
31            edge_count,
32            depth,
33            width,
34            source_nodes: sources,
35            sink_nodes: sinks,
36            avg_connections_per_node: avg_connections,
37            has_cycles: graph.validate().is_err(),
38        }
39    }
40
41    /// Find source nodes (nodes with no incoming edges)
42    fn find_source_nodes(graph: &Graph) -> Vec<String> {
43        graph
44            .nodes()
45            .iter()
46            .filter(|node| {
47                graph
48                    .incoming_edges(&node.config.id)
49                    .map(|edges| edges.is_empty())
50                    .unwrap_or(false)
51            })
52            .map(|node| node.config.id.clone())
53            .collect()
54    }
55
56    /// Find sink nodes (nodes with no outgoing edges)
57    fn find_sink_nodes(graph: &Graph) -> Vec<String> {
58        graph
59            .nodes()
60            .iter()
61            .filter(|node| {
62                graph
63                    .outgoing_edges(&node.config.id)
64                    .map(|edges| edges.is_empty())
65                    .unwrap_or(false)
66            })
67            .map(|node| node.config.id.clone())
68            .collect()
69    }
70
71    /// Calculate graph depth (longest path) and width (max nodes at same level)
72    fn calculate_dimensions(graph: &Graph) -> (usize, usize) {
73        // Get topological order to determine levels
74        let order = match graph.topological_order() {
75            Ok(o) => o,
76            Err(_) => return (0, 0),
77        };
78
79        if order.is_empty() {
80            return (0, 0);
81        }
82
83        // Calculate level for each node
84        let mut levels: HashMap<String, usize> = HashMap::new();
85
86        for node_id in &order {
87            // Find max level of predecessors
88            let incoming = graph.incoming_edges(node_id).unwrap_or_default();
89            let max_pred_level = incoming
90                .iter()
91                .filter_map(|edge| levels.get(&edge.from_node))
92                .max()
93                .copied()
94                .unwrap_or(0);
95
96            let level = if incoming.is_empty() {
97                0
98            } else {
99                max_pred_level + 1
100            };
101            levels.insert(node_id.clone(), level);
102        }
103
104        let depth = levels.values().max().copied().unwrap_or(0) + 1;
105
106        // Calculate width (max nodes at same level)
107        let mut level_counts: HashMap<usize, usize> = HashMap::new();
108        for level in levels.values() {
109            *level_counts.entry(*level).or_insert(0) += 1;
110        }
111        let width = level_counts.values().max().copied().unwrap_or(0);
112
113        (depth, width)
114    }
115
116    /// Suggest optimizations for the graph
117    pub fn suggest_optimizations(graph: &Graph) -> Vec<Optimization> {
118        let mut suggestions = Vec::new();
119
120        // Check for isolated nodes
121        for node in graph.nodes() {
122            let incoming = graph.incoming_edges(&node.config.id).unwrap_or_default();
123            let outgoing = graph.outgoing_edges(&node.config.id).unwrap_or_default();
124
125            if incoming.is_empty() && outgoing.is_empty() && graph.node_count() > 1 {
126                suggestions.push(Optimization {
127                    optimization_type: OptimizationType::RemoveIsolatedNode,
128                    description: format!("Node '{}' is isolated (no connections)", node.config.id),
129                    node_ids: vec![node.config.id.clone()],
130                });
131            }
132        }
133
134        // Check for redundant edges (multiple edges between same nodes)
135        let mut connections: HashSet<(String, String)> = HashSet::new();
136        for edge in graph.edges() {
137            let pair = (edge.from_node.clone(), edge.to_node.clone());
138            if connections.contains(&pair) {
139                suggestions.push(Optimization {
140                    optimization_type: OptimizationType::RemoveRedundantEdge,
141                    description: format!(
142                        "Multiple edges between '{}' and '{}'",
143                        edge.from_node, edge.to_node
144                    ),
145                    node_ids: vec![edge.from_node.clone(), edge.to_node.clone()],
146                });
147            }
148            connections.insert(pair);
149        }
150
151        suggestions
152    }
153
154    /// Visualize graph structure as a simple text representation
155    pub fn visualize(graph: &Graph) -> Result<String> {
156        let order = graph.topological_order()?;
157        let mut output = String::new();
158
159        output.push_str("Graph Structure:\n");
160        output.push_str("================\n\n");
161
162        for node_id in order {
163            let node = graph.get_node(&node_id)?;
164            output.push_str(&format!(
165                "Node: {} ({})\n",
166                node.config.name, node.config.id
167            ));
168
169            // Show inputs
170            if !node.config.input_ports.is_empty() {
171                output.push_str("  Inputs:\n");
172                for port in &node.config.input_ports {
173                    let required = if port.required { "*" } else { "" };
174                    output.push_str(&format!(
175                        "    - {}{} ({})\n",
176                        port.display_name, required, port.broadcast_name
177                    ));
178                }
179            }
180
181            // Show outputs
182            if !node.config.output_ports.is_empty() {
183                output.push_str("  Outputs:\n");
184                for port in &node.config.output_ports {
185                    output.push_str(&format!(
186                        "    - {} ({})\n",
187                        port.display_name, port.broadcast_name
188                    ));
189                }
190            }
191
192            // Show connections
193            let outgoing = graph.outgoing_edges(&node_id)?;
194            if !outgoing.is_empty() {
195                output.push_str("  Connections:\n");
196                for edge in outgoing {
197                    output.push_str(&format!(
198                        "    - {} -> {}:{}\n",
199                        edge.from_port, edge.to_node, edge.to_port
200                    ));
201                }
202            }
203
204            output.push('\n');
205        }
206
207        Ok(output)
208    }
209
210    /// Generate a Mermaid diagram representation of the graph
211    pub fn to_mermaid(graph: &Graph) -> Result<String> {
212        let mut output = String::new();
213
214        output.push_str("```mermaid\n");
215        output.push_str("graph TD\n");
216
217        // Detect parallel execution patterns (fan-out/fan-in)
218        let parallel_groups = Self::detect_parallel_groups(graph);
219
220        // Check for branches (variants)
221        let branch_names = graph.branch_names();
222        let has_branches = !branch_names.is_empty();
223
224        // Add nodes with styling
225        for node in graph.nodes() {
226            let node_id = &node.config.id;
227            let node_name = &node.config.name;
228
229            // Sanitize node ID for Mermaid (replace special chars)
230            let safe_id = node_id.replace(['-', ' '], "_");
231
232            // Replace literal \n with <br/> for proper line breaks in Mermaid
233            let formatted_name = node_name.replace("\\n", "<br/>");
234
235            // Style nodes based on whether they're source/sink
236            let incoming = graph.incoming_edges(node_id).unwrap_or_default();
237            let outgoing = graph.outgoing_edges(node_id).unwrap_or_default();
238
239            if incoming.is_empty() && !outgoing.is_empty() {
240                // Source node
241                output.push_str(&format!("    {}[\"{}\"]\n", safe_id, formatted_name));
242                output.push_str(&format!(
243                    "    style {} fill:#e1f5ff,stroke:#01579b,stroke-width:2px\n",
244                    safe_id
245                ));
246            } else if outgoing.is_empty() && !incoming.is_empty() {
247                // Sink node (potential merge point)
248                output.push_str(&format!("    {}[\"{}\"]\n", safe_id, formatted_name));
249                output.push_str(&format!(
250                    "    style {} fill:#f3e5f5,stroke:#4a148c,stroke-width:2px\n",
251                    safe_id
252                ));
253            } else {
254                // Processing node
255                output.push_str(&format!("    {}[\"{}\"]\n", safe_id, formatted_name));
256                output.push_str(&format!(
257                    "    style {} fill:#fff3e0,stroke:#e65100,stroke-width:2px\n",
258                    safe_id
259                ));
260            }
261        }
262
263        // Add comments for parallel execution groups if detected
264        if !parallel_groups.is_empty() {
265            output.push('\n');
266            output.push_str("    %% Parallel Execution Groups Detected\n");
267            for (i, group) in parallel_groups.iter().enumerate() {
268                output.push_str(&format!(
269                    "    %% Group {}: {} nodes executing in parallel\n",
270                    i + 1,
271                    group.parallel_nodes.len()
272                ));
273            }
274        }
275
276        // Group parallel branches using subgraphs for better visualization
277        for (i, group) in parallel_groups.iter().enumerate() {
278            output.push('\n');
279            output.push_str(&format!(
280                "    subgraph parallel_group_{}[\"⚡ Parallel Execution Group {}\"]\n",
281                i + 1,
282                i + 1
283            ));
284            output.push_str("        direction LR\n");
285            for node_id in &group.parallel_nodes {
286                let safe_id = node_id.replace(['-', ' '], "_");
287                output.push_str(&format!("        {}\n", safe_id));
288            }
289            output.push_str("    end\n");
290            output.push_str(&format!(
291                "    style parallel_group_{} fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,stroke-dasharray: 5 5\n",
292                i + 1
293            ));
294        }
295
296        // Add variant branches as special nodes
297        if has_branches {
298            output.push('\n');
299            output.push_str("    %% Variant Branches\n");
300
301            // Group variants by prefix (detect variant sets)
302            let mut variant_groups: HashMap<String, Vec<String>> = HashMap::new();
303            for branch_name in &branch_names {
304                // Try to extract prefix (e.g., "lr_0" -> "lr")
305                if let Some(underscore_pos) = branch_name.rfind('_') {
306                    // Ensure there's at least one character after the underscore
307                    if underscore_pos + 1 < branch_name.len() {
308                        let prefix = &branch_name[..underscore_pos];
309                        // Check if the suffix is a number
310                        if branch_name[underscore_pos + 1..].parse::<usize>().is_ok() {
311                            variant_groups
312                                .entry(prefix.to_string())
313                                .or_default()
314                                .push(branch_name.clone());
315                            continue;
316                        }
317                    }
318                }
319                // If not a numbered variant, add as single branch
320                variant_groups
321                    .entry(branch_name.clone())
322                    .or_default()
323                    .push(branch_name.clone());
324            }
325
326            // Render variant groups
327            for (prefix, branches) in &variant_groups {
328                let safe_prefix = prefix.replace(['-', ' '], "_");
329                if branches.len() > 1 {
330                    // Multiple variants - use hexagon shape
331                    output.push_str(&format!(
332                        "    {}{{{{\"{}\\n{} variants\"}}}}\n",
333                        safe_prefix,
334                        prefix.replace('_', " "),
335                        branches.len()
336                    ));
337                    output.push_str(&format!(
338                        "    style {} fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,stroke-dasharray: 5 5\n",
339                        safe_prefix
340                    ));
341                } else {
342                    // Single branch - use regular rectangle with different style
343                    output.push_str(&format!(
344                        "    {}[\"Branch: {}\"]\n",
345                        safe_prefix,
346                        prefix.replace('_', " ")
347                    ));
348                    output.push_str(&format!(
349                        "    style {} fill:#fff9c4,stroke:#f57f17,stroke-width:2px\n",
350                        safe_prefix
351                    ));
352                }
353            }
354        }
355
356        output.push('\n');
357
358        // Add edges with labels
359        for edge in graph.edges() {
360            let from_safe = edge.from_node.replace(['-', ' '], "_");
361            let to_safe = edge.to_node.replace(['-', ' '], "_");
362            let label = format!("{}→{}", edge.from_port, edge.to_port);
363
364            output.push_str(&format!(
365                "    {} -->|\"{}\"| {}\n",
366                from_safe, label, to_safe
367            ));
368        }
369
370        output.push_str("```\n");
371
372        Ok(output)
373    }
374
375    /// Detect parallel execution groups (fan-out/fan-in patterns)
376    fn detect_parallel_groups(graph: &Graph) -> Vec<ParallelGroup> {
377        let mut groups = Vec::new();
378
379        // Find nodes that fan out to multiple nodes at the same level
380        for node in graph.nodes() {
381            let outgoing = graph.outgoing_edges(&node.config.id).unwrap_or_default();
382
383            // Check if this node fans out to multiple nodes
384            if outgoing.len() > 1 {
385                // Get all target nodes
386                let target_nodes: Vec<String> =
387                    outgoing.iter().map(|e| e.to_node.clone()).collect();
388
389                // Check if these nodes converge to a common node (fan-in)
390                let mut common_targets: HashMap<String, usize> = HashMap::new();
391                for target_id in &target_nodes {
392                    if let Ok(target_outgoing) = graph.outgoing_edges(target_id) {
393                        for edge in target_outgoing {
394                            *common_targets.entry(edge.to_node.clone()).or_insert(0) += 1;
395                        }
396                    }
397                }
398
399                // If multiple nodes converge to the same target, we have a fan-in
400                let merge_nodes: Vec<String> = common_targets
401                    .iter()
402                    .filter(|(_, &count)| count > 1)
403                    .map(|(node, _)| node.clone())
404                    .collect();
405
406                if !merge_nodes.is_empty() {
407                    groups.push(ParallelGroup {
408                        source_node: node.config.id.clone(),
409                        parallel_nodes: target_nodes,
410                        merge_nodes,
411                    });
412                }
413            }
414        }
415
416        groups
417    }
418}
419
420/// Analysis results for a graph
421#[derive(Debug, Clone)]
422pub struct GraphAnalysis {
423    /// Total number of nodes
424    pub node_count: usize,
425    /// Total number of edges
426    pub edge_count: usize,
427    /// Maximum depth (longest path from source to sink)
428    pub depth: usize,
429    /// Maximum width (max nodes at same level)
430    pub width: usize,
431    /// Source nodes (no incoming edges)
432    pub source_nodes: Vec<String>,
433    /// Sink nodes (no outgoing edges)
434    pub sink_nodes: Vec<String>,
435    /// Average connections per node
436    pub avg_connections_per_node: f64,
437    /// Whether the graph has cycles
438    pub has_cycles: bool,
439}
440
441impl GraphAnalysis {
442    /// Get a summary string
443    pub fn summary(&self) -> String {
444        format!(
445            "Nodes: {}, Edges: {}, Depth: {}, Width: {}, Sources: {}, Sinks: {}, Avg Connections: {:.2}, Cycles: {}",
446            self.node_count,
447            self.edge_count,
448            self.depth,
449            self.width,
450            self.source_nodes.len(),
451            self.sink_nodes.len(),
452            self.avg_connections_per_node,
453            if self.has_cycles { "Yes" } else { "No" }
454        )
455    }
456}
457
458/// Suggested optimization for a graph
459#[derive(Debug, Clone)]
460pub struct Optimization {
461    /// Type of optimization
462    pub optimization_type: OptimizationType,
463    /// Human-readable description
464    pub description: String,
465    /// Node IDs involved in the optimization
466    pub node_ids: Vec<String>,
467}
468
469/// Types of optimizations
470#[derive(Debug, Clone, PartialEq)]
471pub enum OptimizationType {
472    /// Remove an isolated node
473    RemoveIsolatedNode,
474    /// Remove a redundant edge
475    RemoveRedundantEdge,
476    /// Merge nodes
477    MergeNodes,
478    /// Parallelize independent branches
479    ParallelizeBranches,
480}
481
482/// Represents a parallel execution group (fan-out/fan-in pattern)
483#[derive(Debug, Clone)]
484struct ParallelGroup {
485    /// Source node that fans out
486    #[allow(dead_code)]
487    source_node: String,
488    /// Nodes executing in parallel
489    parallel_nodes: Vec<String>,
490    /// Nodes where parallel branches merge
491    #[allow(dead_code)]
492    merge_nodes: Vec<String>,
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::core::{Edge, Node, NodeConfig, Port};
499    use std::collections::HashMap;
500    use std::sync::Arc;
501
502    fn dummy_function(
503        _inputs: &HashMap<String, crate::core::PortData>,
504    ) -> Result<HashMap<String, crate::core::PortData>> {
505        Ok(HashMap::new())
506    }
507
508    #[test]
509    fn test_analyze_empty_graph() {
510        let graph = Graph::new();
511        let analysis = Inspector::analyze(&graph);
512
513        assert_eq!(analysis.node_count, 0);
514        assert_eq!(analysis.edge_count, 0);
515        assert_eq!(analysis.depth, 0);
516        assert_eq!(analysis.width, 0);
517    }
518
519    #[test]
520    fn test_find_source_and_sink_nodes() {
521        let mut graph = Graph::new();
522
523        // Create linear graph: source -> middle -> sink
524        let config1 = NodeConfig::new(
525            "source",
526            "Source",
527            vec![],
528            vec![Port::new("out", "Output")],
529            Arc::new(dummy_function),
530        );
531
532        let config2 = NodeConfig::new(
533            "middle",
534            "Middle",
535            vec![Port::new("in", "Input")],
536            vec![Port::new("out", "Output")],
537            Arc::new(dummy_function),
538        );
539
540        let config3 = NodeConfig::new(
541            "sink",
542            "Sink",
543            vec![Port::new("in", "Input")],
544            vec![],
545            Arc::new(dummy_function),
546        );
547
548        graph.add(Node::new(config1)).unwrap();
549        graph.add(Node::new(config2)).unwrap();
550        graph.add(Node::new(config3)).unwrap();
551
552        graph
553            .add_edge(Edge::new("source", "out", "middle", "in"))
554            .unwrap();
555        graph
556            .add_edge(Edge::new("middle", "out", "sink", "in"))
557            .unwrap();
558
559        let analysis = Inspector::analyze(&graph);
560
561        assert_eq!(analysis.source_nodes.len(), 1);
562        assert_eq!(analysis.source_nodes[0], "source");
563        assert_eq!(analysis.sink_nodes.len(), 1);
564        assert_eq!(analysis.sink_nodes[0], "sink");
565        assert_eq!(analysis.depth, 3);
566        assert_eq!(analysis.width, 1);
567    }
568
569    #[test]
570    fn test_suggest_optimizations_isolated_node() {
571        let mut graph = Graph::new();
572
573        let config1 = NodeConfig::new("node1", "Node 1", vec![], vec![], Arc::new(dummy_function));
574
575        let config2 = NodeConfig::new(
576            "node2",
577            "Node 2",
578            vec![],
579            vec![Port::new("out", "Output")],
580            Arc::new(dummy_function),
581        );
582
583        graph.add(Node::new(config1)).unwrap();
584        graph.add(Node::new(config2)).unwrap();
585
586        let optimizations = Inspector::suggest_optimizations(&graph);
587
588        assert!(!optimizations.is_empty());
589        assert!(optimizations
590            .iter()
591            .any(|o| o.optimization_type == OptimizationType::RemoveIsolatedNode));
592    }
593
594    #[test]
595    fn test_mermaid_newline_replacement() {
596        let mut graph = Graph::new();
597
598        // Create a node with \n in the name
599        let config = NodeConfig::new(
600            "test_node",
601            "Line 1\\nLine 2\\nLine 3",
602            vec![],
603            vec![Port::new("out", "Output")],
604            Arc::new(dummy_function),
605        );
606
607        graph.add(Node::new(config)).unwrap();
608
609        let mermaid = Inspector::to_mermaid(&graph).unwrap();
610
611        // Should replace \n with <br/>
612        assert!(mermaid.contains("<br/>"));
613        assert!(!mermaid.contains("\\n"));
614    }
615
616    #[test]
617    fn test_mermaid_parallel_group_detection() {
618        let mut graph = Graph::new();
619
620        // Create a fan-out/fan-in pattern
621        let source = NodeConfig::new(
622            "source",
623            "Source",
624            vec![],
625            vec![Port::new("out", "Output")],
626            Arc::new(dummy_function),
627        );
628
629        let branch1 = NodeConfig::new(
630            "branch1",
631            "Branch 1",
632            vec![Port::new("in", "Input")],
633            vec![Port::new("out", "Output")],
634            Arc::new(dummy_function),
635        );
636
637        let branch2 = NodeConfig::new(
638            "branch2",
639            "Branch 2",
640            vec![Port::new("in", "Input")],
641            vec![Port::new("out", "Output")],
642            Arc::new(dummy_function),
643        );
644
645        let merger = NodeConfig::new(
646            "merger",
647            "Merger",
648            vec![Port::new("in1", "Input 1"), Port::new("in2", "Input 2")],
649            vec![],
650            Arc::new(dummy_function),
651        );
652
653        graph.add(Node::new(source)).unwrap();
654        graph.add(Node::new(branch1)).unwrap();
655        graph.add(Node::new(branch2)).unwrap();
656        graph.add(Node::new(merger)).unwrap();
657
658        // Fan-out from source
659        graph
660            .add_edge(Edge::new("source", "out", "branch1", "in"))
661            .unwrap();
662        graph
663            .add_edge(Edge::new("source", "out", "branch2", "in"))
664            .unwrap();
665
666        // Fan-in to merger
667        graph
668            .add_edge(Edge::new("branch1", "out", "merger", "in1"))
669            .unwrap();
670        graph
671            .add_edge(Edge::new("branch2", "out", "merger", "in2"))
672            .unwrap();
673
674        let mermaid = Inspector::to_mermaid(&graph).unwrap();
675
676        // Should detect parallel group
677        assert!(mermaid.contains("Parallel Execution Groups Detected"));
678        assert!(mermaid.contains("subgraph parallel_group_"));
679        assert!(mermaid.contains("⚡ Parallel Execution Group"));
680    }
681
682    #[test]
683    fn test_mermaid_node_styling() {
684        let mut graph = Graph::new();
685
686        // Source node (no incoming edges)
687        let source = NodeConfig::new(
688            "source",
689            "Source",
690            vec![],
691            vec![Port::new("out", "Output")],
692            Arc::new(dummy_function),
693        );
694
695        // Processing node (both incoming and outgoing)
696        let processor = NodeConfig::new(
697            "processor",
698            "Processor",
699            vec![Port::new("in", "Input")],
700            vec![Port::new("out", "Output")],
701            Arc::new(dummy_function),
702        );
703
704        // Sink node (no outgoing edges)
705        let sink = NodeConfig::new(
706            "sink",
707            "Sink",
708            vec![Port::new("in", "Input")],
709            vec![],
710            Arc::new(dummy_function),
711        );
712
713        graph.add(Node::new(source)).unwrap();
714        graph.add(Node::new(processor)).unwrap();
715        graph.add(Node::new(sink)).unwrap();
716
717        graph
718            .add_edge(Edge::new("source", "out", "processor", "in"))
719            .unwrap();
720        graph
721            .add_edge(Edge::new("processor", "out", "sink", "in"))
722            .unwrap();
723
724        let mermaid = Inspector::to_mermaid(&graph).unwrap();
725
726        // Should have different styles for source, processor, and sink
727        assert!(mermaid.contains("#e1f5ff")); // Source color
728        assert!(mermaid.contains("#fff3e0")); // Processor color
729        assert!(mermaid.contains("#f3e5f5")); // Sink color
730    }
731
732    #[test]
733    fn test_mermaid_edge_labels() {
734        let mut graph = Graph::new();
735
736        let source = NodeConfig::new(
737            "source",
738            "Source",
739            vec![],
740            vec![Port::new("output_port", "Output")],
741            Arc::new(dummy_function),
742        );
743
744        let sink = NodeConfig::new(
745            "sink",
746            "Sink",
747            vec![Port::new("input_port", "Input")],
748            vec![],
749            Arc::new(dummy_function),
750        );
751
752        graph.add(Node::new(source)).unwrap();
753        graph.add(Node::new(sink)).unwrap();
754
755        graph
756            .add_edge(Edge::new("source", "output_port", "sink", "input_port"))
757            .unwrap();
758
759        let mermaid = Inspector::to_mermaid(&graph).unwrap();
760
761        // Should have edge label with port names
762        assert!(mermaid.contains("output_port→input_port"));
763    }
764}